@musnows/scriverse 0.7.1 → 0.7.3
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/README.md +5 -0
- package/dist/ai.js +248 -250
- package/dist/ai.js.map +1 -1
- package/dist/app.js +194 -40
- package/dist/app.js.map +1 -1
- package/dist/attachment-storage.js +28 -12
- package/dist/attachment-storage.js.map +1 -1
- package/dist/backup-encryption.js +129 -0
- package/dist/backup-encryption.js.map +1 -0
- package/dist/collaboration-presence.js +200 -18
- package/dist/collaboration-presence.js.map +1 -1
- package/dist/database.js +215 -4
- package/dist/database.js.map +1 -1
- package/dist/docx-export.js +1 -1
- package/dist/docx-export.js.map +1 -1
- package/dist/domain.js +18 -0
- package/dist/domain.js.map +1 -1
- package/dist/image-metadata.js +17 -2
- package/dist/image-metadata.js.map +1 -1
- package/dist/presence-store.js +160 -0
- package/dist/presence-store.js.map +1 -0
- package/dist/public/ai-context-meter.js +27 -0
- package/dist/public/ai-mentions.js +14 -0
- package/dist/public/app.js +1018 -292
- package/dist/public/background-task-center.js +1 -1
- package/dist/public/chapter-editor-virtualization.js +52 -0
- package/dist/public/index.html +44 -22
- package/dist/public/presence-client-id.d.ts +10 -0
- package/dist/public/presence-client-id.js +31 -0
- package/dist/public/relationship-graph.js +24 -4
- package/dist/public/s3-backup-ui.d.ts +10 -0
- package/dist/public/s3-backup-ui.js +29 -0
- package/dist/public/setting-filters.d.ts +16 -0
- package/dist/public/setting-filters.js +19 -0
- package/dist/public/styles.css +39 -9
- package/dist/s3-backup.js +197 -12
- package/dist/s3-backup.js.map +1 -1
- package/dist/security.js +82 -20
- package/dist/security.js.map +1 -1
- package/dist/server-runtime.js +36 -18
- package/dist/server-runtime.js.map +1 -1
- package/dist/store.js +77 -18
- package/dist/store.js.map +1 -1
- package/dist/upload-limits.js +35 -0
- package/dist/upload-limits.js.map +1 -0
- package/dist/user-auth.js +11 -1
- package/dist/user-auth.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/dist/public/styles.css
CHANGED
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
--font-cjk: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", "Heiti SC";
|
|
66
66
|
--ui-font-size: 14px;
|
|
67
67
|
--ui-font-scale: 1;
|
|
68
|
-
--ai-font-size:
|
|
68
|
+
--ai-font-size: 12px;
|
|
69
69
|
--ai-font-scale: 1;
|
|
70
70
|
--editor-font-size: 17px;
|
|
71
71
|
--editor-line-height: 1.55;
|
|
@@ -796,6 +796,17 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
|
|
|
796
796
|
}
|
|
797
797
|
.dialog.s3-backup-dialog { width: min(900px, 94vw); }
|
|
798
798
|
.s3-backup-body { display: grid; gap: 18px; max-height: calc(88vh - 86px); overflow-y: auto; padding: 18px 24px 24px; }
|
|
799
|
+
.s3-backup-encryption { display: grid; gap: 12px; min-width: 0; margin: 0; padding: 15px 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
|
|
800
|
+
.s3-backup-encryption legend { padding: 0 6px; color: var(--ink); font-size: 13px; font-weight: 600; }
|
|
801
|
+
.s3-backup-encryption-control { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
802
|
+
.s3-backup-encryption-control > label { display: flex; align-items: flex-start; gap: 9px; min-width: 0; color: var(--ink); cursor: pointer; }
|
|
803
|
+
.s3-backup-encryption-control input { flex: 0 0 auto; width: 17px; height: 17px; margin-top: 1px; }
|
|
804
|
+
.s3-backup-encryption-control label > span { display: grid; gap: 4px; min-width: 0; }
|
|
805
|
+
.s3-backup-encryption-control strong { font-size: 12px; font-weight: 600; }
|
|
806
|
+
.s3-backup-encryption-control small { color: var(--muted); font-size: 10px; line-height: 1.55; }
|
|
807
|
+
.s3-backup-encryption-warning { padding: 10px 12px; border: 1px solid color-mix(in srgb, #bf6a35 48%, var(--line)); border-radius: 5px; background: color-mix(in srgb, #bf6a35 10%, var(--surface)); }
|
|
808
|
+
.s3-backup-encryption-warning strong { color: var(--ink); font-size: 11px; }
|
|
809
|
+
.s3-backup-encryption-warning p { margin: 3px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
|
|
799
810
|
.s3-backup-overview { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 15px 16px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
|
|
800
811
|
.s3-backup-overview h3, .s3-backup-history h3 { margin: 0; font-size: 15px; font-weight: 600; }
|
|
801
812
|
.s3-backup-overview p, .s3-backup-history header p { margin: 4px 0 0; color: var(--muted); font-size: 10px; line-height: 1.55; }
|
|
@@ -841,6 +852,12 @@ body.is-panel-resizing { cursor: col-resize; user-select: none; }
|
|
|
841
852
|
.s3-backup-options span { display: grid; gap: 3px; }
|
|
842
853
|
.s3-backup-options strong { font-size: 10px; font-weight: 600; }
|
|
843
854
|
.s3-backup-options small { color: var(--muted); font-size: 9px; line-height: 1.45; }
|
|
855
|
+
.dialog.s3-backup-key-dialog { width: min(620px, 94vw); }
|
|
856
|
+
.s3-backup-key-body { display: grid; gap: 14px; padding: 18px 24px 4px; }
|
|
857
|
+
.s3-backup-key-body > label { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
|
|
858
|
+
.s3-backup-key-body textarea { width: 100%; min-height: 82px; resize: none; padding: 10px 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface); color: var(--ink); font: 12px/1.6 var(--font-latin), monospace; overflow-wrap: anywhere; }
|
|
859
|
+
.s3-backup-key-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
860
|
+
.s3-backup-key-actions button { min-height: 34px; }
|
|
844
861
|
.member-permission-form { display: grid; gap: 12px; margin: 0; padding: 14px; border: 1px solid var(--line); border-radius: 7px; background: var(--surface-soft); }
|
|
845
862
|
.member-person-field { display: grid; gap: 6px; color: var(--muted); font-size: 11px; }
|
|
846
863
|
.member-person-field small, .member-permission-help { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
|
@@ -1415,6 +1432,10 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1415
1432
|
.draft-type-filter-field { display: grid; min-width: 0; }
|
|
1416
1433
|
.draft-type-filter-field > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
1417
1434
|
.draft-type-filter-field select { width: 100%; min-width: 0; min-height: 38px; padding: 8px 10px; background: var(--surface); color: var(--ink); font-size: 11px; }
|
|
1435
|
+
.setting-filter-toolbar { grid-template-columns: minmax(0, 1.2fr) repeat(2, minmax(0, .8fr)) auto; }
|
|
1436
|
+
.setting-filter-field { display: grid; min-width: 0; }
|
|
1437
|
+
.setting-filter-field > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
1438
|
+
.setting-filter-field input, .setting-filter-field select { width: 100%; min-width: 0; min-height: 38px; padding: 8px 10px; background: var(--surface); color: var(--ink); font-size: 11px; }
|
|
1418
1439
|
.draft-binding-field { min-width: 0; }
|
|
1419
1440
|
.draft-binding-field > label { display: grid; gap: 7px; }
|
|
1420
1441
|
.character-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
@@ -1615,13 +1636,13 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1615
1636
|
}
|
|
1616
1637
|
.task-status-badge.is-running .task-status-indicator::after { animation: task-status-running-ripple 1.8s ease-out infinite; }
|
|
1617
1638
|
.task-status-badge.is-review, .task-status-badge.is-completed { --task-status-color: var(--green); }
|
|
1618
|
-
.task-status-badge.is-partial { --task-status-color: var(--accent); }
|
|
1639
|
+
.task-status-badge.is-partial, .task-status-badge.is-failed { --task-status-color: var(--accent); }
|
|
1619
1640
|
.task-status-badge.is-expired { --task-status-color: color-mix(in srgb, var(--muted) 72%, var(--accent)); border-style: dashed; }
|
|
1620
1641
|
.task-status-badge.is-cancelled, .task-status-badge.is-unknown { --task-status-color: var(--muted); }
|
|
1621
1642
|
.task-status-badge.is-cancelled .task-status-indicator, .task-status-badge.is-unknown .task-status-indicator { background: transparent; border: 1px solid currentColor; box-shadow: none; }
|
|
1622
1643
|
.task-status-badge.is-state-change { animation: task-status-enter .42s cubic-bezier(.22, .8, .32, 1) both; }
|
|
1623
1644
|
.task-status-badge.is-running.is-state-change { animation: task-status-enter .42s cubic-bezier(.22, .8, .32, 1) both, task-status-running-breathe 1.8s ease-in-out .42s infinite; }
|
|
1624
|
-
.task-status-badge.is-partial.is-state-change { animation: task-status-attention .46s ease-out both; }
|
|
1645
|
+
.task-status-badge.is-partial.is-state-change, .task-status-badge.is-failed.is-state-change { animation: task-status-attention .46s ease-out both; }
|
|
1625
1646
|
.task-progress-cell { width: 128px; }
|
|
1626
1647
|
.task-progress { display: grid; grid-template-columns: 36px minmax(58px, 1fr); align-items: center; gap: 8px; min-width: 102px; }
|
|
1627
1648
|
.task-progress-value { color: var(--ink); font-family: var(--font-latin), monospace; font-size: 11px; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
@@ -1629,7 +1650,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1629
1650
|
.task-progress-fill { position: relative; display: block; width: var(--task-progress); height: 100%; overflow: hidden; border-radius: inherit; background: var(--muted); transition: width .35s ease; }
|
|
1630
1651
|
.task-progress.is-running .task-progress-fill { background: var(--task-running); }
|
|
1631
1652
|
.task-progress.is-review .task-progress-fill, .task-progress.is-completed .task-progress-fill { background: var(--green); }
|
|
1632
|
-
.task-progress.is-partial .task-progress-fill { background: var(--accent); }
|
|
1653
|
+
.task-progress.is-partial .task-progress-fill, .task-progress.is-failed .task-progress-fill { background: var(--accent); }
|
|
1633
1654
|
.task-progress.is-running .task-progress-fill::after {
|
|
1634
1655
|
content: "";
|
|
1635
1656
|
position: absolute;
|
|
@@ -1963,7 +1984,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
1963
1984
|
.mind-edge-detail { position: absolute; z-index: 5; left: 16px; right: 16px; bottom: 14px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: start; padding: 10px 12px; border: 1px solid var(--line); border-radius: 5px; background: var(--surface-strong); box-shadow: var(--shadow); font-size: 11px; }
|
|
1964
1985
|
.mind-edge-detail b { white-space: nowrap; }.mind-edge-detail span { color: var(--muted); line-height: 1.55; }
|
|
1965
1986
|
.relationship-empty-note { margin-top: -12px; padding: 12px 14px; border: 1px dashed var(--line); color: var(--muted); font-size: 11px; }
|
|
1966
|
-
.relationship-map-dialog { position:
|
|
1987
|
+
.relationship-map-dialog { position: fixed; inset: 0; width: 100vw; max-width: none; height: 100dvh; max-height: none; margin: 0; padding: 0; overflow: hidden; border: 0; border-radius: 0; background: var(--panel); box-shadow: none; }
|
|
1967
1988
|
.relationship-map-dialog::backdrop { background: rgba(34,30,25,.52); backdrop-filter: blur(3px); }
|
|
1968
1989
|
.relationship-map-floating-close { position: absolute; z-index: 12; top: 25px; right: 25px; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: 50%; background: var(--surface-strong); color: var(--ink); font-size: 20px; }
|
|
1969
1990
|
.relationship-map-expanded-host { height: 100%; padding: 18px; }
|
|
@@ -2194,7 +2215,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2194
2215
|
.galaxy-graph { z-index: 2; }.galaxy-node-layer { z-index: 3; pointer-events: none; }
|
|
2195
2216
|
.galaxy-node { --node-size: 12px; position: absolute; left: 0; top: 0; display: grid; grid-template-rows: var(--node-size) auto; justify-items: center; align-items: start; gap: 5px; padding: 8px; border: 0; background: transparent; color: #edf7ff; cursor: grab; touch-action: none; pointer-events: auto; transform-origin: center; opacity: var(--depth-opacity, 1); }
|
|
2196
2217
|
.galaxy-node.is-dragging, .galaxy-node.is-selected, .galaxy-node.is-edge-endpoint { will-change: transform, opacity; }
|
|
2197
|
-
.galaxy-node i { position: relative; isolation: isolate; width: var(--node-size); height: var(--node-size); border-radius: 50%; background: radial-gradient(circle at 32% 27%, var(--node-core, #fff) 0 7%, color-mix(in srgb, var(--node-color) 62%, #fff) 18%, var(--node-color) 52%, var(--node-rim, #07101f) 100%); box-shadow: inset -3px -4px 7px color-mix(in srgb, var(--node-rim, #07101f) 74%, transparent), 0 0 4px rgba(255,255,255,.82), 0 0 calc(7px + var(--node-glow) * 11px) var(--node-color), 0 0 calc(15px + var(--node-glow) * 25px) var(--node-atmosphere, color-mix(in srgb, var(--node-color) 72%, transparent)); filter: brightness(var(--node-brightness)); transition: filter .18s ease, box-shadow .18s ease; }
|
|
2218
|
+
.galaxy-node i { position: relative; isolation: isolate; width: var(--node-size); height: var(--node-size); border-radius: 50%; background: radial-gradient(circle at 32% 27%, var(--node-core, #fff) 0 7%, color-mix(in srgb, var(--node-color) 62%, #fff) 18%, var(--node-color) 52%, var(--node-rim, #07101f) 100%); box-shadow: inset -3px -4px 7px color-mix(in srgb, var(--node-rim, #07101f) 74%, transparent), 0 0 4px rgba(255,255,255,.82), 0 0 calc(7px + var(--node-glow) * 11px) var(--node-color), 0 0 calc(15px + var(--node-glow) * 25px) var(--node-atmosphere, color-mix(in srgb, var(--node-color) 72%, transparent)); filter: brightness(var(--node-brightness)); transform: scale(var(--node-marker-scale, 1)); transform-origin: center; transition: filter .18s ease, box-shadow .18s ease; }
|
|
2198
2219
|
.galaxy-node i::before, .galaxy-node i::after { content: ""; position: absolute; pointer-events: none; }
|
|
2199
2220
|
.galaxy-node i::before { z-index: -1; inset: -34%; border-radius: 50%; background: radial-gradient(circle, var(--node-atmosphere) 0, transparent 68%); opacity: .64; }
|
|
2200
2221
|
.galaxy-node[data-celestial-type="star"] i { background: radial-gradient(circle at 38% 34%, #fff 0 10%, var(--node-core) 22%, var(--node-color) 53%, var(--node-rim) 100%); box-shadow: 0 0 5px #fff, 0 0 calc(10px + var(--node-glow) * 15px) var(--node-color), 0 0 calc(24px + var(--node-glow) * 34px) var(--node-atmosphere); }
|
|
@@ -2206,7 +2227,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2206
2227
|
.galaxy-node[data-celestial-type="volcanic"] i { background: radial-gradient(circle at 66% 61%, #ffd37a 0 3%, #ff7438 5%, transparent 9%), radial-gradient(circle at 37% 72%, #ff9b48 0 3%, transparent 8%), radial-gradient(circle at 30% 25%, var(--node-core) 0 5%, var(--node-color) 38%, var(--node-rim) 100%); }
|
|
2207
2228
|
.galaxy-node[data-celestial-type="rocky"] i { background: radial-gradient(circle at 67% 31%, color-mix(in srgb, var(--node-rim) 74%, transparent) 0 8%, transparent 10%), radial-gradient(circle at 34% 68%, color-mix(in srgb, var(--node-rim) 58%, transparent) 0 11%, transparent 13%), radial-gradient(circle at 30% 26%, var(--node-core) 0 5%, var(--node-color) 45%, var(--node-rim) 100%); }
|
|
2208
2229
|
.galaxy-node[data-celestial-type="dwarf"] i { box-shadow: inset -2px -3px 5px var(--node-rim), 0 0 3px rgba(255,255,255,.65), 0 0 calc(5px + var(--node-glow) * 8px) var(--node-atmosphere); }
|
|
2209
|
-
.galaxy-node span { opacity: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 6px; border-radius: 4px; background: rgba(5,7,13,.68); font-size: 10px; text-align: center; text-shadow: 0 1px 3px #000; transition: opacity .14s ease; }
|
|
2230
|
+
.galaxy-node span { opacity: 0; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 6px; border-radius: 4px; background: rgba(5,7,13,.68); font-size: 10px; text-align: center; text-shadow: 0 1px 3px #000; transform: translate3d(0, var(--node-label-offset, 0px), 0); transition: opacity .14s ease; }
|
|
2210
2231
|
.galaxy-node.show-label span, .galaxy-node:hover span, .galaxy-node:focus-visible span, .galaxy-node.is-selected span, .galaxy-node.is-related span, .galaxy-node.is-edge-endpoint span { opacity: 1; }.galaxy-node.is-selected i { background: #fff4bd; box-shadow: 0 0 5px #fff, 0 0 18px #ffc86b, 0 0 42px rgba(255,173,66,.82); }.galaxy-node.is-related i { background: #d8f4ff; box-shadow: 0 0 4px #fff, 0 0 15px #438cff, 0 0 28px rgba(67,140,255,.56); }.galaxy-node.is-edge-endpoint i { background: #fff; box-shadow: 0 0 6px #fff, 0 0 22px #6fcaff, 0 0 48px rgba(67,140,255,.92); }.galaxy-node.is-dimmed { opacity: .16; }.galaxy-node.is-selected, .galaxy-node.is-related, .galaxy-node.is-edge-endpoint { z-index: 2; }
|
|
2211
2232
|
.galaxy-node.is-dragging { cursor: grabbing; z-index: 6; }
|
|
2212
2233
|
.galaxy-close { position: fixed; z-index: 8; top: 22px; right: 25px; width: 42px; height: 42px; border: 1px solid rgba(255,255,255,.22); border-radius: 50%; background: rgba(5,7,13,.62); color: #fff; font-size: 25px; }
|
|
@@ -2437,7 +2458,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2437
2458
|
.prompt-composer { position: relative; }
|
|
2438
2459
|
.ai-mention-menu { position: absolute; z-index: 20; left: 0; right: 0; bottom: calc(100% + 6px); max-height: 230px; overflow-y: auto; padding: 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); box-shadow: 0 12px 34px rgba(49,42,32,.18); }
|
|
2439
2460
|
.ai-mention-option { display: grid; grid-template-columns: 34px minmax(0, 1fr); gap: 8px; width: 100%; padding: 7px 8px; border: 0; border-radius: 4px; background: transparent; text-align: left; }
|
|
2440
|
-
.ai-mention-option:hover, .ai-mention-option:focus-visible { background: var(--paper-deep); }
|
|
2461
|
+
.ai-mention-option:hover, .ai-mention-option:focus-visible, .ai-mention-option.is-active { background: var(--paper-deep); }
|
|
2441
2462
|
.ai-mention-option small { color: var(--accent); font-size: 8px; }
|
|
2442
2463
|
.ai-mention-option strong { overflow: hidden; font-size: 10px; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
|
|
2443
2464
|
.ai-mention-empty { margin: 0; padding: 10px; color: var(--muted); font-size: 10px; text-align: center; }
|
|
@@ -2487,6 +2508,9 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2487
2508
|
.ai-citation-card pre { display: none; grid-column: 1 / -1; max-height: 160px; overflow: auto; margin: 0; padding: 9px; border-top: 1px solid var(--line); background: var(--surface); font: 10px/1.55 var(--font-cjk), sans-serif; white-space: pre-wrap; }
|
|
2488
2509
|
.ai-citation-card.is-expanded pre { display: block; }
|
|
2489
2510
|
.message-citations { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 7px; }.message-citations span { padding: 3px 6px; border: 1px solid currentColor; border-radius: 10px; font-size: 8px; opacity: .75; }
|
|
2511
|
+
.user-message-mentions { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; margin-top: 8px; padding-top: 7px; border-top: 1px solid color-mix(in srgb, currentColor 28%, transparent); font-size: 8px; line-height: 1.4; }
|
|
2512
|
+
.user-message-mentions-label { flex: 0 0 auto; opacity: .72; letter-spacing: .08em; }
|
|
2513
|
+
.user-message-mention { min-width: 0; max-width: 100%; padding: 2px 6px; overflow-wrap: anywhere; border: 1px solid color-mix(in srgb, currentColor 55%, transparent); border-radius: 999px; background: color-mix(in srgb, currentColor 12%, transparent); }
|
|
2490
2514
|
|
|
2491
2515
|
/* Toast 使用 Popover Top Layer;必须覆盖 UA 默认居中/系统边框/Canvas 背景 */
|
|
2492
2516
|
.toast-region,
|
|
@@ -2531,6 +2555,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2531
2555
|
background: var(--toast-error-bg);
|
|
2532
2556
|
color: var(--toast-error-fg);
|
|
2533
2557
|
}
|
|
2558
|
+
.toast.warning { border-color: color-mix(in srgb, #f0bd78 42%, transparent); background: #65451f; color: #fff7e8; }
|
|
2534
2559
|
.toast-confirmation {
|
|
2535
2560
|
display: grid;
|
|
2536
2561
|
gap: 8px;
|
|
@@ -2860,6 +2885,7 @@ select:focus, input:focus, textarea:focus { border-color: #a77768; box-shadow: 0
|
|
|
2860
2885
|
.character-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
|
|
2861
2886
|
.character-filter-toolbar-actions { justify-content: flex-start; }
|
|
2862
2887
|
.draft-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
|
|
2888
|
+
.setting-filter-toolbar { grid-template-columns: minmax(0, 1fr); }
|
|
2863
2889
|
}
|
|
2864
2890
|
.entity-editor-open { overflow: hidden; }
|
|
2865
2891
|
.preview-record-card { cursor: pointer; }
|
|
@@ -3527,7 +3553,7 @@ body { font-size: var(--ui-font-size); }
|
|
|
3527
3553
|
.ai-context-distribution-label small, .ai-context-distribution-label strong { font-size: calc(9px * var(--ai-font-scale)); }
|
|
3528
3554
|
.ai-send-button { font-size: calc(10px * var(--ai-font-scale)); }
|
|
3529
3555
|
.ai-citation-main strong { font-size: calc(10px * var(--ai-font-scale)); }
|
|
3530
|
-
.ai-citation-main small, .ai-citation-main span, .message-citations span { font-size: calc(9px * var(--ai-font-scale)); }
|
|
3556
|
+
.ai-citation-main small, .ai-citation-main span, .message-citations span, .user-message-mentions { font-size: calc(9px * var(--ai-font-scale)); }
|
|
3531
3557
|
.ai-history-item strong { font-size: calc(14px * var(--ai-font-scale)); }
|
|
3532
3558
|
.ai-history-item small { font-size: calc(10px * var(--ai-font-scale)); }
|
|
3533
3559
|
.ai-tool-call-info dt, .ai-tool-call-detail h3 { font-size: calc(9px * var(--ai-font-scale)); }
|
|
@@ -3541,6 +3567,8 @@ body { font-size: var(--ui-font-size); }
|
|
|
3541
3567
|
|
|
3542
3568
|
@media (max-width: 640px) {
|
|
3543
3569
|
.s3-backup-body { gap: 14px; padding: 14px 16px 18px; }
|
|
3570
|
+
.s3-backup-encryption-control { align-items: stretch; flex-direction: column; }
|
|
3571
|
+
.s3-backup-encryption-control > .s3-backup-status { width: fit-content; }
|
|
3544
3572
|
.s3-backup-overview, .s3-backup-history > header { align-items: stretch; flex-direction: column; }
|
|
3545
3573
|
.s3-backup-overview-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
|
|
3546
3574
|
.s3-backup-overview-actions button { width: 100%; }
|
|
@@ -3553,4 +3581,6 @@ body { font-size: var(--ui-font-size); }
|
|
|
3553
3581
|
.s3-backup-run-card details { grid-column: 1 / -1; }
|
|
3554
3582
|
.s3-backup-target-fields { grid-template-columns: minmax(0, 1fr) !important; padding-inline: 16px !important; }
|
|
3555
3583
|
.s3-backup-target-fields > * { grid-column: 1 !important; }
|
|
3584
|
+
.s3-backup-key-body { padding-inline: 16px; }
|
|
3585
|
+
.s3-backup-key-actions { display: grid; grid-template-columns: minmax(0, 1fr); }
|
|
3556
3586
|
}
|
package/dist/s3-backup.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
1
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { Agent as HttpAgent } from "node:http";
|
|
3
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
4
|
+
import { isIP } from "node:net";
|
|
2
5
|
import { DeleteObjectsCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
6
|
+
import { NodeHttpHandler } from "@smithy/node-http-handler";
|
|
7
|
+
import { encryptObject, generateKek } from "./backup-encryption.js";
|
|
3
8
|
import { PLATFORM_AI_WORK_ID } from "./database.js";
|
|
4
9
|
import { AppError } from "./errors.js";
|
|
5
10
|
import { logger, sanitizeError } from "./logger.js";
|
|
@@ -65,6 +70,12 @@ export function s3FailureServerResponse(error, secrets = []) {
|
|
|
65
70
|
class AwsS3ObjectClient {
|
|
66
71
|
client;
|
|
67
72
|
constructor(connection) {
|
|
73
|
+
const requestHandler = connection.resolvedAddresses?.length
|
|
74
|
+
? new NodeHttpHandler({
|
|
75
|
+
httpAgent: new HttpAgent({ keepAlive: true, maxSockets: 10, lookup: pinnedS3Lookup(connection) }),
|
|
76
|
+
httpsAgent: new HttpsAgent({ keepAlive: true, maxSockets: 10, lookup: pinnedS3Lookup(connection) })
|
|
77
|
+
})
|
|
78
|
+
: undefined;
|
|
68
79
|
this.client = new S3Client({
|
|
69
80
|
endpoint: connection.endpoint,
|
|
70
81
|
region: connection.region,
|
|
@@ -75,7 +86,8 @@ class AwsS3ObjectClient {
|
|
|
75
86
|
forcePathStyle: connection.forcePathStyle,
|
|
76
87
|
maxAttempts: 3,
|
|
77
88
|
requestChecksumCalculation: "WHEN_REQUIRED",
|
|
78
|
-
responseChecksumValidation: "WHEN_REQUIRED"
|
|
89
|
+
responseChecksumValidation: "WHEN_REQUIRED",
|
|
90
|
+
...(requestHandler ? { requestHandler } : {})
|
|
79
91
|
});
|
|
80
92
|
}
|
|
81
93
|
async objectExists(bucket, key) {
|
|
@@ -138,6 +150,35 @@ class AwsS3ObjectClient {
|
|
|
138
150
|
this.client.destroy();
|
|
139
151
|
}
|
|
140
152
|
}
|
|
153
|
+
function normalizedHostname(value) {
|
|
154
|
+
return value.replace(/^\[|\]$/gu, "").replace(/\.$/u, "").toLocaleLowerCase("en-US");
|
|
155
|
+
}
|
|
156
|
+
function pinnedS3Lookup(connection) {
|
|
157
|
+
const endpointHostname = normalizedHostname(new URL(connection.endpoint).hostname);
|
|
158
|
+
const addresses = (connection.resolvedAddresses ?? []).filter(({ address, family }) => isIP(address) === family);
|
|
159
|
+
return (hostname, options, callback) => {
|
|
160
|
+
const requestedHostname = normalizedHostname(hostname);
|
|
161
|
+
const expectedHostname = requestedHostname === endpointHostname
|
|
162
|
+
|| (!connection.forcePathStyle && requestedHostname.endsWith(`.${endpointHostname}`));
|
|
163
|
+
const requestedFamily = options.family === 4 || options.family === 6 ? options.family : 0;
|
|
164
|
+
const candidates = expectedHostname
|
|
165
|
+
? addresses.filter(({ family }) => requestedFamily === 0 || family === requestedFamily)
|
|
166
|
+
: [];
|
|
167
|
+
if (candidates.length === 0) {
|
|
168
|
+
const error = Object.assign(new Error("S3 endpoint hostname or address was not approved by the SSRF validator"), {
|
|
169
|
+
code: "ENOTFOUND"
|
|
170
|
+
});
|
|
171
|
+
callback(error, "", 0);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (options.all) {
|
|
175
|
+
callback(null, candidates.map(({ address, family }) => ({ address, family })));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const selected = candidates[0];
|
|
179
|
+
callback(null, selected.address, selected.family);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
141
182
|
function requiredString(row, key) {
|
|
142
183
|
const value = row[key];
|
|
143
184
|
if (typeof value !== "string")
|
|
@@ -179,9 +220,11 @@ export class S3BackupManager {
|
|
|
179
220
|
validateEndpoint;
|
|
180
221
|
now;
|
|
181
222
|
log;
|
|
223
|
+
encryptionConfirmationTtlMs;
|
|
182
224
|
queuedTargetIds = new Set();
|
|
183
225
|
executionChain = Promise.resolve();
|
|
184
226
|
schedulerTimer = null;
|
|
227
|
+
pendingBackupEncryption = null;
|
|
185
228
|
disposed = false;
|
|
186
229
|
constructor(database, vault, store, attachmentStorage, options = {}) {
|
|
187
230
|
this.database = database;
|
|
@@ -198,6 +241,10 @@ export class S3BackupManager {
|
|
|
198
241
|
this.validateEndpoint = options.validateEndpoint;
|
|
199
242
|
this.now = options.now ?? (() => new Date());
|
|
200
243
|
this.log = options.logger ?? logger;
|
|
244
|
+
const encryptionConfirmationTtlMs = Number(options.encryptionConfirmationTtlMs ?? 10 * 60_000);
|
|
245
|
+
this.encryptionConfirmationTtlMs = Number.isFinite(encryptionConfirmationTtlMs) && encryptionConfirmationTtlMs > 0
|
|
246
|
+
? Math.floor(encryptionConfirmationTtlMs)
|
|
247
|
+
: 10 * 60_000;
|
|
201
248
|
}
|
|
202
249
|
listTargets() {
|
|
203
250
|
return this.database.all("SELECT * FROM s3_backup_targets ORDER BY sort_order, created_at, id").map((row) => this.mapTarget(row));
|
|
@@ -205,6 +252,91 @@ export class S3BackupManager {
|
|
|
205
252
|
getTarget(targetId) {
|
|
206
253
|
return this.mapTarget(this.requireTargetRow(targetId));
|
|
207
254
|
}
|
|
255
|
+
getEncryptionState() {
|
|
256
|
+
const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
|
|
257
|
+
if (!row)
|
|
258
|
+
return { enabled: false, keyConfiguredAt: null };
|
|
259
|
+
const keyConfigured = this.hasBackupEncryptionKey(row);
|
|
260
|
+
if (Number(row.enabled) === 1 && !keyConfigured) {
|
|
261
|
+
throw new AppError(500, "BACKUP_ENCRYPTION_STATE_INVALID", "S3 备份加密配置缺少密钥");
|
|
262
|
+
}
|
|
263
|
+
return {
|
|
264
|
+
enabled: Number(row.enabled) === 1,
|
|
265
|
+
keyConfiguredAt: keyConfigured ? requiredString(row, "created_at") : null
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
setEncryptionEnabled(enabled) {
|
|
269
|
+
if (!enabled)
|
|
270
|
+
this.pendingBackupEncryption = null;
|
|
271
|
+
return this.database.transaction(() => {
|
|
272
|
+
const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
|
|
273
|
+
const currentEnabled = Number(row?.enabled ?? 0) === 1;
|
|
274
|
+
const keyConfigured = row ? this.hasBackupEncryptionKey(row) : false;
|
|
275
|
+
if (currentEnabled && !keyConfigured) {
|
|
276
|
+
throw new AppError(500, "BACKUP_ENCRYPTION_STATE_INVALID", "S3 备份加密配置缺少密钥");
|
|
277
|
+
}
|
|
278
|
+
if (currentEnabled === enabled) {
|
|
279
|
+
return {
|
|
280
|
+
enabled,
|
|
281
|
+
keyConfiguredAt: keyConfigured && row ? requiredString(row, "created_at") : null
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const timestamp = this.now().toISOString();
|
|
285
|
+
if (!enabled) {
|
|
286
|
+
this.database.run("UPDATE s3_backup_encryption SET enabled = 0, updated_at = ? WHERE id = 1", timestamp);
|
|
287
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-encryption.disabled", "s3-backup-encryption", "s3-backup-encryption");
|
|
288
|
+
return {
|
|
289
|
+
enabled: false,
|
|
290
|
+
keyConfiguredAt: keyConfigured && row ? requiredString(row, "created_at") : null
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
if (row && keyConfigured) {
|
|
294
|
+
this.database.run("UPDATE s3_backup_encryption SET enabled = 1, updated_at = ? WHERE id = 1", timestamp);
|
|
295
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-encryption.enabled", "s3-backup-encryption", "s3-backup-encryption", { keyGenerated: false });
|
|
296
|
+
return { enabled: true, keyConfiguredAt: requiredString(row, "created_at") };
|
|
297
|
+
}
|
|
298
|
+
const key = generateKek();
|
|
299
|
+
const confirmationToken = randomBytes(32).toString("base64url");
|
|
300
|
+
this.pendingBackupEncryption = {
|
|
301
|
+
key,
|
|
302
|
+
confirmationTokenHash: createHash("sha256").update(confirmationToken).digest(),
|
|
303
|
+
expiresAtMs: this.now().getTime() + this.encryptionConfirmationTtlMs
|
|
304
|
+
};
|
|
305
|
+
return { enabled: false, keyConfiguredAt: null, key, confirmationToken };
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
confirmEncryptionEnabled(confirmationToken) {
|
|
309
|
+
const pending = this.pendingBackupEncryption;
|
|
310
|
+
const tokenHash = createHash("sha256").update(confirmationToken).digest();
|
|
311
|
+
if (!pending || pending.expiresAtMs <= this.now().getTime() || !timingSafeEqual(pending.confirmationTokenHash, tokenHash)) {
|
|
312
|
+
if (pending && pending.expiresAtMs <= this.now().getTime())
|
|
313
|
+
this.pendingBackupEncryption = null;
|
|
314
|
+
throw new AppError(409, "BACKUP_ENCRYPTION_CONFIRMATION_INVALID", "备份加密确认已失效,请重新开启并保存新密钥");
|
|
315
|
+
}
|
|
316
|
+
const state = this.database.transaction(() => {
|
|
317
|
+
const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
|
|
318
|
+
const currentEnabled = Number(row?.enabled ?? 0) === 1;
|
|
319
|
+
const keyConfigured = row ? this.hasBackupEncryptionKey(row) : false;
|
|
320
|
+
if (currentEnabled || keyConfigured) {
|
|
321
|
+
throw new AppError(409, "BACKUP_ENCRYPTION_CONFIRMATION_INVALID", "备份加密状态已变化,请刷新后重试");
|
|
322
|
+
}
|
|
323
|
+
const timestamp = this.now().toISOString();
|
|
324
|
+
const encrypted = this.vault.encrypt(pending.key);
|
|
325
|
+
if (row) {
|
|
326
|
+
this.database.run(`UPDATE s3_backup_encryption SET enabled = 1, kek_encrypted = ?, kek_iv = ?, kek_tag = ?,
|
|
327
|
+
created_at = ?, updated_at = ? WHERE id = 1`, encrypted.encrypted, encrypted.iv, encrypted.tag, timestamp, timestamp);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
this.database.run(`INSERT INTO s3_backup_encryption (
|
|
331
|
+
id, enabled, kek_encrypted, kek_iv, kek_tag, created_at, updated_at
|
|
332
|
+
) VALUES (1, 1, ?, ?, ?, ?, ?)`, encrypted.encrypted, encrypted.iv, encrypted.tag, timestamp, timestamp);
|
|
333
|
+
}
|
|
334
|
+
this.store.audit(PLATFORM_AI_WORK_ID, "platform.backup-encryption.enabled", "s3-backup-encryption", "s3-backup-encryption", { keyGenerated: true });
|
|
335
|
+
return { enabled: true, keyConfiguredAt: timestamp };
|
|
336
|
+
});
|
|
337
|
+
this.pendingBackupEncryption = null;
|
|
338
|
+
return state;
|
|
339
|
+
}
|
|
208
340
|
createTarget(input) {
|
|
209
341
|
const targetId = randomUUID();
|
|
210
342
|
const timestamp = this.now().toISOString();
|
|
@@ -345,11 +477,31 @@ export class S3BackupManager {
|
|
|
345
477
|
this.schedulerTimer = setInterval(poll, 30_000);
|
|
346
478
|
this.schedulerTimer.unref();
|
|
347
479
|
}
|
|
348
|
-
async waitForIdle() {
|
|
349
|
-
|
|
480
|
+
async waitForIdle(timeoutMs) {
|
|
481
|
+
const executionChain = this.executionChain;
|
|
482
|
+
if (timeoutMs === undefined) {
|
|
483
|
+
await executionChain;
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const normalizedTimeoutMs = Math.floor(timeoutMs);
|
|
487
|
+
if (!Number.isFinite(timeoutMs) || normalizedTimeoutMs < 1) {
|
|
488
|
+
throw new RangeError("S3 backup idle timeout must be a positive finite number");
|
|
489
|
+
}
|
|
490
|
+
let timer = null;
|
|
491
|
+
const timeout = new Promise((_, reject) => {
|
|
492
|
+
timer = setTimeout(() => reject(new AppError(504, "BACKUP_IDLE_TIMEOUT", `等待 S3 备份执行链结束超时(${normalizedTimeoutMs} 毫秒)`)), normalizedTimeoutMs);
|
|
493
|
+
});
|
|
494
|
+
try {
|
|
495
|
+
await Promise.race([executionChain, timeout]);
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
if (timer)
|
|
499
|
+
clearTimeout(timer);
|
|
500
|
+
}
|
|
350
501
|
}
|
|
351
502
|
dispose() {
|
|
352
503
|
this.disposed = true;
|
|
504
|
+
this.pendingBackupEncryption = null;
|
|
353
505
|
if (this.schedulerTimer)
|
|
354
506
|
clearInterval(this.schedulerTimer);
|
|
355
507
|
this.schedulerTimer = null;
|
|
@@ -377,30 +529,35 @@ export class S3BackupManager {
|
|
|
377
529
|
let imagesUploaded = 0;
|
|
378
530
|
let imagesSkipped = 0;
|
|
379
531
|
let databasesDeleted = 0;
|
|
532
|
+
let encryptionKey = null;
|
|
380
533
|
try {
|
|
381
|
-
|
|
534
|
+
encryptionKey = this.activeBackupEncryptionKey();
|
|
535
|
+
const resolvedAddresses = await this.validateEndpoint?.(target.endpoint);
|
|
382
536
|
client = this.clientFactory({
|
|
383
537
|
endpoint: target.endpoint,
|
|
384
538
|
region: target.region,
|
|
385
539
|
accessKeyId: target.accessKeyId,
|
|
386
540
|
secretAccessKey: target.secretAccessKey,
|
|
387
|
-
forcePathStyle: target.forcePathStyle
|
|
541
|
+
forcePathStyle: target.forcePathStyle,
|
|
542
|
+
...(resolvedAddresses?.length ? { resolvedAddresses } : {})
|
|
388
543
|
});
|
|
389
544
|
const databaseSnapshot = await this.snapshotDatabase();
|
|
390
545
|
databaseKey = `${target.rootPrefix}/db/scriverse-${this.snapshotTimestamp(started)}-${runId.replaceAll("-", "").slice(0, 8)}.db`;
|
|
391
546
|
if (!this.masterKey)
|
|
392
547
|
throw new AppError(500, "BACKUP_MASTER_KEY_UNAVAILABLE", "S3 备份缺少 CredentialVault 恢复密钥");
|
|
548
|
+
const masterKeyBody = encryptionKey ? encryptObject(this.masterKey, encryptionKey) : this.masterKey;
|
|
393
549
|
await this.withRequestTimeout(client.putObject({
|
|
394
550
|
bucket: target.bucket,
|
|
395
551
|
key: `${target.rootPrefix}/master.key`,
|
|
396
|
-
body:
|
|
552
|
+
body: masterKeyBody,
|
|
397
553
|
contentType: "application/octet-stream"
|
|
398
554
|
}), "上传 CredentialVault 恢复密钥");
|
|
555
|
+
const databaseBody = encryptionKey ? encryptObject(databaseSnapshot, encryptionKey) : databaseSnapshot;
|
|
399
556
|
await this.withRequestTimeout(client.putObject({
|
|
400
557
|
bucket: target.bucket,
|
|
401
558
|
key: databaseKey,
|
|
402
|
-
body:
|
|
403
|
-
contentType: "application/vnd.sqlite3"
|
|
559
|
+
body: databaseBody,
|
|
560
|
+
contentType: encryptionKey ? "application/octet-stream" : "application/vnd.sqlite3"
|
|
404
561
|
}), "上传数据库快照");
|
|
405
562
|
if (target.backupImages) {
|
|
406
563
|
for (const source of this.backupImageSources(target.rootPrefix)) {
|
|
@@ -409,11 +566,12 @@ export class S3BackupManager {
|
|
|
409
566
|
continue;
|
|
410
567
|
}
|
|
411
568
|
const body = await source.read();
|
|
569
|
+
const uploadBody = encryptionKey ? encryptObject(body, encryptionKey) : body;
|
|
412
570
|
await this.withRequestTimeout(client.putObject({
|
|
413
571
|
bucket: target.bucket,
|
|
414
572
|
key: source.objectKey,
|
|
415
|
-
body,
|
|
416
|
-
contentType: source.contentType,
|
|
573
|
+
body: uploadBody,
|
|
574
|
+
contentType: encryptionKey ? "application/octet-stream" : source.contentType,
|
|
417
575
|
metadata: { sha256: source.sha256 }
|
|
418
576
|
}), "上传图片");
|
|
419
577
|
imagesUploaded += 1;
|
|
@@ -445,7 +603,11 @@ export class S3BackupManager {
|
|
|
445
603
|
});
|
|
446
604
|
}
|
|
447
605
|
catch (error) {
|
|
448
|
-
const serverResponse = s3FailureServerResponse(error, [
|
|
606
|
+
const serverResponse = s3FailureServerResponse(error, [
|
|
607
|
+
target.accessKeyId,
|
|
608
|
+
target.secretAccessKey,
|
|
609
|
+
...(encryptionKey ? [encryptionKey] : [])
|
|
610
|
+
]);
|
|
449
611
|
const errorMessage = typeof serverResponse.message === "string" ? serverResponse.message : "S3 备份失败";
|
|
450
612
|
const finishedAt = this.now().toISOString();
|
|
451
613
|
this.database.transaction(() => {
|
|
@@ -488,6 +650,29 @@ export class S3BackupManager {
|
|
|
488
650
|
throw new AppError(404, "BACKUP_TARGET_NOT_FOUND", "S3 备份目标不存在");
|
|
489
651
|
return row;
|
|
490
652
|
}
|
|
653
|
+
hasBackupEncryptionKey(row) {
|
|
654
|
+
return typeof row.kek_encrypted === "string"
|
|
655
|
+
&& typeof row.kek_iv === "string"
|
|
656
|
+
&& typeof row.kek_tag === "string";
|
|
657
|
+
}
|
|
658
|
+
activeBackupEncryptionKey() {
|
|
659
|
+
const row = this.database.get("SELECT * FROM s3_backup_encryption WHERE id = 1");
|
|
660
|
+
if (!row || Number(row.enabled) !== 1)
|
|
661
|
+
return null;
|
|
662
|
+
if (!this.hasBackupEncryptionKey(row)) {
|
|
663
|
+
throw new AppError(500, "BACKUP_ENCRYPTION_STATE_INVALID", "S3 备份加密配置缺少密钥");
|
|
664
|
+
}
|
|
665
|
+
try {
|
|
666
|
+
return this.vault.decrypt({
|
|
667
|
+
encrypted: requiredString(row, "kek_encrypted"),
|
|
668
|
+
iv: requiredString(row, "kek_iv"),
|
|
669
|
+
tag: requiredString(row, "kek_tag")
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
catch {
|
|
673
|
+
throw new AppError(500, "BACKUP_ENCRYPTION_KEY_UNAVAILABLE", "S3 备份加密密钥无法读取");
|
|
674
|
+
}
|
|
675
|
+
}
|
|
491
676
|
runtimeTarget(row) {
|
|
492
677
|
return {
|
|
493
678
|
...this.mapTarget(row),
|