@musnows/scriverse 0.9.0 → 0.9.2

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.
@@ -1517,9 +1517,16 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1517
1517
  .record-card { position: relative; border: 1px solid var(--line); background: var(--surface-soft); padding: 18px; min-height: 130px; border-radius: 4px; }
1518
1518
  .record-card.has-card-edit h3 { padding-right: 34px; }
1519
1519
  .record-card.has-favorite-control h3 { padding-right: 70px; }
1520
+ .record-card.has-pin-control h3 { padding-right: 106px; }
1520
1521
  .record-card-edit { position: absolute; top: 12px; right: 12px; display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1521
1522
  .record-card-edit:hover, .record-card-edit:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1522
1523
  .record-card-edit svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.7; }
1524
+ .character-pin-button { position: absolute; top: 12px; right: 84px; display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1525
+ .character-pin-button:hover, .character-pin-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1526
+ .character-pin-button.is-pinned { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
1527
+ .character-pin-button:disabled { cursor: not-allowed; opacity: .5; }
1528
+ .character-pin-button svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1529
+ .character-pin-button.is-pinned svg { fill: currentColor; }
1523
1530
  .character-favorite-button { position: absolute; top: 12px; right: 48px; display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1524
1531
  .character-favorite-button:hover, .character-favorite-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1525
1532
  .character-favorite-button.is-favorite { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
@@ -1533,12 +1540,25 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1533
1540
  .record-favorite-button:disabled { cursor: not-allowed; opacity: .5; }
1534
1541
  .record-favorite-button svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1535
1542
  .record-favorite-button.is-favorite svg { fill: currentColor; }
1543
+ .record-pin-button { display: inline-grid; flex: 0 0 auto; place-items: center; width: 28px; height: 28px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1544
+ .record-pin-button.is-card-control { position: absolute; top: 12px; right: 84px; }
1545
+ .record-pin-button:hover, .record-pin-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1546
+ .record-pin-button.is-pinned { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
1547
+ .record-pin-button:disabled { cursor: not-allowed; opacity: .5; }
1548
+ .record-pin-button svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1549
+ .record-pin-button.is-pinned svg { fill: currentColor; }
1536
1550
  .entity-detail-favorite-button { display: inline-grid; flex: 0 0 36px; place-items: center; width: 36px; min-width: 36px; height: 36px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1537
1551
  .entity-detail-favorite-button:hover, .entity-detail-favorite-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1538
1552
  .entity-detail-favorite-button.is-favorite { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
1539
1553
  .entity-detail-favorite-button:disabled { cursor: not-allowed; opacity: .5; }
1540
1554
  .entity-detail-favorite-button svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1541
1555
  .entity-detail-favorite-button.is-favorite svg { fill: currentColor; }
1556
+ .entity-detail-pin-button { display: inline-grid; flex: 0 0 36px; place-items: center; width: 36px; min-width: 36px; height: 36px; padding: 0; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); }
1557
+ .entity-detail-pin-button:hover, .entity-detail-pin-button:focus-visible { border-color: var(--accent); color: var(--accent-dark); outline: none; }
1558
+ .entity-detail-pin-button.is-pinned { border-color: color-mix(in srgb, var(--accent) 58%, var(--line)); background: color-mix(in srgb, var(--accent) 10%, var(--surface)); color: var(--accent-dark); }
1559
+ .entity-detail-pin-button:disabled { cursor: not-allowed; opacity: .5; }
1560
+ .entity-detail-pin-button svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.6; }
1561
+ .entity-detail-pin-button.is-pinned svg { fill: currentColor; }
1542
1562
  .record-card h3 { font-size: 16px; margin: 5px 0 10px; font-weight: 600; }
1543
1563
  .record-card p { color: var(--muted); font-size: 12px; line-height: 1.55; margin: 0; white-space: pre-wrap; }
1544
1564
  .record-card small { color: var(--accent); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; }
@@ -1573,11 +1593,21 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1573
1593
  .character-row { grid-template-columns: minmax(140px, .28fr) minmax(0, 1fr) auto; }
1574
1594
  .character-card-heading { display: flex; min-width: 0; align-items: center; gap: 7px; }
1575
1595
  .character-card-heading .character-avatar { width: 32px; height: 32px; font-size: 12px; }
1576
- .character-card-heading h3 { min-width: 0; }
1577
- .record-card.has-card-edit .character-card-heading { padding-right: 70px; }
1596
+ .character-card-heading h3 { flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
1597
+ .record-card.has-card-edit .character-card-heading { padding-right: 70px; transform: translateY(-3px); }
1598
+ .record-card.has-card-edit.has-pin-control .character-card-heading { padding-right: 106px; }
1578
1599
  .record-card.has-card-edit .character-card-heading h3 { padding-right: 0; }
1600
+ .character-card.has-card-edit > .character-pin-button, .character-card.has-card-edit > .character-favorite-button, .character-card.has-card-edit > .record-card-edit { top: 18px; }
1579
1601
  .module-row .character-favorite-button { position: static; }
1602
+ .module-row .character-pin-button { position: static; }
1580
1603
  .module-row .record-favorite-button { position: static; }
1604
+ .module-row .record-pin-button { position: static; }
1605
+ @media (max-width: 560px) {
1606
+ .character-card-heading h3 { font-size: 15px; }
1607
+ }
1608
+ @media (max-width: 420px) {
1609
+ .character-card-heading h3 { font-size: 14px; }
1610
+ }
1581
1611
  .character-lock-badge { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 3px; min-height: 20px; padding: 2px 6px; border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent-dark); font-size: 9px; font-variant-numeric: tabular-nums; line-height: 1; }
1582
1612
  .entity-lifecycle-badge { display: inline-flex; align-items: center; min-height: 20px; margin-left: 7px; padding: 2px 7px; border: 1px solid color-mix(in srgb, var(--accent) 58%, var(--line)); border-radius: 10px; background: color-mix(in srgb, var(--accent) 13%, var(--surface)); color: var(--accent-dark); font-size: 9px; font-weight: 650; line-height: 1; vertical-align: middle; white-space: nowrap; }
1583
1613
  .character-lock-badge svg { width: 11px; height: 11px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; }
@@ -3009,8 +3039,15 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
3009
3039
  animation: toast-appear-top .18s ease;
3010
3040
  pointer-events: auto;
3011
3041
  }
3012
- .toast:not(.toast-confirmation):not(.chapter-insight-toast) { cursor: pointer; }
3042
+ .toast:not(.toast-confirmation):not(.chapter-insight-toast):not(.import-progress-toast) { cursor: pointer; }
3013
3043
  .toast.ai-new-conversation-toast { white-space: pre-line; }
3044
+ .import-progress-region,
3045
+ .import-progress-region:popover-open { top: auto; right: 24px; bottom: 24px; left: auto; }
3046
+ .import-progress-toast { display: grid; gap: 8px; width: min(360px, calc(100vw - 48px)); cursor: progress; }
3047
+ .import-progress-copy { display: grid; gap: 3px; }
3048
+ .import-progress-copy strong { font-size: 12px; }
3049
+ .import-progress-status { color: color-mix(in srgb, var(--toast-fg) 78%, transparent); font-size: 10px; }
3050
+ .import-progress-toast progress { width: 100%; height: 6px; accent-color: var(--accent); }
3014
3051
  .toast-region[data-position="bottom-right"] .toast { animation-name: toast-appear-bottom; }
3015
3052
  .toast.error {
3016
3053
  border-color: color-mix(in srgb, var(--toast-error-fg) 16%, transparent);
@@ -4066,6 +4103,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
4066
4103
  .module-content { padding: 18px 0 56px; }
4067
4104
  .card-grid, .provider-card-grid { grid-template-columns: minmax(0, 1fr); }
4068
4105
  .record-card { padding: 15px; }
4106
+ .character-card.has-card-edit > .character-pin-button, .character-card.has-card-edit > .character-favorite-button, .character-card.has-card-edit > .record-card-edit { top: 8px; }
4069
4107
  .card-actions, .task-row-actions, .module-header-actions { gap: 8px; }
4070
4108
  .card-actions button, .task-row-actions button { flex: 1 1 auto; min-height: 38px; }
4071
4109
  .setting-row, .module-row { padding: 13px; }
@@ -4152,8 +4190,8 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
4152
4190
  .entity-editor-header, .character-editor-header { grid-template-columns: auto minmax(0, 1fr); gap: 8px; padding: 12px var(--mobile-gutter); }
4153
4191
  .entity-editor-mode-actions, .character-editor-header-actions { grid-column: 1 / -1; justify-content: stretch; flex-wrap: wrap; }
4154
4192
  .entity-editor-mode-actions > button, .character-editor-header-actions > button { flex: 1 1 auto; }
4155
- .entity-editor-mode-actions > .entity-detail-favorite-button, .character-editor-header-actions > .entity-detail-favorite-button { flex: 0 0 40px; width: 40px; min-width: 40px; height: 40px; }
4156
- .dialog-context-actions .entity-detail-favorite-button { flex-basis: 40px; width: 40px; min-width: 40px; height: 40px; }
4193
+ .entity-editor-mode-actions > .entity-detail-favorite-button, .entity-editor-mode-actions > .entity-detail-pin-button, .character-editor-header-actions > .entity-detail-favorite-button, .character-editor-header-actions > .entity-detail-pin-button { flex: 0 0 40px; width: 40px; min-width: 40px; height: 40px; }
4194
+ .dialog-context-actions .entity-detail-favorite-button, .dialog-context-actions .entity-detail-pin-button { flex-basis: 40px; width: 40px; min-width: 40px; height: 40px; }
4157
4195
  .setting-editor-header { min-height: 0; }
4158
4196
  .setting-editor-content { padding: 12px; }
4159
4197
  .setting-markdown-field { height: 68dvh; }
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CHARACTER_GENDERS, DRAFT_SETTING_MODULES } from "./domain.js";
2
2
  import { createHash } from "node:crypto";
3
- import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID } from "./database.js";
3
+ import { ENTITY_VERSION_BASELINE_MIGRATION_VERSION, PLATFORM_AI_WORK_ID, SYSTEM_USER_ID } from "./database.js";
4
4
  import { exportWorkDocx } from "./docx-export.js";
5
5
  import { createEpubArchive } from "./epub-export.js";
6
6
  import { AppError, notFound } from "./errors.js";
@@ -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(() => {
@@ -716,11 +800,11 @@ export class Store {
716
800
  this.getWork(workId);
717
801
  if (type === "work") {
718
802
  return this.db.transaction(() => {
719
- const ownerUserId = typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null;
803
+ const ownerUserId = this.resolveWorkOwnerUserId(typeof snapshot.ownerUserId === "string" ? snapshot.ownerUserId : null, true);
720
804
  const timestamp = now();
721
805
  this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, version_no, created_at, updated_at, owner_user_id)
722
806
  VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, entityId, String(snapshot.title ?? "未命名作品"), String(snapshot.author ?? ""), String(snapshot.description ?? ""), String(snapshot.language ?? "zh-CN"), snapshot.coverUrl ?? null, JSON.stringify(Array.isArray(snapshot.tags) ? snapshot.tags : []), timestamp, timestamp, ownerUserId);
723
- if (ownerUserId) {
807
+ if (ownerUserId !== SYSTEM_USER_ID) {
724
808
  this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", entityId, ownerUserId, ownerUserId, timestamp);
725
809
  }
726
810
  const versionNo = this.recordEntityVersion("work", entityId, "restore", sourceRef, changeNote, timestamp);
@@ -779,21 +863,29 @@ export class Store {
779
863
  }
780
864
  }
781
865
  }
782
- createWork(input) {
866
+ createWork(input, ownerUserId = null) {
783
867
  const workId = id("work");
784
868
  const timestamp = now();
785
- const actor = currentRequestActor();
869
+ const resolvedOwnerUserId = this.resolveWorkOwnerUserId(ownerUserId);
786
870
  this.db.transaction(() => {
787
871
  this.db.run(`INSERT INTO works (id, title, author, description, language, cover_url, tags_json, created_at, updated_at, owner_user_id)
788
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), timestamp, timestamp, actor?.userId ?? null);
789
- if (actor) {
790
- this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId, actor.userId, actor.userId, timestamp);
872
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, workId, input.title, input.author ?? "", input.description ?? "", input.language ?? "zh-CN", input.coverUrl ?? null, JSON.stringify(input.tags ?? []), timestamp, timestamp, resolvedOwnerUserId);
873
+ if (resolvedOwnerUserId !== SYSTEM_USER_ID) {
874
+ this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId, resolvedOwnerUserId, resolvedOwnerUserId, timestamp);
791
875
  }
792
876
  this.recordEntityVersion("work", workId, "create", null, "建立作品", timestamp);
793
877
  this.audit(workId, "work.created", "work", workId);
794
878
  });
795
879
  return this.getWork(workId);
796
880
  }
881
+ resolveWorkOwnerUserId(ownerUserId = null, allowUnknownFallback = false) {
882
+ const candidate = ownerUserId ?? currentRequestActor()?.userId ?? null;
883
+ if (candidate && this.db.get("SELECT 1 AS present FROM users WHERE id = ?", candidate))
884
+ return candidate;
885
+ if (candidate && !allowUnknownFallback)
886
+ throw new AppError(400, "WORK_OWNER_INVALID", "作品 Owner 用户不存在");
887
+ return SYSTEM_USER_ID;
888
+ }
797
889
  listWorks() {
798
890
  const actor = currentRequestActor();
799
891
  if (!actor) {
@@ -1618,9 +1710,9 @@ export class Store {
1618
1710
  this.db.transaction(() => { result = this.importNovelInTransaction(workId, fileName, fileType, parsed, mode, expectedVersionNo); });
1619
1711
  return { ...result, tree: this.getWorkDirectory(workId) };
1620
1712
  }
1621
- createImportedWork(input, fileName, fileType, parsed) {
1713
+ createImportedWork(input, fileName, fileType, parsed, ownerUserId = null) {
1622
1714
  return this.db.transaction(() => {
1623
- const work = this.createWork(input);
1715
+ const work = this.createWork(input, ownerUserId);
1624
1716
  const imported = this.importNovelInTransaction(String(work.id), fileName, fileType, parsed, undefined, undefined, false);
1625
1717
  return { ...imported, work: this.getWork(String(work.id)) };
1626
1718
  });
@@ -3748,18 +3840,20 @@ export class Store {
3748
3840
  }
3749
3841
  listDrafts(workId, draftType, includeContent = false) {
3750
3842
  this.getWork(workId);
3751
- return this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3843
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3844
+ return this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3752
3845
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3753
3846
  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));
3847
+ ORDER BY ${preferences.orderBy}, draft.updated_at DESC, draft.title`, ...preferences.params, workId, draftType ?? null, draftType ?? null).map((row) => this.mapDraft(row, includeContent));
3755
3848
  }
3756
3849
  listDraftsPage(workId, pagination, draftType, includeContent = false) {
3757
3850
  this.getWork(workId);
3851
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3758
3852
  const page = paginationSql(pagination);
3759
- const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3853
+ const rows = this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3760
3854
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3761
3855
  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);
3856
+ ORDER BY ${preferences.orderBy}, draft.updated_at DESC, draft.title${page.sql}`, ...preferences.params, workId, draftType ?? null, draftType ?? null, ...page.params);
3763
3857
  return paginated(rows.map((row) => this.mapDraft(row, includeContent)), pagination);
3764
3858
  }
3765
3859
  searchDrafts(workId, query, draftType, limit = 20) {
@@ -3768,22 +3862,24 @@ export class Store {
3768
3862
  const normalizedQuery = query.normalize("NFKC").trim();
3769
3863
  const escapedQuery = escapeSqlLikePattern(normalizedQuery);
3770
3864
  const pattern = `%${escapedQuery}%`;
3865
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3771
3866
  const rows = normalizedQuery
3772
- ? this.db.all(`SELECT draft.*, volume.title AS volume_title FROM drafts draft
3867
+ ? this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3773
3868
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3774
3869
  WHERE draft.work_id = ? AND (? IS NULL OR draft.draft_type = ?)
3775
3870
  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
3871
+ ORDER BY ${preferences.orderBy}, CASE WHEN draft.title LIKE ? ESCAPE '\\' COLLATE NOCASE THEN 0 ELSE 1 END, draft.updated_at DESC
3872
+ LIMIT ?`, ...preferences.params, workId, draftType ?? null, draftType ?? null, pattern, pattern, pattern, safeLimit)
3873
+ : this.db.all(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3779
3874
  LEFT JOIN volumes volume ON volume.id = draft.volume_id
3780
3875
  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);
3876
+ ORDER BY ${preferences.orderBy}, draft.updated_at DESC, draft.title LIMIT ?`, ...preferences.params, workId, draftType ?? null, draftType ?? null, safeLimit);
3782
3877
  return rows.map((row) => this.mapDraft(row, true));
3783
3878
  }
3784
3879
  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);
3880
+ const preferences = this.entityPreferenceProjection("draft", "draft");
3881
+ const row = this.db.get(`SELECT draft.*, volume.title AS volume_title, ${preferences.columns} FROM drafts draft
3882
+ LEFT JOIN volumes volume ON volume.id = draft.volume_id WHERE draft.id = ?`, ...preferences.params, draftId);
3787
3883
  if (!row)
3788
3884
  throw notFound("想法");
3789
3885
  return this.mapDraft(row, true);
@@ -3793,13 +3889,30 @@ export class Store {
3793
3889
  if (!draft)
3794
3890
  throw notFound("想法");
3795
3891
  const workId = requiredString(draft, "work_id");
3796
- const previousFavorite = booleanValue(draft, "is_favorite");
3797
3892
  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
- });
3893
+ const previousFavorite = this.setEntityFavorite(workId, "draft", draftId, isFavorite, booleanValue(draft, "is_favorite"));
3894
+ if (previousFavorite !== isFavorite) {
3895
+ this.audit(workId, "draft.favorite-updated", "draft", draftId, {
3896
+ previousFavorite,
3897
+ isFavorite
3898
+ });
3899
+ }
3900
+ });
3901
+ return this.getDraft(draftId);
3902
+ }
3903
+ setDraftPin(draftId, isPinned) {
3904
+ const draft = this.db.get("SELECT id, work_id FROM drafts WHERE id = ?", draftId);
3905
+ if (!draft)
3906
+ throw notFound("想法");
3907
+ const workId = requiredString(draft, "work_id");
3908
+ this.db.transaction(() => {
3909
+ const previousPin = this.setEntityPin(workId, "draft", draftId, isPinned);
3910
+ if (previousPin !== isPinned) {
3911
+ this.audit(workId, "draft.pin-updated", "draft", draftId, {
3912
+ previousPin,
3913
+ isPinned
3914
+ });
3915
+ }
3803
3916
  });
3804
3917
  return this.getDraft(draftId);
3805
3918
  }
@@ -3841,7 +3954,8 @@ export class Store {
3841
3954
  volumeTitle: optionalString(row, "volume_title"),
3842
3955
  settingModule: optionalString(row, "setting_module"),
3843
3956
  title: requiredString(row, "title"),
3844
- isFavorite: booleanValue(row, "is_favorite"),
3957
+ isFavorite: this.mapEntityFavorite(row),
3958
+ isPinned: this.mapEntityPin(row),
3845
3959
  ...(includeContent ? { content } : { contentPreview: content.replace(/\s+/gu, " ").trim().slice(0, 320) }),
3846
3960
  versionNo: this.currentEntityVersionNo("draft", requiredString(row, "id")),
3847
3961
  createdAt: requiredString(row, "created_at"),
@@ -3905,16 +4019,19 @@ export class Store {
3905
4019
  }
3906
4020
  listSettings(workId, includeContent = true) {
3907
4021
  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));
4022
+ const preferences = this.entityPreferenceProjection("setting", "setting");
4023
+ 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
4024
  }
3910
4025
  listSettingsPage(workId, pagination, includeContent = true) {
3911
4026
  this.getWork(workId);
4027
+ const preferences = this.entityPreferenceProjection("setting", "setting");
3912
4028
  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);
4029
+ 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
4030
  return paginated(rows.map((row) => this.mapSetting(row, includeContent)), pagination);
3915
4031
  }
3916
4032
  getSetting(settingId) {
3917
- const row = this.db.get("SELECT * FROM settings WHERE id = ?", settingId);
4033
+ const preferences = this.entityPreferenceProjection("setting", "setting");
4034
+ const row = this.db.get(`SELECT setting.*, ${preferences.columns} FROM settings setting WHERE setting.id = ?`, ...preferences.params, settingId);
3918
4035
  if (!row)
3919
4036
  throw notFound("设定");
3920
4037
  return this.mapSetting(row);
@@ -3924,13 +4041,30 @@ export class Store {
3924
4041
  if (!setting)
3925
4042
  throw notFound("设定");
3926
4043
  const workId = requiredString(setting, "work_id");
3927
- const previousFavorite = booleanValue(setting, "is_favorite");
3928
4044
  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
- });
4045
+ const previousFavorite = this.setEntityFavorite(workId, "setting", settingId, isFavorite, booleanValue(setting, "is_favorite"));
4046
+ if (previousFavorite !== isFavorite) {
4047
+ this.audit(workId, "setting.favorite-updated", "setting", settingId, {
4048
+ previousFavorite,
4049
+ isFavorite
4050
+ });
4051
+ }
4052
+ });
4053
+ return this.getSetting(settingId);
4054
+ }
4055
+ setSettingPin(settingId, isPinned) {
4056
+ const setting = this.db.get("SELECT id, work_id FROM settings WHERE id = ?", settingId);
4057
+ if (!setting)
4058
+ throw notFound("设定");
4059
+ const workId = requiredString(setting, "work_id");
4060
+ this.db.transaction(() => {
4061
+ const previousPin = this.setEntityPin(workId, "setting", settingId, isPinned);
4062
+ if (previousPin !== isPinned) {
4063
+ this.audit(workId, "setting.pin-updated", "setting", settingId, {
4064
+ previousPin,
4065
+ isPinned
4066
+ });
4067
+ }
3934
4068
  });
3935
4069
  return this.getSetting(settingId);
3936
4070
  }
