@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.
- package/dist/app.js +24 -2
- package/dist/app.js.map +1 -1
- package/dist/database.js +159 -2
- package/dist/database.js.map +1 -1
- package/dist/public/app.js +281 -21
- package/dist/public/index.html +6 -2
- package/dist/public/styles.css +43 -5
- package/dist/store.js +238 -60
- package/dist/store.js.map +1 -1
- package/dist/user-auth.js +12 -10
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/public/styles.css
CHANGED
|
@@ -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
|
|
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,
|
|
789
|
-
if (
|
|
790
|
-
this.db.run("INSERT INTO work_memberships (work_id, user_id, role, invited_by_user_id, created_at) VALUES (?, ?, 'owner', ?, ?)", workId,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
3786
|
-
|
|
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
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4312
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
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:
|
|
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,
|