@musnows/scriverse 0.3.9 → 0.3.11

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.
@@ -9,7 +9,7 @@
9
9
  <script src="/theme-init.js?v=20260720-route-skeleton"></script>
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
- <link rel="stylesheet" href="/styles.css?v=20260722-settings-import-mode">
12
+ <link rel="stylesheet" href="/styles.css?v=20260722-prose-replacement-permission">
13
13
  </head>
14
14
  <body class="auth-pending">
15
15
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -91,7 +91,7 @@
91
91
  <div id="left-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整作品侧栏宽度" aria-orientation="vertical" tabindex="0"></div>
92
92
  <div class="left-actions">
93
93
  <div class="left-primary-actions">
94
- <label class="file-button" aria-label="导入 TXT / DOCX">
94
+ <label id="import-file-button" class="file-button" aria-label="导入 TXT / DOCX">
95
95
  <span class="import-file-label import-file-label-full" aria-hidden="true">导入 TXT / DOCX</span>
96
96
  <span class="import-file-label import-file-label-compact" aria-hidden="true">导入TXT/DOCX</span>
97
97
  <span class="import-file-label import-file-label-short" aria-hidden="true">导入</span>
@@ -99,6 +99,7 @@
99
99
  </label>
100
100
  <button id="left-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起作品侧栏" aria-expanded="true">‹</button>
101
101
  </div>
102
+ <button id="import-history-button" class="secondary-button import-history-button" type="button" aria-controls="import-history-dialog" aria-haspopup="dialog">导入历史与恢复</button>
102
103
  <button id="new-volume-button" class="secondary-button" type="button">新建分卷</button>
103
104
  <button id="new-chapter-button" class="secondary-button" type="button">新建章节</button>
104
105
  </div>
@@ -328,7 +329,7 @@
328
329
  <p id="import-mode-unsaved-warning" class="import-mode-unsaved-warning hidden" role="alert">当前章节有未保存修改,继续导入会丢失这些本地修改。</p>
329
330
  <div class="import-mode-options" aria-label="导入方式说明">
330
331
  <section><strong>追加正文</strong><span>保留现有卷章,把新文件解析出的卷章添加到目录末尾。</span></section>
331
- <section><strong>覆盖正文</strong><span>删除现有正文目录后写入新文件;导入前会保留可恢复的版本快照。</span></section>
332
+ <section><strong>覆盖正文</strong><span>删除现有正文目录后写入新文件;导入前会保留可恢复的版本快照。</span><small id="import-mode-overwrite-permission" class="import-mode-permission hidden">覆盖会影响章节关联资料,需要所有受影响模块均为可编辑。</small></section>
332
333
  </div>
333
334
  </div>
334
335
  <div class="dialog-actions">
@@ -480,6 +481,17 @@
480
481
  <div id="versions-list" class="versions-list"></div>
481
482
  </dialog>
482
483
 
484
+ <dialog id="import-history-dialog" class="dialog wide-dialog" aria-labelledby="import-history-title" aria-describedby="import-history-description">
485
+ <div class="dialog-header">
486
+ <div><span class="eyebrow">正文安全</span><h2 id="import-history-title">导入历史与正文恢复</h2></div>
487
+ <button id="import-history-close" class="dialog-close" aria-label="关闭导入历史" type="button">×</button>
488
+ </div>
489
+ <div class="import-history-body">
490
+ <p id="import-history-description" class="import-history-note">每条记录都保存了该次导入开始前的正文。恢复仅作用于分卷、章节标题和正文,并会重新建立分卷和章节;大纲、伏笔、首次登场等章节关联信息不在快照中,无法通过这里恢复。</p>
491
+ <div id="import-history-list" class="entity-history-list" aria-live="polite"></div>
492
+ </div>
493
+ </dialog>
494
+
483
495
  <dialog id="users-dialog" class="dialog wide-dialog" aria-labelledby="users-dialog-title">
484
496
  <div class="dialog-header"><div><span class="eyebrow">系统管理员</span><h2 id="users-dialog-title">用户管理</h2></div><button id="users-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
485
497
  <div class="access-dialog-body">
@@ -529,9 +541,22 @@
529
541
  </dialog>
530
542
 
531
543
  <dialog id="members-dialog" class="dialog wide-dialog" aria-labelledby="members-dialog-title">
532
- <div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">可访问人</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
544
+ <div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">成员模块权限</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
533
545
  <div class="access-dialog-body">