@@ -3968,7 +4102,8 @@ export class Store {
3968
4102
  tags: json(requiredString(row, "tags_json"), []),
3969
4103
  status: requiredString(row, "status"),
3970
4104
  locked: booleanValue(row, "locked"),
3971
- isFavorite: booleanValue(row, "is_favorite"),
4105
+ isFavorite: this.mapEntityFavorite(row),
4106
+ isPinned: this.mapEntityPin(row),
3972
4107
  evidence: json(requiredString(row, "evidence_json"), []),
3973
4108
  scope: json(requiredString(row, "scope_json"), {}),
3974
4109
  authorNote: requiredString(row, "author_note"),
@@ -4282,19 +4417,22 @@ export class Store {
4282
4417
  }
4283
4418
  listOrganizations(workId, includeMarkdown = true) {
4284
4419
  this.getWork(workId);
4285
- const rows = this.db.all("SELECT * FROM organizations WHERE work_id = ? ORDER BY is_favorite DESC, name", workId);
4420
+ const preferences = this.entityPreferenceProjection("organization", "organization");
4421
+ 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
4422
  const batch = this.organizationListBatch(rows);
4287
4423
  return rows.map((row) => this.mapOrganization(row, includeMarkdown, batch));
4288
4424
  }
4289
4425
  listOrganizationsPage(workId, pagination, includeMarkdown = true) {
4290
4426
  this.getWork(workId);
4427
+ const preferences = this.entityPreferenceProjection("organization", "organization");
4291
4428
  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);
4429
+ 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
4430
  const batch = this.organizationListBatch(rows);
4294
4431
  return paginated(rows.map((row) => this.mapOrganization(row, includeMarkdown, batch)), pagination);
4295
4432
  }