534
- <form id="member-invite-form" class="member-invite-form"><label>邀请已注册用户<select id="member-user-select" required></select></label><label>访问权限<select id="member-role-select" required><option value="viewer">仅查看</option><option value="settings-editor">仅编辑设定</option><option value="editor">编辑正文与设定</option></select></label><button class="primary-button" type="submit">发送邀请</button></form>
546
+ <form id="member-permission-form" class="member-permission-form">
547
+ <label class="member-person-field">选择成员<select id="member-user-select" required></select><small>先选择已注册用户,再逐项配置模块权限。</small></label>
548
+ <fieldset id="member-permission-fieldset" disabled>
549
+ <legend>模块读写权限</legend>
550
+ <div class="member-permission-presets" aria-label="权限快捷设置">
551
+ <button type="button" data-permission-preset="none">全部关闭</button>
552
+ <button type="button" data-permission-preset="read">全部只读</button>
553
+ <button type="button" data-permission-preset="write">全部可编辑</button>
554
+ </div>
555
+ <div id="member-permission-grid" class="member-permission-grid"></div>
556
+ <small class="member-permission-help">“可编辑”自动包含读取权限;未授权模块不会显示,也不能通过 API 读取。</small>
557
+ <button id="member-permission-submit" class="primary-button" type="submit">保存成员权限</button>
558
+ </fieldset>
559
+ </form>
535
560
  <div id="members-list" class="access-list"></div>
536
561
  </div>
537
562
  </dialog>
@@ -598,6 +623,6 @@
598
623
  </dialog>
599
624
 
600
625
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
601
- <script type="module" src="/app.js?v=20260722-settings-import-version-lock"></script>
626
+ <script type="module" src="/app.js?v=20260722-prose-replacement-permission"></script>
602
627
  </body>
603
628
  </html>
@@ -391,8 +391,8 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
391
391
  .settings-hub-card strong { align-self: end; font-size: 16px; }
392
392
  .settings-hub-card small { align-self: start; color: var(--muted); font-size: 10px; line-height: 1.5; }
393
393
  .settings-work-note { max-width: 1000px; margin: 18px auto 0; color: var(--muted); font-size: 11px; }
394
- .access-dialog-body { display: grid; gap: 16px; padding: 18px 24px 22px; }
395
- .access-list { display: grid; gap: 8px; max-height: min(52vh, 480px); overflow: auto; padding: 0; }
394
+ .access-dialog-body { display: grid; gap: 16px; max-height: calc(88vh - 82px); overflow-y: auto; padding: 18px 24px 22px; }
395
+ .access-list { display: grid; gap: 8px; max-height: min(34vh, 340px); overflow: auto; padding: 0; }
396
396
  .access-row {
397
397
  display: grid;
398
398
  grid-template-columns: minmax(0, 1fr) auto auto;
@@ -450,9 +450,10 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
450
450
  cursor: not-allowed;
451
451
  opacity: .46;
452
452
  }