4296
4433
  getOrganization(organizationId) {
4297
- const row = this.db.get("SELECT * FROM organizations WHERE id = ?", organizationId);
4434
+ const preferences = this.entityPreferenceProjection("organization", "organization");
4435
+ const row = this.db.get(`SELECT organization.*, ${preferences.columns} FROM organizations organization WHERE organization.id = ?`, ...preferences.params, organizationId);
4298
4436
  if (!row)
4299
4437
  throw notFound("组织");
4300
4438
  return this.mapOrganization(row);
@@ -4304,13 +4442,30 @@ export class Store {
4304
4442
  if (!organization)
4305
4443
  throw notFound("组织");
4306
4444
  const workId = requiredString(organization, "work_id");
4307
- const previousFavorite = booleanValue(organization, "is_favorite");
4308
4445
  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
- });
4446
+ const previousFavorite = this.setEntityFavorite(workId, "organization", organizationId, isFavorite, booleanValue(organization, "is_favorite"));
4447
+ if (previousFavorite !== isFavorite) {
4448
+ this.audit(workId, "organization.favorite-updated", "organization", organizationId, {
4449
+ previousFavorite,
4450
+ isFavorite
4451
+ });
4452
+ }
4453
+ });
4454
+ return this.getOrganization(organizationId);
4455
+ }
4456
+ setOrganizationPin(organizationId, isPinned) {
4457
+ const organization = this.db.get("SELECT id, work_id FROM organizations WHERE id = ?", organizationId);
4458
+ if (!organization)
4459
+ throw notFound("组织");
4460
+ const workId = requiredString(organization, "work_id");
4461
+ this.db.transaction(() => {
4462
+ const previousPin = this.setEntityPin(workId, "organization", organizationId, isPinned);
4463
+ if (previousPin !== isPinned) {
4464
+ this.audit(workId, "organization.pin-updated", "organization", organizationId, {
4465
+ previousPin,
4466
+ isPinned
4467
+ });
4468
+ }
4314
4469
  });
4315
4470
  return this.getOrganization(organizationId);
4316
4471
  }
@@ -4441,7 +4596,8 @@ export class Store {
4441
4596
  name: requiredString(row, "name"),
4442
4597
  description: requiredString(row, "description"),
4443
4598
  isDissolved: booleanValue(row, "is_dissolved"),
4444
- isFavorite: booleanValue(row, "is_favorite"),
4599
+ isFavorite: this.mapEntityFavorite(row),
4600
+ isPinned: this.mapEntityPin(row),
4445
4601
  ...(includeMarkdown
4446
4602
  ? { settings, settingsMarkdown: settingsMarkdownFromList(settings), settingsSections }
4447
4603
  : { settings: [], settingsCount: settingsSections.length }),
@@ -4560,14 +4716,16 @@ export class Store {
4560
4716
  }
4561
4717
  listCharacters(workId, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4562
4718
  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)
4719
+ const preferences = this.entityPreferenceProjection("character", "character");
4720
+ 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
4721
  .map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown));
4565
4722
  }
4566
4723
  listCharactersPage(workId, pagination, includeProfileSections = false, includeMerged = false, includeRaceMarkdown = true) {
4567
4724
  this.getWork(workId);
4725
+ const preferences = this.entityPreferenceProjection("character", "character");
4568
4726
  const page = paginationSql(pagination);
4569
4727
  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);