453
- .member-invite-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(130px, .3fr) auto; align-items: end; gap: 10px; margin: 0; }
454
- .member-invite-form label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
455
- .member-invite-form select {
453
+ .member-permission-form { display: grid; gap: 12px; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
454
+ .member-person-field { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
455
+ .member-person-field small, .member-permission-help { color: var(--muted); font-size: 10px; line-height: 1.5; }
456
+ .member-person-field select, .member-permission-row select {
456
457
  width: 100%;
457
458
  min-height: 38px;
458
459
  padding: 9px 10px;
@@ -462,11 +463,20 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
462
463
  color: var(--ink);
463
464
  font-size: 13px;
464
465
  }
465
- .member-invite-form .primary-button { min-height: 38px; white-space: nowrap; }
466
- .app-shell.view-only-mode:not(.shelf-mode) { grid-template-columns: var(--left-panel-width) minmax(480px, 1fr); }
467
- .app-shell.view-only-mode:not(.shelf-mode) .ai-panel { display: none; }
468
- .app-shell.settings-only-mode:not(.shelf-mode) { grid-template-columns: var(--left-panel-width) minmax(480px, 1fr); }
469
- .app-shell.settings-only-mode:not(.shelf-mode) .ai-panel { display: none; }
466
+ .member-permission-form fieldset { display: grid; gap: 12px; min-width: 0; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 6px; }
467
+ .member-permission-form fieldset:disabled { opacity: .58; }
468
+ .member-permission-form legend { padding: 0 6px; color: var(--ink); font-size: 12px; font-weight: 650; }
469
+ .member-permission-presets { display: flex; flex-wrap: wrap; gap: 6px; }
470
+ .member-permission-presets button { padding: 6px 9px; border: 1px solid var(--line); border-radius: 4px; background: var(--surface); color: var(--muted); font-size: 10px; }
471
+ .member-permission-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px 12px; }
472
+ .member-permission-row { display: grid; grid-template-columns: minmax(90px, 1fr) minmax(112px, .7fr); align-items: center; gap: 8px; color: var(--ink); font-size: 11px; }
473
+ .member-permission-row select { min-height: 34px; padding-block: 6px; font-size: 11px; }
474
+ .member-permission-form .primary-button { justify-self: end; min-height: 38px; white-space: nowrap; }
475
+ .permission-hidden { display: none !important; }
476
+ .app-shell.ai-hidden-mode:not(.shelf-mode) { grid-template-columns: var(--left-panel-width) minmax(480px, 1fr); }
477
+ .app-shell.prose-hidden-mode:not(.shelf-mode) .left-actions,
478
+ .app-shell.prose-hidden-mode:not(.shelf-mode) .panel-heading,
479
+ .app-shell.prose-hidden-mode:not(.shelf-mode) #novel-tree { display: none !important; }
470
480
  .app-shell.prose-read-only-mode:not(.shelf-mode) .left-primary-actions .file-button,
471
481
  .app-shell.prose-read-only-mode:not(.shelf-mode) #new-volume-button,
472
482
  .app-shell.prose-read-only-mode:not(.shelf-mode) #new-chapter-button,
@@ -475,16 +485,11 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
475
485
  .app-shell.prose-read-only-mode:not(.shelf-mode) .left-primary-actions { justify-content: flex-end; }
476
486
  .app-shell.prose-read-only-mode:not(.shelf-mode) #chapter-title[readonly],
477
487
  .app-shell.prose-read-only-mode:not(.shelf-mode) #chapter-content[readonly] { cursor: default; }
478
- .app-shell.settings-only-mode:not(.shelf-mode) [data-work-settings],
479
- .app-shell.settings-only-mode:not(.shelf-mode) [data-module="tasks"],
480
- .app-shell.settings-only-mode:not(.shelf-mode) [data-module="ai-settings"] { display: none !important; }
481
488
  .app-shell.view-only-mode:not(.shelf-mode) .left-primary-actions .file-button,
482
489
  .app-shell.view-only-mode:not(.shelf-mode) #new-volume-button,
483
490
  .app-shell.view-only-mode:not(.shelf-mode) #new-chapter-button,
484
491
  .app-shell.view-only-mode:not(.shelf-mode) #module-create-button,
485
492
  .app-shell.view-only-mode:not(.shelf-mode) [data-work-settings],
486
- .app-shell.view-only-mode:not(.shelf-mode) [data-module="tasks"],
487
- .app-shell.view-only-mode:not(.shelf-mode) [data-module="ai-settings"],
488
493
  .app-shell.view-only-mode:not(.shelf-mode) #tidy-blank-lines-button,
489
494
  .app-shell.view-only-mode:not(.shelf-mode) #save-button { display: none !important; }
490
495
  .app-shell.view-only-mode:not(.shelf-mode) .left-primary-actions { justify-content: flex-end; }
@@ -508,6 +513,8 @@ body.work-viewer-mode [data-edit-relationship],
508
513
  body.work-viewer-mode [data-character-relationship-edit],
509
514
  body.work-viewer-mode [data-character-relationship-create],
510
515
  body.work-viewer-mode [data-review-id],
516
+ body.work-viewer-mode [data-merge-review],
517
+ body.work-viewer-mode [data-keep-characters-separate],
511
518
  body.work-viewer-mode .task-auto-run-panel,
512
519
  body.work-viewer-mode [data-run-task],
513
520
  body.work-viewer-mode [data-cancel-task],
@@ -519,11 +526,6 @@ body.work-viewer-mode [data-item-list-remove],
519
526
  body.work-viewer-mode [data-structured-list-add],
520
527
  body.work-viewer-mode [data-structured-list-remove],
521
528
  body.work-viewer-mode .character-editor-actions { display: none !important; }
522
- body.work-settings-editor-mode [data-review-id],
523
- body.work-settings-editor-mode .task-auto-run-panel,
524
- body.work-settings-editor-mode [data-run-task],
525
- body.work-settings-editor-mode [data-cancel-task],
526
- body.work-settings-editor-mode [data-restore-version] { display: none !important; }
527
529
  .search-form { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: end; gap: 10px; margin: 0; }
528
530
  .search-form label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
529
531
  .search-form input {
@@ -589,10 +591,12 @@ body.work-settings-editor-mode [data-restore-version] { display: none !important
589
591
  -webkit-box-orient: vertical;
590
592
  }
591
593
  .work-access-field { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
592
- .work-access-field > div { display: grid; gap: 4px; min-width: 0; }
594
+ .work-access-field > div { display: grid; gap: 6px; min-width: 0; }
593
595
  .work-access-field strong { color: var(--ink); font-size: 12px; }
594
596
  .work-access-field small { color: var(--muted); font-size: 10px; line-height: 1.5; }
595
597
  .work-access-field .ghost-button { flex: 0 0 auto; white-space: nowrap; }
598
+ .work-access-options { display: flex; flex-wrap: wrap; gap: 5px; }
599
+ .work-access-options span { padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; background: var(--surface); color: var(--ink); font-size: 10px; line-height: 1.35; }
596
600
  .account-dialog { width: min(640px, 92vw); overflow: hidden; }
597
601
  .account-settings-body { display: grid; gap: 16px; max-height: calc(88vh - 86px); overflow-y: auto; padding: 20px 24px 24px; }
598
602
  .account-settings-section { padding: 18px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
@@ -702,6 +706,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
702
706
  .left-primary-actions { display: flex; grid-column: 1 / -1; width: 100%; align-items: center; gap: 8px; }
703
707
  .left-primary-actions .file-button { flex: 1 1 auto; width: auto; min-width: 0; }
704
708
  .left-primary-actions #left-panel-toggle { flex: 0 0 30px; width: 30px; height: 30px; }
709
+ .import-history-button { grid-column: 1 / -1; }
705
710
  .file-button, .secondary-button { display: grid; place-items: center; min-height: 30px; border: 1px solid var(--line); border-radius: 4px; font-size: 11px; background: transparent; }
706
711
  .file-button { container-type: inline-size; overflow: hidden; white-space: nowrap; }
707
712
  .import-file-label-compact, .import-file-label-short { display: none; }
@@ -1414,6 +1419,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1414
1419
  .import-mode-options section { display: grid; gap: 5px; min-width: 0; padding: 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
1415
1420
  .import-mode-options strong { color: var(--ink); font-size: 12px; }
1416
1421
  .import-mode-options span { color: var(--muted); font-size: 10px; line-height: 1.55; }
1422
+ .import-mode-permission { color: var(--accent-dark); font-size: 9px; line-height: 1.55; }
1417
1423
  @media (max-width: 560px) {
1418
1424
  .import-mode-options { grid-template-columns: 1fr; }
1419
1425
  .import-mode-dialog .dialog-actions { flex-wrap: wrap; }
@@ -1573,6 +1579,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1573
1579
  .version-row { display: grid; grid-template-columns: 75px 1fr auto; gap: 14px; padding: 14px 0; border-bottom: 1px solid var(--line); align-items: start; }
1574
1580
  .version-row p { margin: 0; color: var(--muted); font-size: 11px; white-space: pre-wrap; max-height: 70px; overflow: hidden; }
1575
1581
  .entity-history-list { display: grid; gap: 10px; padding: 18px 24px 28px; max-height: 65vh; overflow: auto; }
1582
+ .import-history-body { min-height: 220px; }
1583
+ .import-history-note { margin: 0; padding: 16px 24px 0; color: var(--muted); font-size: 10px; line-height: 1.65; }
1584
+ .import-history-body .entity-history-list { padding-top: 14px; }
1585
+ .import-history-card header strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1586
+ .import-history-card .import-history-kind { flex: none; }
1587
+ .import-history-load-more { justify-self: center; min-height: 34px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 4px; background: transparent; color: var(--muted); font-size: 10px; }
1588
+ .import-history-load-more:hover:not(:disabled) { border-color: var(--accent); color: var(--accent-dark); }
1576
1589
  .entity-version-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; padding: 14px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-soft); }
1577
1590
  .entity-version-card.is-current { border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); box-shadow: inset 3px 0 var(--accent); }
1578
1591
  .entity-version-card header { display: flex; align-items: center; gap: 8px; min-width: 0; }
@@ -1590,7 +1603,12 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
1590
1603
  .relationship-table .relationship-actions button + button { margin-left: 5px; }
1591
1604
 
1592
1605
  @media (max-width: 540px) {
1593
- .member-invite-form { grid-template-columns: minmax(0, 1fr); }
1606
+ .member-permission-grid { grid-template-columns: minmax(0, 1fr); }
1607
+ .member-permission-row { grid-template-columns: minmax(0, 1fr); }
1608
+ .member-permission-form .primary-button { justify-self: stretch; }
1609
+ .access-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; }
1610
+ .access-row select, .access-row button { width: 100%; }
1611
+ .work-access-field { align-items: stretch; flex-direction: column; }
1594
1612
  .onboarding-popover { width: calc(100vw - 24px); }
1595
1613
  .onboarding-popover-header { padding-inline: 15px; }
1596
1614
  .onboarding-popover-body { padding: 16px 17px 18px; }
@@ -0,0 +1,14 @@
1
+ export type WorkPermissionModule = "prose" | "settings" | "characters" | "races" | "organizations" | "timeline" | "relationships" | "outlines" | "reviews" | "ai" | "ai-settings";
2
+ export type WorkUiModule = "editor" | "settings" | "characters" | "races" | "organizations" | "timeline" | "relationships" | "outlines" | "reviews" | "tasks" | "ai-settings";
3
+ export type WorkModuleAccess = "none" | "read" | "write";
4
+ export type WorkModulePermissions = Record<WorkPermissionModule, WorkModuleAccess>;
5
+ export type WorkPermissionAware = { accessRole?: string | null; modulePermissions?: WorkModulePermissions };
6
+
7
+ export const WORK_PERMISSION_MODULES: readonly Readonly<{ id: WorkPermissionModule; uiModule: WorkUiModule; label: string }>[];
8
+ export function emptyModulePermissions(): WorkModulePermissions;
9
+ export function normalizeModulePermissions(value: unknown, accessRole?: string): WorkModulePermissions;
10
+ export function permissionModuleForUiModule(uiModule: string): WorkPermissionModule;
11
+ export function canReadUiModule(work: WorkPermissionAware | null | undefined, uiModule: string): boolean;
12
+ export function canWriteUiModule(work: WorkPermissionAware | null | undefined, uiModule: string): boolean;
13
+ export function firstReadableUiModule(work: WorkPermissionAware | null | undefined): WorkUiModule | null;
14
+ export function permissionSummary(value: unknown): string;
@@ -0,0 +1,71 @@
1
+ export const WORK_PERMISSION_MODULES = Object.freeze([
2
+ { id: "prose", uiModule: "editor", label: "正文" },
3
+ { id: "settings", uiModule: "settings", label: "设定库" },
4
+ { id: "characters", uiModule: "characters", label: "角色" },
5
+ { id: "races", uiModule: "races", label: "种族" },
6
+ { id: "organizations", uiModule: "organizations", label: "组织" },
7
+ { id: "timeline", uiModule: "timeline", label: "时间轴" },
8
+ { id: "relationships", uiModule: "relationships", label: "关系" },
9
+ { id: "outlines", uiModule: "outlines", label: "大纲与伏笔" },
10
+ { id: "reviews", uiModule: "reviews", label: "审核" },
11
+ { id: "ai", uiModule: "tasks", label: "AI 对话与分析" },
12
+ { id: "ai-settings", uiModule: "ai-settings", label: "AI 设置" }
13
+ ]);
14
+
15
+ const permissionModuleByUiModule = new Map(WORK_PERMISSION_MODULES.map((item) => [item.uiModule, item.id]));
16
+ const validAccess = new Set(["none", "read", "write"]);
17
+
18
+ export function emptyModulePermissions() {
19
+ return Object.fromEntries(WORK_PERMISSION_MODULES.map((item) => [item.id, "none"]));
20
+ }
21
+
22
+ export function normalizeModulePermissions(value, accessRole = "viewer") {
23
+ if (value && typeof value === "object" && !Array.isArray(value)
24
+ && WORK_PERMISSION_MODULES.every((item) => validAccess.has(value[item.id]))) {
25
+ return Object.fromEntries(WORK_PERMISSION_MODULES.map((item) => [item.id, value[item.id]]));
26
+ }
27
+ const fallback = emptyModulePermissions();
28
+ for (const item of WORK_PERMISSION_MODULES) {
29
+ fallback[item.id] = accessRole === "editor" || accessRole === "admin" || accessRole === "owner"
30
+ ? "write"
31
+ : accessRole === "viewer" || accessRole === "settings-editor" ? "read" : "none";
32
+ }
33
+ if (accessRole === "settings-editor") {
34
+ fallback.prose = "read";
35
+ fallback.reviews = "read";
36
+ fallback.ai = "read";
37
+ fallback["ai-settings"] = "read";
38
+ }
39
+ return fallback;
40
+ }
41
+
42
+ export function permissionModuleForUiModule(uiModule) {
43
+ return permissionModuleByUiModule.get(String(uiModule)) ?? "prose";
44
+ }
45
+
46
+ export function canReadUiModule(work, uiModule) {
47
+ if (!work) return false;
48
+ const permissions = normalizeModulePermissions(work.modulePermissions, work.accessRole);
49
+ return permissions[permissionModuleForUiModule(uiModule)] !== "none";
50
+ }
51
+
52
+ export function canWriteUiModule(work, uiModule) {
53
+ if (!work) return false;
54
+ const permissions = normalizeModulePermissions(work.modulePermissions, work.accessRole);
55
+ return permissions[permissionModuleForUiModule(uiModule)] === "write";
56
+ }
57
+
58
+ export function firstReadableUiModule(work) {
59
+ return WORK_PERMISSION_MODULES.find((item) => canReadUiModule(work, item.uiModule))?.uiModule ?? null;
60
+ }
61
+
62
+ export function permissionSummary(value) {
63
+ const permissions = normalizeModulePermissions(value, "custom");
64
+ const writable = WORK_PERMISSION_MODULES.filter((item) => permissions[item.id] === "write").map((item) => item.label);
65
+ const readable = WORK_PERMISSION_MODULES.filter((item) => permissions[item.id] === "read").map((item) => item.label);
66
+ const parts = [];
67
+ if (writable.length) parts.push(`可编辑:${writable.join("、")}`);
68
+ if (readable.length) parts.push(`只读:${readable.join("、")}`);
69
+ if (!parts.length) parts.push("未授权任何模块");
70
+ return parts.join(";");
71
+ }
package/dist/store.js CHANGED
@@ -4,6 +4,7 @@ import { AppError, notFound } from "./errors.js";
4
4
  import { accountReference, logger } from "./logger.js";
5
5
  import { paginated, paginationSql } from "./pagination.js";
6
6
  import { currentRequestActor } from "./request-context.js";
7
+ import { classifyWorkModulePermissions, emptyWorkModulePermissions, fullWorkModulePermissions, storedWorkModulePermissions } from "./work-permissions.js";
7
8
  import { countWords, documentShortSearchTerms, id, json, normalizeDocumentSearchText, normalizeParagraphSpacing, now, splitDocumentParagraphs } from "./utils.js";
8
9
  export const versionedEntityTypes = [
9
10
  "work",
@@ -17,6 +18,65 @@ export const versionedEntityTypes = [
17
18
  "chapter-outline",
18
19
  "foreshadow"
19
20
  ];
21
+ function isRecord(value) {
22
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
23
+ }
24
+ function invalidFileSnapshot() {
25
+ throw new AppError(409, "FILE_VERSION_INVALID", "正文历史快照已损坏,未执行恢复");
26
+ }
27
+ function parseRestorableFileSnapshot(value, workId) {
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(value);
31
+ }
32
+ catch {
33
+ invalidFileSnapshot();
34
+ }
35
+ if (!isRecord(parsed) || parsed.id !== workId || !Array.isArray(parsed.volumes) || parsed.volumes.length > 10_000) {
36
+ return invalidFileSnapshot();
37
+ }
38
+ let chapterCount = 0;
39
+ let contentLength = 0;
40
+ const volumes = parsed.volumes.map((volumeValue) => {
41
+ if (!isRecord(volumeValue) || typeof volumeValue.title !== "string" || typeof volumeValue.kind !== "string"
42
+ || typeof volumeValue.source !== "string" || typeof volumeValue.sortOrder !== "number"
43
+ || !Number.isFinite(volumeValue.sortOrder) || !Array.isArray(volumeValue.chapters)) {
44
+ return invalidFileSnapshot();
45
+ }
46
+ const description = volumeValue.description === undefined ? "" : volumeValue.description;
47
+ const keywords = volumeValue.keywords === undefined ? [] : volumeValue.keywords;
48
+ if (typeof description !== "string" || !Array.isArray(keywords) || !keywords.every((keyword) => typeof keyword === "string")) {
49
+ return invalidFileSnapshot();
50
+ }
51
+ const chapters = volumeValue.chapters.map((chapterValue) => {
52
+ if (!isRecord(chapterValue) || typeof chapterValue.title !== "string" || typeof chapterValue.content !== "string"
53
+ || typeof chapterValue.sortOrder !== "number" || !Number.isFinite(chapterValue.sortOrder)
54
+ || !["正文", "设定", "作者的话", "其他"].includes(String(chapterValue.chapterType))) {
55
+ return invalidFileSnapshot();
56
+ }
57
+ chapterCount += 1;
58
+ contentLength += chapterValue.content.length;
59
+ if (chapterCount > 100_000 || contentLength > 20_000_000)
60
+ return invalidFileSnapshot();
61
+ return {
62
+ title: chapterValue.title,
63
+ content: chapterValue.content,
64
+ sortOrder: chapterValue.sortOrder,
65
+ chapterType: chapterValue.chapterType
66
+ };
67
+ });
68
+ return {
69
+ title: volumeValue.title,
70
+ kind: volumeValue.kind,
71
+ source: volumeValue.source,
72
+ description,
73
+ keywords: [...keywords],
74
+ sortOrder: volumeValue.sortOrder,
75
+ chapters
76
+ };
77
+ });
78
+ return { volumes };
79
+ }
20
80
  function requiredString(row, key) {
21
81
  return String(row[key] ?? "");
22
82
  }
@@ -603,6 +663,9 @@ export class Store {
603
663
  }
604
664
  getWorkDirectory(workId) {
605
665
  const work = this.getWork(workId);
666
+ const permissions = work.modulePermissions;
667
+ if (permissions.prose === "none")
668
+ return { ...work, volumes: [] };
606
669
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
607
670
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
608
671
  analysis_status, excluded_from_analysis, created_at, updated_at
@@ -623,6 +686,9 @@ export class Store {
623
686
  }
624
687
  getWorkDirectoryPage(workId, pagination) {
625
688
  const work = this.getWork(workId);
689
+ const permissions = work.modulePermissions;
690
+ if (permissions.prose === "none")
691
+ return { ...work, volumes: [], directoryPage: paginated([], pagination) };
626
692
  const volumeRows = this.db.all("SELECT * FROM volumes WHERE work_id = ? ORDER BY sort_order, created_at", workId);
627
693
  const page = paginationSql(pagination);
628
694
  const chapterRows = this.db.all(`SELECT id, work_id, volume_id, title, chapter_type, sort_order, word_count, version_no,
@@ -648,7 +714,7 @@ export class Store {
648
714
  .all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
649
715
  version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
650
716
  FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
651
- WHERE version.work_id = ? ORDER BY version.created_at DESC`, workId)
717
+ WHERE version.work_id = ? ORDER BY version.created_at DESC, version.id DESC`, workId)
652
718
  .map((row) => ({
653
719
  id: requiredString(row, "id"),
654
720
  workId: requiredString(row, "work_id"),
@@ -667,7 +733,7 @@ export class Store {
667
733
  const rows = this.db.all(`SELECT version.id, version.work_id, version.file_name, version.file_type, version.word_count, version.paragraph_count,
668
734
  version.warnings_json, version.created_at, user.display_name AS actor_display_name, user.username AS actor_username
669
735
  FROM file_versions version LEFT JOIN users user ON user.id = version.created_by_user_id
670
- WHERE version.work_id = ? ORDER BY version.created_at DESC${page.sql}`, workId, ...page.params);
736
+ WHERE version.work_id = ? ORDER BY version.created_at DESC, version.id DESC${page.sql}`, workId, ...page.params);
671
737
  return paginated(rows.map((row) => ({
672
738
  id: requiredString(row, "id"),
673
739
  workId: requiredString(row, "work_id"),
@@ -685,8 +751,7 @@ export class Store {
685
751
  const version = this.db.get("SELECT * FROM file_versions WHERE id = ? AND work_id = ?", fileVersionId, workId);
686
752
  if (!version)
687
753
  throw notFound("文件版本");
688
- const snapshot = json(requiredString(version, "snapshot_json"), {});
689
- const volumes = Array.isArray(snapshot.volumes) ? snapshot.volumes : [];
754
+ const { volumes } = parseRestorableFileSnapshot(requiredString(version, "snapshot_json"), workId);
690
755
  return this.db.transaction(() => {
691
756
  const current = this.getWork(workId);
692
757
  this.assertExpectedVersion("work", workId, expectedVersionNo, "作品", Number(current.versionNo));
@@ -707,20 +772,16 @@ export class Store {
707
772
  this.db.run("DELETE FROM volumes WHERE work_id = ?", workId);
708
773
  for (const volume of volumes) {
709
774
  const volumeId = id("volume");
710
- const chapters = Array.isArray(volume.chapters) ? volume.chapters : [];
711
775
  this.insertVolumeWithId(workId, volumeId, {
712
- title: String(volume.title ?? "正文"),
713
- kind: String(volume.kind ?? "main"),
714
- source: String(volume.source ?? "manual"),
715
- description: String(volume.description ?? ""),
716
- keywords: Array.isArray(volume.keywords) ? volume.keywords : [],
717
- sortOrder: Number(volume.sortOrder ?? 0)
776
+ title: volume.title,
777
+ kind: volume.kind,
778
+ source: volume.source,
779
+ description: volume.description,
780
+ keywords: volume.keywords,
781
+ sortOrder: volume.sortOrder
718
782
  }, "restore", fileVersionId, `恢复文件版本 ${fileVersionId}`);
719
- for (const chapter of chapters) {
720
- const chapterType = (["正文", "设定", "作者的话", "其他"].includes(String(chapter.chapterType))
721
- ? String(chapter.chapterType)
722
- : "正文");
723
- this.insertChapter(workId, volumeId, String(chapter.title ?? "未命名章节"), String(chapter.content ?? ""), Number(chapter.sortOrder ?? 0), "restore", fileVersionId, chapterType);
783
+ for (const chapter of volume.chapters) {
784
+ this.insertChapter(workId, volumeId, chapter.title, chapter.content, chapter.sortOrder, "restore", fileVersionId, chapter.chapterType);
724
785
  }
725
786
  }
726
787
  this.db.run("UPDATE works SET version_no = version_no + 1, updated_at = ? WHERE id = ?", timestamp, workId);
@@ -1193,15 +1254,19 @@ export class Store {
1193
1254
  const membership = actor
1194
1255
  ? this.db.get("SELECT role, permissions_json FROM work_memberships WHERE work_id = ? AND user_id = ?", requiredString(row, "id"), actor.userId)
1195
1256
  : undefined;
1196
- const membershipPermissions = json(optionalString(membership ?? {}, "permissions_json"), {});
1197
1257
  const membershipRole = String(membership?.role ?? "");
1258
+ const ownerAccess = ownerUserId === actor?.userId;
1259
+ const adminAccess = actor?.role === "admin" && actor.authentication !== "api-key";
1260
+ const modulePermissions = !actor || ownerAccess || adminAccess
1261
+ ? fullWorkModulePermissions()
1262
+ : membershipRole
1263
+ ? storedWorkModulePermissions(membershipRole, optionalString(membership ?? {}, "permissions_json"))
1264
+ : emptyWorkModulePermissions();
1198
1265
  const accessRole = ownerUserId === actor?.userId
1199
1266
  ? "owner"
1200
- : actor?.role === "admin"
1267
+ : adminAccess
1201
1268
  ? "admin"
1202
- : membershipRole === "editor" && membershipPermissions.editScope === "settings"
1203
- ? "settings-editor"
1204
- : ["editor", "viewer"].includes(membershipRole) ? membershipRole : null;
1269
+ : membershipRole ? classifyWorkModulePermissions(modulePermissions) : null;
1205
1270
  const count = this.db.get("SELECT COUNT(*) AS chapter_count, COALESCE(SUM(word_count), 0) AS word_count FROM chapters WHERE work_id = ?", requiredString(row, "id"));
1206
1271
  const cover = this.db.get("SELECT updated_at FROM work_covers WHERE work_id = ?", requiredString(row, "id"));
1207
1272
  return {
@@ -1217,8 +1282,9 @@ export class Store {
1217
1282
  versionNo: numberValue(row, "version_no") || this.currentEntityVersionNo("work", requiredString(row, "id")),
1218
1283
  ownerUserId,
1219
1284
  accessRole,
1220
- chapterCount: numberValue(count ?? {}, "chapter_count"),
1221
- wordCount: numberValue(count ?? {}, "word_count"),
1285
+ modulePermissions,
1286
+ chapterCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "chapter_count"),
1287
+ wordCount: modulePermissions.prose === "none" ? 0 : numberValue(count ?? {}, "word_count"),
1222
1288
  createdAt: requiredString(row, "created_at"),
1223
1289
  updatedAt: requiredString(row, "updated_at")
1224
1290
  };