4728
+ 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
4729
  return paginated(rows.map((row) => this.mapCharacter(row, includeProfileSections, includeRaceMarkdown)), pagination, Number(count?.count ?? 0));
4572
4730
  }
4573
4731
  mapCharacterProfileSection(row) {
@@ -5043,7 +5201,8 @@ export class Store {
5043
5201
  return { storageKey, cleanupQueued: !this.attachmentStorageKeyInUse(storageKey) };
5044
5202
  }
5045
5203
  getCharacter(characterId) {
5046
- const row = this.db.get("SELECT * FROM characters WHERE id = ?", characterId);
5204
+ const preferences = this.entityPreferenceProjection("character", "character");
5205
+ const row = this.db.get(`SELECT character.*, ${preferences.columns} FROM characters character WHERE character.id = ?`, ...preferences.params, characterId);
5047
5206
  if (!row)
5048
5207
  throw notFound("角色");
5049
5208
  return this.mapCharacter(row);
@@ -5056,15 +5215,33 @@ export class Store {
5056
5215
  throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能收藏");
5057
5216
  }
5058
5217
  const workId = requiredString(character, "work_id");
5059
- const previousFavorite = booleanValue(character, "is_favorite");
5060
- if (previousFavorite === isFavorite)
5061
- return this.getCharacter(characterId);
5062
5218
  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
- });
5219
+ const previousFavorite = this.setEntityFavorite(workId, "character", characterId, isFavorite, booleanValue(character, "is_favorite"));
5220
+ if (previousFavorite !== isFavorite) {
5221
+ this.audit(workId, "character.favorite-updated", "character", characterId, {
5222
+ previousFavorite,
5223
+ isFavorite
5224
+ });
5225
+ }
5226
+ });
5227
+ return this.getCharacter(characterId);
5228
+ }
5229
+ setCharacterPin(characterId, isPinned) {
5230
+ const character = this.db.get("SELECT id, work_id, merged_into_character_id FROM characters WHERE id = ?", characterId);
5231
+ if (!character)
5232
+ throw notFound("角色");
5233
+ if (optionalString(character, "merged_into_character_id")) {
5234
+ throw new AppError(409, "CHARACTER_ALREADY_MERGED", "已合并角色不能置顶");
5235
+ }
5236
+ const workId = requiredString(character, "work_id");
5237
+ this.db.transaction(() => {
5238
+ const previousPin = this.setEntityPin(workId, "character", characterId, isPinned);
5239
+ if (previousPin !== isPinned) {
5240
+ this.audit(workId, "character.pin-updated", "character", characterId, {
5241
+ previousPin,
5242
+ isPinned
5243
+ });
5244
+ }
5068
5245
  });
5069
5246
  return this.getCharacter(characterId);
5070
5247
  }
@@ -5342,7 +5519,8 @@ export class Store {
5342
5519
  profileSectionCount,
5343
5520
  currentState: json(requiredString(row, "current_state_json"), {}),
5344
5521
  isDead: booleanValue(row, "is_dead"),
5345
- isFavorite: booleanValue(row, "is_favorite"),
5522
+ isFavorite: this.mapEntityFavorite(row),
5523
+ isPinned: this.mapEntityPin(row),
5346
5524
  avatarUrl: avatarSha256
5347
5525
  ? `/api/characters/${encodeURIComponent(characterId)}/avatar?v=${encodeURIComponent(avatarSha256)}`
5348
5526
  : null,