pi-roundtable-web 0.29.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/dist/chunk-account-66e6ezdq.js +1 -0
- package/dist/{chunk-action-menu-q5y7xpry.js → chunk-action-menu-ap5xn7bf.js} +1 -1
- package/dist/{chunk-agent-x74xezyt.js → chunk-agent-sapt5qhn.js} +1 -1
- package/dist/chunk-arranged-rooms-n6bk696f.js +1 -0
- package/dist/chunk-attachments-p5v9gqyt.js +102 -0
- package/dist/chunk-bulk-actions-aze4ewc4.js +1 -0
- package/dist/chunk-character-creator-m7tw20ef.js +225 -0
- package/dist/{chunk-codeeditor-239ss6a2.js → chunk-codeeditor-yy6pq8mv.js} +2 -2
- package/dist/chunk-confirm-0cajsfhk.js +1 -0
- package/dist/chunk-connectors-tw0tbm21.js +37 -0
- package/dist/chunk-conversations-wkm23m5r.js +1 -0
- package/dist/{chunk-council-sheet-a5e6tspr.js → chunk-council-sheet-mz7s23nq.js} +2 -2
- package/dist/chunk-council-view-zgvzxtgn.js +1 -0
- package/dist/{chunk-drag-7873fvzw.js → chunk-drag-d1bpy8w6.js} +1 -1
- package/dist/{chunk-entry-5zmjxadx.js → chunk-entry-7zr5b1fp.js} +1 -1
- package/dist/{chunk-folder-list-h9hmbm8e.js → chunk-folder-list-bs4eh3g9.js} +1 -1
- package/dist/chunk-folds-eez1q41c.js +1 -0
- package/dist/{chunk-gate-fa6za28z.js → chunk-gate-dt91egcd.js} +1 -1
- package/dist/chunk-host-icons-nfe2t8tk.js +1 -0
- package/dist/chunk-host-picture-4rtswxwa.js +1 -0
- package/dist/chunk-host-state-1vxzmmq6.js +1 -0
- package/dist/chunk-host-ui-x38bf0et.js +1 -0
- package/dist/{chunk-index-4re5prfp.js → chunk-index-0h99xcs3.js} +2 -2
- package/dist/{chunk-index-5ssncptg.js → chunk-index-1kb8tv4v.js} +2 -2
- package/dist/chunk-index-24cggw6k.js +50 -0
- package/dist/chunk-index-2g8xtwqg.js +2 -0
- package/dist/chunk-index-3n38xe7m.js +2 -0
- package/dist/{chunk-index-en5dm1zv.js → chunk-index-3tksdmx7.js} +2 -2
- package/dist/{chunk-index-rr73h4kc.js → chunk-index-4a1tdadd.js} +3 -3
- package/dist/{chunk-index-f4gqzfkv.js → chunk-index-53fwc7sc.js} +2 -2
- package/dist/chunk-index-55ps4x5n.js +2 -0
- package/dist/{chunk-index-4krm48hm.js → chunk-index-5j1v2q7w.js} +1 -1
- package/dist/chunk-index-5w6tb7p0.js +2 -0
- package/dist/{chunk-index-y6p8j3zv.js → chunk-index-6btdp13p.js} +1 -1
- package/dist/chunk-index-6m7dwsmv.js +1 -0
- package/dist/{chunk-index-7c9fn21n.js → chunk-index-6rdywten.js} +2 -2
- package/dist/{chunk-index-gj3n4dh5.js → chunk-index-6s2pqwna.js} +2 -2
- package/dist/{chunk-index-r0hk9kyd.js → chunk-index-7mrkq793.js} +2 -2
- package/dist/chunk-index-7xcbpeqz.js +2 -0
- package/dist/chunk-index-88wh5eyj.js +2 -0
- package/dist/{chunk-index-ntt3em2k.js → chunk-index-8pbwr3rs.js} +1 -1
- package/dist/{chunk-index-bjeg3e2j.js → chunk-index-8wpvj7k5.js} +2 -2
- package/dist/chunk-index-97dcp1vh.js +1 -0
- package/dist/chunk-index-a7e6rhxy.js +2 -0
- package/dist/{chunk-index-4eag6s50.js → chunk-index-bxf8eq1s.js} +2 -2
- package/dist/chunk-index-c8gm1crh.js +120 -0
- package/dist/{chunk-index-4jnn2g09.js → chunk-index-ce7hsda9.js} +2 -2
- package/dist/chunk-index-cfesmk1f.js +2 -0
- package/dist/{chunk-index-zdzzf3fp.js → chunk-index-cmzvq3nf.js} +1 -1
- package/dist/{chunk-index-35zqk1zr.js → chunk-index-d4mx2vvf.js} +2 -2
- package/dist/chunk-index-d74tpnkd.js +2 -0
- package/dist/chunk-index-d941ae7c.js +2 -0
- package/dist/{chunk-index-rtz4936v.js → chunk-index-dr0jr7d3.js} +3 -3
- package/dist/{chunk-index-pcbkp62w.js → chunk-index-en728705.js} +2 -2
- package/dist/chunk-index-fb3kzyyp.js +2 -0
- package/dist/{chunk-index-a8c0ap8g.js → chunk-index-ffnd0jcj.js} +2 -2
- package/dist/chunk-index-fn6305vn.js +3 -0
- package/dist/{chunk-index-evhaaqzk.js → chunk-index-gdh7wsd5.js} +11 -11
- package/dist/{chunk-index-3tjmyd1h.js → chunk-index-m76gdwnk.js} +1 -1
- package/dist/chunk-index-mr391t49.js +2 -0
- package/dist/chunk-index-n3khm9fq.js +2 -0
- package/dist/{chunk-index-93f3e06v.js → chunk-index-nn4w6a04.js} +2 -2
- package/dist/{chunk-index-hef4gwtb.js → chunk-index-nr275sa7.js} +2 -2
- package/dist/{chunk-index-cttrqfm8.js → chunk-index-prq011b6.js} +1 -1
- package/dist/{chunk-index-h2a42p6s.js → chunk-index-q4k9vk04.js} +17 -1
- package/dist/chunk-index-rgdaec7y.js +2 -0
- package/dist/chunk-index-rjs1can6.js +2 -0
- package/dist/chunk-index-s4a8e4qm.js +2 -0
- package/dist/{chunk-index-q33ppq0j.js → chunk-index-sz381vda.js} +3 -3
- package/dist/{chunk-index-1652v386.css → chunk-index-t7csmbpy.css} +1 -1
- package/dist/{chunk-index-amspdq2j.js → chunk-index-v9jqyr1f.js} +1 -1
- package/dist/{chunk-index-e46652wf.js → chunk-index-vpq2cj14.js} +1 -1
- package/dist/chunk-index-vsaggfns.js +2 -0
- package/dist/{chunk-index-7k5895sn.js → chunk-index-w17wgjby.js} +3 -3
- package/dist/{chunk-index-teyx2hz7.js → chunk-index-wgrx2ybn.js} +2 -2
- package/dist/chunk-index-wsv46y9p.js +2 -0
- package/dist/{chunk-index-v6h43553.js → chunk-index-xbj816p0.js} +2 -2
- package/dist/{chunk-index-aycxt14t.js → chunk-index-yr758n6t.js} +2 -2
- package/dist/{chunk-index-pfs6rm6w.js → chunk-index-ysv36nbk.js} +2 -2
- package/dist/{chunk-index-haac6nbt.js → chunk-index-ze9p4npm.js} +2 -2
- package/dist/chunk-index-zfdvyrjt.js +2 -0
- package/dist/chunk-lang-css-3h22se37.js +1 -0
- package/dist/{chunk-lang-dockerfile-2p7e3ssz.js → chunk-lang-dockerfile-y24eejnh.js} +1 -1
- package/dist/{chunk-lang-go-zxp9k517.js → chunk-lang-go-qzgv1161.js} +2 -2
- package/dist/chunk-lang-html-9rvrmmpg.js +1 -0
- package/dist/chunk-lang-javascript-4kv8s3tk.js +1 -0
- package/dist/{chunk-lang-json-zmym50rg.js → chunk-lang-json-99v80v88.js} +1 -1
- package/dist/{chunk-lang-markdown-ww2kcs85.js → chunk-lang-markdown-gn8he877.js} +4 -4
- package/dist/{chunk-lang-python-ng1zdtd4.js → chunk-lang-python-prsw4p4k.js} +2 -2
- package/dist/{chunk-lang-rust-6ynhxjdd.js → chunk-lang-rust-g6xqta16.js} +1 -1
- package/dist/{chunk-lang-shell-39t9bz2s.js → chunk-lang-shell-dmpnzc6w.js} +1 -1
- package/dist/{chunk-lang-sql-w875ejpg.js → chunk-lang-sql-wz7ca27b.js} +2 -2
- package/dist/{chunk-lang-toml-b8ckgkg5.js → chunk-lang-toml-160prtq4.js} +1 -1
- package/dist/chunk-lang-typescript-mp0yw903.js +1 -0
- package/dist/{chunk-lang-xml-bz91cjp1.js → chunk-lang-xml-9htkz2dd.js} +1 -1
- package/dist/{chunk-lang-yaml-g9zy0tdv.js → chunk-lang-yaml-08749wd5.js} +1 -1
- package/dist/chunk-layout-actions-tdx896xf.js +1 -0
- package/dist/chunk-menu-items-trhybv1g.js +1 -0
- package/dist/chunk-mode-toggle-4dtfphq4.js +1 -0
- package/dist/chunk-note-row-28e5x7n8.js +233 -0
- package/dist/chunk-notes-5nkayhw8.js +1 -0
- package/dist/chunk-notice-bell-xz8a04am.js +1 -0
- package/dist/{chunk-notices-mtn7pg1z.js → chunk-notices-d0cwvk27.js} +1 -1
- package/dist/{chunk-onboarding-f3hhxgg4.js → chunk-onboarding-zs4rtea9.js} +1 -1
- package/dist/{chunk-overview-qmepntrr.js → chunk-overview-9sb3ywa8.js} +1 -1
- package/dist/{chunk-palette-edkn1ypq.js → chunk-palette-5sfhrv4x.js} +1 -1
- package/dist/chunk-placement-items-e2neja55.js +1 -0
- package/dist/{chunk-profile-pwfq0wns.js → chunk-profile-jffhtpqf.js} +1 -1
- package/dist/{chunk-prompt-history-xp9hmeqt.js → chunk-prompt-history-1jz2gk3v.js} +1 -1
- package/dist/{chunk-prompts-a56qvytg.js → chunk-prompts-0t30ttd0.js} +1 -1
- package/dist/chunk-room-confirms-6595921h.js +1 -0
- package/dist/{chunk-room-menu-1m2c2ekt.js → chunk-room-menu-hwkjyxwe.js} +1 -1
- package/dist/chunk-rooms-menu-button-k0eq1tcr.js +1 -0
- package/dist/{chunk-schedules-kwnw7p8v.js → chunk-schedules-e12y8fw2.js} +1 -1
- package/dist/{chunk-seat-menu-m2n9nvv0.js → chunk-seat-menu-n2bcw0mf.js} +1 -1
- package/dist/chunk-sheet-hosts-myvm2vhd.js +29 -0
- package/dist/chunk-shortcut-help-434sqnxc.js +26 -0
- package/dist/chunk-shortcuts-mjxph0g7.js +1 -0
- package/dist/chunk-skill-editor-98492yrt.js +8 -0
- package/dist/chunk-skills-mfv8ppfh.js +1 -0
- package/dist/chunk-start-over-line-m526193z.js +16 -0
- package/dist/{chunk-switcher-list-ehsn2wsk.js → chunk-switcher-list-5ngjar24.js} +1 -1
- package/dist/{chunk-tab-dot-gkpe06q8.js → chunk-tab-dot-tzjkahj5.js} +1 -1
- package/dist/{chunk-table-av3kh3tt.js → chunk-table-xbtbex2z.js} +1 -1
- package/dist/{chunk-table2d-5x3jptp9.js → chunk-table2d-61bvrykm.js} +1 -1
- package/dist/{chunk-table3d-rsvajx7r.js → chunk-table3d-5a7vbgyj.js} +24 -24
- package/dist/chunk-thread-actions-bf6vf2mt.js +1 -0
- package/dist/{chunk-thread-cards-grzszfha.js → chunk-thread-cards-n0n603vf.js} +1 -1
- package/dist/chunk-thread-list-7bkdn51x.js +227 -0
- package/dist/{chunk-transcript-t5szb8r0.js → chunk-transcript-8ztrkt6g.js} +1 -1
- package/dist/chunk-waiting-chip-y83c90v3.js +32 -0
- package/dist/chunk-waiting-s273kcgz.js +1 -0
- package/dist/chunk-where-jr791gzj.js +1 -0
- package/dist/chunk-workspace-dw71mshw.js +586 -0
- package/dist/chunk-zh-tw-vm5d0w6j.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +2 -2
- package/src/api-types.ts +12 -0
- package/src/app/app.ts +7 -0
- package/src/app/card-log.ts +10 -2
- package/src/app/protocol.ts +18 -0
- package/src/app/queued-delivery.ts +107 -0
- package/src/app/queued-frames.ts +115 -0
- package/src/app/room-log.ts +39 -7
- package/src/app/routes.ts +97 -5
- package/src/app/socket.ts +12 -2
- package/src/app/surface.ts +37 -6
- package/src/app/zip.ts +124 -0
- package/src/auth/sessions.ts +9 -1
- package/src/host/cards.ts +10 -3
- package/src/host/stored-json.ts +23 -0
- package/src/i18n/zh-tw.ts +14 -0
- package/src/notices/mount.ts +1 -0
- package/src/notices/store.ts +9 -1
- package/src/web-plugin.ts +4 -1
- package/dist/chunk-account-9616x405.js +0 -1
- package/dist/chunk-arranged-rooms-9prq46vh.js +0 -1
- package/dist/chunk-bulk-actions-5dc4m9ad.js +0 -1
- package/dist/chunk-character-creator-wf02eg0h.js +0 -1
- package/dist/chunk-confirm-bhrx9195.js +0 -1
- package/dist/chunk-connectors-k77pd9by.js +0 -37
- package/dist/chunk-conversations-ftcpaqw1.js +0 -1
- package/dist/chunk-council-view-6593rqbr.js +0 -1
- package/dist/chunk-folds-vxq86t1s.js +0 -1
- package/dist/chunk-host-icons-y6frbzqt.js +0 -1
- package/dist/chunk-host-picture-fqg55rbr.js +0 -1
- package/dist/chunk-host-state-65tdhkyq.js +0 -1
- package/dist/chunk-host-ui-233kz7qp.js +0 -1
- package/dist/chunk-index-2c00zf3z.js +0 -2
- package/dist/chunk-index-2dhrqvba.js +0 -2
- package/dist/chunk-index-2s4bkrz3.js +0 -2
- package/dist/chunk-index-2ypaqwjy.js +0 -2
- package/dist/chunk-index-39qq37j5.js +0 -2
- package/dist/chunk-index-57teqgct.js +0 -2
- package/dist/chunk-index-9m9v1p7s.js +0 -2
- package/dist/chunk-index-a8nqn3y0.js +0 -3
- package/dist/chunk-index-am7ghh75.js +0 -120
- package/dist/chunk-index-amxa2bn3.js +0 -2
- package/dist/chunk-index-dajanzxc.js +0 -1
- package/dist/chunk-index-e2eeckhh.js +0 -2
- package/dist/chunk-index-ek98d2rn.js +0 -2
- package/dist/chunk-index-gp4chnp3.js +0 -2
- package/dist/chunk-index-mg96sqpk.js +0 -2
- package/dist/chunk-index-nsya88fn.js +0 -1
- package/dist/chunk-index-smz90j6p.js +0 -2
- package/dist/chunk-index-vzqwn3wp.js +0 -2
- package/dist/chunk-index-wt2asefx.js +0 -50
- package/dist/chunk-index-x4tq9btg.js +0 -2
- package/dist/chunk-index-yvcdg5hh.js +0 -2
- package/dist/chunk-index-zeqfnkty.js +0 -2
- package/dist/chunk-index-zpwb8mkk.js +0 -2
- package/dist/chunk-lang-css-avzhvm72.js +0 -1
- package/dist/chunk-lang-html-red02gep.js +0 -1
- package/dist/chunk-lang-javascript-n33kmjcx.js +0 -1
- package/dist/chunk-lang-typescript-qf8acbb2.js +0 -1
- package/dist/chunk-layout-actions-fyzzzeyp.js +0 -1
- package/dist/chunk-menu-items-yvnk3vds.js +0 -1
- package/dist/chunk-mode-toggle-ccv271hm.js +0 -1
- package/dist/chunk-note-row-314py1pp.js +0 -204
- package/dist/chunk-notes-4rnrddzw.js +0 -1
- package/dist/chunk-notice-bell-z9wgamnz.js +0 -1
- package/dist/chunk-placement-items-trsa5t23.js +0 -1
- package/dist/chunk-room-confirms-gqpkz4h0.js +0 -1
- package/dist/chunk-rooms-menu-button-tcvda1gq.js +0 -1
- package/dist/chunk-sheet-hosts-pr8xktyq.js +0 -29
- package/dist/chunk-shortcut-help-sbvrds1m.js +0 -26
- package/dist/chunk-shortcuts-8705p7q3.js +0 -1
- package/dist/chunk-skill-editor-7xy09sq1.js +0 -8
- package/dist/chunk-skills-cnc25x7q.js +0 -1
- package/dist/chunk-start-over-line-97zdd9pb.js +0 -16
- package/dist/chunk-thread-actions-7xtkegwm.js +0 -1
- package/dist/chunk-thread-list-9bjjac3j.js +0 -217
- package/dist/chunk-waiting-chip-d8b6a0p8.js +0 -28
- package/dist/chunk-waiting-ts3kxjvv.js +0 -1
- package/dist/chunk-where-3d3ncsa5.js +0 -1
- package/dist/chunk-workspace-ct6hvn0z.js +0 -578
- package/dist/chunk-zh-tw-v9v1eerm.js +0 -1
package/src/app/room-log.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type RoomMessage,
|
|
9
9
|
START_OVER_TEXT,
|
|
10
10
|
} from "../api-types.ts";
|
|
11
|
-
import { storedJson } from "../host/stored-json.ts";
|
|
11
|
+
import { jsonValuesMigration, storedJson } from "../host/stored-json.ts";
|
|
12
12
|
|
|
13
13
|
/** A message to store; the log numbers it and stamps its time. */
|
|
14
14
|
export interface NewRoomMessage {
|
|
@@ -49,6 +49,7 @@ interface Row {
|
|
|
49
49
|
edited_at: Date | null;
|
|
50
50
|
note: boolean | null;
|
|
51
51
|
intercom?: RoomIntercom | string | null;
|
|
52
|
+
delivery: "steered" | "cancelled" | null;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/** The most messages one page of history holds. */
|
|
@@ -78,6 +79,7 @@ function messageOf(row: Row): RoomMessage {
|
|
|
78
79
|
...(row.intercom == null
|
|
79
80
|
? {}
|
|
80
81
|
: { intercom: storedJson(row.intercom, "intercom card") }),
|
|
82
|
+
...(row.delivery === null ? {} : { delivery: row.delivery }),
|
|
81
83
|
};
|
|
82
84
|
}
|
|
83
85
|
|
|
@@ -94,6 +96,13 @@ export class RoomLog {
|
|
|
94
96
|
this.#sql = sql;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
/** Its jsonb columns stored as the JSON values themselves, not as the text of them. */
|
|
100
|
+
static readonly jsonValuesMigration: Migration = jsonValuesMigration(
|
|
101
|
+
"web-room-messages-json-values",
|
|
102
|
+
"web_room_messages",
|
|
103
|
+
["files", "picture", "intercom"],
|
|
104
|
+
);
|
|
105
|
+
|
|
97
106
|
/** The log's table. */
|
|
98
107
|
static readonly migration: Migration = {
|
|
99
108
|
name: "web-room-messages",
|
|
@@ -152,6 +161,14 @@ export class RoomLog {
|
|
|
152
161
|
},
|
|
153
162
|
};
|
|
154
163
|
|
|
164
|
+
/** The column that records how an owner message sent during a turn went: steered into it, or cancelled while it waited. */
|
|
165
|
+
static readonly deliveryMigration: Migration = {
|
|
166
|
+
name: "web-room-messages-delivery",
|
|
167
|
+
up: async (sql) => {
|
|
168
|
+
await sql`ALTER TABLE web_room_messages ADD COLUMN IF NOT EXISTS delivery text`;
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
155
172
|
async append(message: NewRoomMessage): Promise<RoomMessage> {
|
|
156
173
|
const [row] = await this.#insert(message, false);
|
|
157
174
|
if (!row) throw new Error("the room log stored nothing");
|
|
@@ -184,18 +201,18 @@ export class RoomLog {
|
|
|
184
201
|
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
|
|
185
202
|
author_kind, author_label, picture, reference, created_at, note, intercom)
|
|
186
203
|
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
187
|
-
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
|
|
188
|
-
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
|
|
189
|
-
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
|
|
204
|
+
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::text::jsonb,
|
|
205
|
+
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::text::jsonb,
|
|
206
|
+
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::text::jsonb)
|
|
190
207
|
ON CONFLICT (room, reference) WHERE reference IS NOT NULL DO NOTHING
|
|
191
208
|
RETURNING *`
|
|
192
209
|
: await this.#sql`
|
|
193
210
|
INSERT INTO web_room_messages (room, kind, principal_id, agent, author_name, text, files,
|
|
194
211
|
author_kind, author_label, picture, reference, created_at, note, intercom)
|
|
195
212
|
VALUES (${message.room}, ${message.kind}, ${message.principalId ?? null},
|
|
196
|
-
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::jsonb,
|
|
197
|
-
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::jsonb,
|
|
198
|
-
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::jsonb)
|
|
213
|
+
${message.agent ?? null}, ${message.author}, ${message.text}, ${files}::text::jsonb,
|
|
214
|
+
${message.authorKind ?? null}, ${message.authorLabel ?? null}, ${picture}::text::jsonb,
|
|
215
|
+
${message.reference ?? null}, ${at}, ${message.note === true}, ${intercom}::text::jsonb)
|
|
199
216
|
RETURNING *`;
|
|
200
217
|
}
|
|
201
218
|
|
|
@@ -219,6 +236,21 @@ export class RoomLog {
|
|
|
219
236
|
return row && messageOf(row);
|
|
220
237
|
}
|
|
221
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Records how a message sent during a turn went (`steered`, `cancelled`), or clears it; the
|
|
241
|
+
* message as it is now, or undefined when the room holds none.
|
|
242
|
+
*/
|
|
243
|
+
async setDelivery(
|
|
244
|
+
room: string,
|
|
245
|
+
id: number,
|
|
246
|
+
delivery: "steered" | "cancelled" | null,
|
|
247
|
+
): Promise<RoomMessage | undefined> {
|
|
248
|
+
const [row]: Row[] = await this.#sql`
|
|
249
|
+
UPDATE web_room_messages SET delivery = ${delivery}
|
|
250
|
+
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
251
|
+
return row && messageOf(row);
|
|
252
|
+
}
|
|
253
|
+
|
|
222
254
|
/** The message the host's reference names, if the room holds one. */
|
|
223
255
|
async byReference(
|
|
224
256
|
room: string,
|
package/src/app/routes.ts
CHANGED
|
@@ -71,15 +71,40 @@ import {
|
|
|
71
71
|
type WorkspaceFiles,
|
|
72
72
|
WorkspaceRefusal,
|
|
73
73
|
} from "./workspace-files.ts";
|
|
74
|
+
import { uniqueNames, zipStore } from "./zip.ts";
|
|
74
75
|
|
|
75
76
|
/** The most threads one request puts back on the room list: more than the list ever shows. */
|
|
76
77
|
const MAX_RESTORED = 100;
|
|
78
|
+
/** The most bytes a message's files may add up to and still be zipped together: 200 MB. */
|
|
79
|
+
const MAX_ZIP_BYTES = 200 * 1024 * 1024;
|
|
80
|
+
|
|
81
|
+
/** A message's zip, named after when it was sent on the host's clock: `attachments-20261031-2030.zip`. */
|
|
82
|
+
export const zipName = (at: Date, timeZone = "UTC"): string => {
|
|
83
|
+
if (Number.isNaN(at.getTime())) return "attachments.zip";
|
|
84
|
+
const part = Object.fromEntries(
|
|
85
|
+
new Intl.DateTimeFormat("en-CA", {
|
|
86
|
+
timeZone,
|
|
87
|
+
year: "numeric",
|
|
88
|
+
month: "2-digit",
|
|
89
|
+
day: "2-digit",
|
|
90
|
+
hour: "2-digit",
|
|
91
|
+
minute: "2-digit",
|
|
92
|
+
hourCycle: "h23",
|
|
93
|
+
})
|
|
94
|
+
.formatToParts(at)
|
|
95
|
+
.map(({ type, value }) => [type, value]),
|
|
96
|
+
);
|
|
97
|
+
return `attachments-${part.year}${part.month}${part.day}-${part.hour}${part.minute}.zip`;
|
|
98
|
+
};
|
|
99
|
+
|
|
77
100
|
/** The most rooms one bulk request names. */
|
|
78
101
|
const MAX_BULK_ROOMS = MAX_READ_ALL;
|
|
79
102
|
|
|
80
103
|
export interface AppApiOptions {
|
|
81
104
|
/** The host's wording: a refusal reaches a client of the API in this locale when the catalog has it. */
|
|
82
105
|
presentation?: ConsolePresentation;
|
|
106
|
+
/** The host's IANA time zone, for the date in a zip's name; absent = UTC. */
|
|
107
|
+
timeZone?: string;
|
|
83
108
|
surface: AppSurface;
|
|
84
109
|
rooms: Rooms;
|
|
85
110
|
log: Pick<RoomLog, "page" | "lastOf" | "removeRoom" | "get" | "startOver">;
|
|
@@ -489,6 +514,8 @@ export class AppApi {
|
|
|
489
514
|
throw notFound();
|
|
490
515
|
return json({ ok: true });
|
|
491
516
|
}
|
|
517
|
+
if (area === "files" && method === "GET" && id === "zip" && !action)
|
|
518
|
+
return await this.#messageZip(request, admission);
|
|
492
519
|
if (area === "files" && method === "GET" && id && !action)
|
|
493
520
|
return await this.#file(id, admission);
|
|
494
521
|
if (area === "workspace") return this.#workspace(request, parts, admission);
|
|
@@ -633,7 +660,10 @@ export class AppApi {
|
|
|
633
660
|
const cursor = await reads?.cursorOf(room.key, self);
|
|
634
661
|
return json({
|
|
635
662
|
...page,
|
|
636
|
-
messages: withExpiredFiles(
|
|
663
|
+
messages: withExpiredFiles(
|
|
664
|
+
page.messages.map((one) => this.#options.surface.delivery.shown(one)),
|
|
665
|
+
this.#options.files,
|
|
666
|
+
),
|
|
637
667
|
cards: await cards.during(room.key, {
|
|
638
668
|
...(first && (page.more || isStartOver(first))
|
|
639
669
|
? { from: first.at }
|
|
@@ -867,6 +897,16 @@ export class AppApi {
|
|
|
867
897
|
|
|
868
898
|
async #file(id: string, admission: Authenticated): Promise<Response> {
|
|
869
899
|
if (!isFileId(id)) throw notFound();
|
|
900
|
+
await this.#mayServe(id, admission);
|
|
901
|
+
const { files } = this.#options;
|
|
902
|
+
const meta = files.meta(id);
|
|
903
|
+
const blob = files.blob(id);
|
|
904
|
+
if (!meta || !blob) throw notFound();
|
|
905
|
+
return fileResponse(meta, blob);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/** Refuses, as a missing file, a stored file the visitor may not be served. */
|
|
909
|
+
async #mayServe(id: string, admission: Authenticated): Promise<void> {
|
|
870
910
|
const { files, host } = this.#options;
|
|
871
911
|
// A file bound to a host room or page is for the people it is open to, owners too; every other stored file belongs to an agent room, which only owners read.
|
|
872
912
|
const bound = files.binding(id)?.room;
|
|
@@ -877,10 +917,62 @@ export class AppApi {
|
|
|
877
917
|
!(await host.mayRead(viewerOf(admission.visitor), bound))
|
|
878
918
|
)
|
|
879
919
|
throw notFound();
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* `GET api/files/zip?room=<room>&message=<id>`: the message's files still kept, as one zip.
|
|
924
|
+
* The room and every file answer as the single file does; a refusal is a missing message.
|
|
925
|
+
*/
|
|
926
|
+
async #messageZip(
|
|
927
|
+
request: Request,
|
|
928
|
+
admission: Authenticated,
|
|
929
|
+
): Promise<Response> {
|
|
930
|
+
const params = new URL(request.url).searchParams;
|
|
931
|
+
const room = params.get("room") ?? "";
|
|
932
|
+
const id = Number(params.get("message"));
|
|
933
|
+
if (room === "" || !Number.isSafeInteger(id) || id <= 0) throw notFound();
|
|
934
|
+
const { files, host, log } = this.#options;
|
|
935
|
+
const mayReadRoom =
|
|
936
|
+
admission.visitor.tier === "owner" ||
|
|
937
|
+
(host !== undefined &&
|
|
938
|
+
(await host.mayRead(viewerOf(admission.visitor), room)));
|
|
939
|
+
if (!mayReadRoom) throw notFound();
|
|
940
|
+
const message = await log.get(room, id);
|
|
941
|
+
if (!message) throw notFound();
|
|
942
|
+
// Only the files still kept: a swept file has no binding left to check it by.
|
|
943
|
+
const kept = message.files.flatMap((file) => {
|
|
944
|
+
const meta = isFileId(file.id) ? files.meta(file.id) : undefined;
|
|
945
|
+
const blob = meta && files.blob(file.id);
|
|
946
|
+
return meta && blob ? [{ id: file.id, meta, blob }] : [];
|
|
947
|
+
});
|
|
948
|
+
for (const file of kept) await this.#mayServe(file.id, admission);
|
|
949
|
+
if (kept.length === 0)
|
|
950
|
+
throw new HttpError(
|
|
951
|
+
404,
|
|
952
|
+
"None of this message's files are kept any more.",
|
|
953
|
+
);
|
|
954
|
+
const total = kept.reduce((sum, { blob }) => sum + blob.size, 0);
|
|
955
|
+
if (total > MAX_ZIP_BYTES)
|
|
956
|
+
throw new HttpError(
|
|
957
|
+
413,
|
|
958
|
+
"These files are too large to download together; download them one by one.",
|
|
959
|
+
);
|
|
960
|
+
const names = uniqueNames(kept.map(({ meta }) => meta.name));
|
|
961
|
+
const entries = await Promise.all(
|
|
962
|
+
kept.map(async ({ blob }, i) => ({
|
|
963
|
+
name: names[i] ?? "file",
|
|
964
|
+
data: new Uint8Array(await blob.arrayBuffer()),
|
|
965
|
+
})),
|
|
966
|
+
);
|
|
967
|
+
const at = new Date(message.at);
|
|
968
|
+
return new Response(zipStore(entries, at), {
|
|
969
|
+
headers: {
|
|
970
|
+
"content-type": "application/zip",
|
|
971
|
+
"content-disposition": `attachment; filename="${zipName(at, this.#options.timeZone)}"`,
|
|
972
|
+
"cache-control": "private, no-store",
|
|
973
|
+
"x-content-type-options": "nosniff",
|
|
974
|
+
},
|
|
975
|
+
});
|
|
884
976
|
}
|
|
885
977
|
|
|
886
978
|
/** The workspace files pane's API; every call, a read too, is an owner's. */
|
package/src/app/socket.ts
CHANGED
|
@@ -26,6 +26,8 @@ import type { PluggedFrames, Viewer, WebAppRegistry } from "../web-app.ts";
|
|
|
26
26
|
import type { Answerer, OwnerDesk } from "./desk.ts";
|
|
27
27
|
import type { Presence } from "./presence.ts";
|
|
28
28
|
import { parseClientFrame } from "./protocol.ts";
|
|
29
|
+
import { coreMessageId } from "./queued-delivery.ts";
|
|
30
|
+
import { changeQueued } from "./queued-frames.ts";
|
|
29
31
|
import type { Rooms } from "./rooms.ts";
|
|
30
32
|
import type { AppSocket, Audience, Sockets } from "./sockets.ts";
|
|
31
33
|
import type { AppSurface } from "./surface.ts";
|
|
@@ -65,7 +67,7 @@ export interface SocketRouteOptions {
|
|
|
65
67
|
* not: the pieces of the app send what is per person there, such as the notices and the badge.
|
|
66
68
|
*/
|
|
67
69
|
opened?(connection: AppSocket): Promise<void>;
|
|
68
|
-
conversations: Pick<ConversationPort, "stop">;
|
|
70
|
+
conversations: Pick<ConversationPort, "stop" | "cancelQueued" | "editQueued">;
|
|
69
71
|
logger: Logger;
|
|
70
72
|
/**
|
|
71
73
|
* The longest a socket stays open, whatever its credential says. A password session lives for
|
|
@@ -443,6 +445,14 @@ export class SocketRoute {
|
|
|
443
445
|
else conversations.stop(room.key);
|
|
444
446
|
break;
|
|
445
447
|
}
|
|
448
|
+
case "edit_queued":
|
|
449
|
+
case "cancel_queued":
|
|
450
|
+
refused = await changeQueued(frame, who, {
|
|
451
|
+
rooms,
|
|
452
|
+
surface: this.#options.surface,
|
|
453
|
+
conversations,
|
|
454
|
+
});
|
|
455
|
+
break;
|
|
446
456
|
case "approval":
|
|
447
457
|
case "answer":
|
|
448
458
|
case "answers":
|
|
@@ -658,7 +668,7 @@ export class SocketRoute {
|
|
|
658
668
|
stored?.(message);
|
|
659
669
|
const inbound: InboundMessage = {
|
|
660
670
|
channel: room,
|
|
661
|
-
messageId:
|
|
671
|
+
messageId: coreMessageId(message.id),
|
|
662
672
|
actor: who.actor,
|
|
663
673
|
authorId: who.principalId,
|
|
664
674
|
authorName: who.name,
|
package/src/app/surface.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from "../api-types.ts";
|
|
23
23
|
import type { OwnerDesk } from "./desk.ts";
|
|
24
24
|
import { type FileBinding, type FileDir, typeByName } from "./files.ts";
|
|
25
|
+
import { QueuedDelivery } from "./queued-delivery.ts";
|
|
25
26
|
import type { NewRoomMessage, RoomLog } from "./room-log.ts";
|
|
26
27
|
import type { AppSocket, Audience, Sockets } from "./sockets.ts";
|
|
27
28
|
import { AppSurfaceThreads } from "./surface-threads.ts";
|
|
@@ -118,6 +119,8 @@ export class AppSurface implements ChatSurface {
|
|
|
118
119
|
/** Present on a surface with host rooms: what was said in one since the assistant last posted. */
|
|
119
120
|
channelContext?: ChatSurface["channelContext"];
|
|
120
121
|
readonly threads: AppSurfaceThreads;
|
|
122
|
+
/** How the messages sent during a turn stand: steered, queued, cancelled. */
|
|
123
|
+
readonly delivery: QueuedDelivery;
|
|
121
124
|
readonly #options: AppSurfaceOptions;
|
|
122
125
|
readonly #activity = new Map<ChannelKey, Activity>();
|
|
123
126
|
/** The round each group room runs now; an ended round is dropped, since the page keeps what it was told. */
|
|
@@ -132,6 +135,11 @@ export class AppSurface implements ChatSurface {
|
|
|
132
135
|
this.surface = options.surface;
|
|
133
136
|
this.maxAttachmentBytes = options.uploads.maxBytes;
|
|
134
137
|
this.#options = options;
|
|
138
|
+
this.delivery = new QueuedDelivery({
|
|
139
|
+
log: options.log,
|
|
140
|
+
isHostRoom: options.isHostRoom,
|
|
141
|
+
show: (stored) => this.#showChange(stored),
|
|
142
|
+
});
|
|
135
143
|
if (options.channelContext) this.channelContext = options.channelContext;
|
|
136
144
|
this.threads = new AppSurfaceThreads({
|
|
137
145
|
surface: options.surface,
|
|
@@ -168,7 +176,7 @@ export class AppSurface implements ChatSurface {
|
|
|
168
176
|
|
|
169
177
|
/** Stores a message of a room and shows it to the sockets that may have the room, except `except`. */
|
|
170
178
|
async publish(row: NewRoomMessage, except?: AppSocket): Promise<RoomMessage> {
|
|
171
|
-
const message = await this.#options.log.append(row);
|
|
179
|
+
const message = this.delivery.shown(await this.#options.log.append(row));
|
|
172
180
|
this.#options.sockets.deliver(
|
|
173
181
|
this.#options.isHostRoom(row.room)
|
|
174
182
|
? { type: "host_message", room: row.room, message }
|
|
@@ -180,17 +188,40 @@ export class AppSurface implements ChatSurface {
|
|
|
180
188
|
}
|
|
181
189
|
|
|
182
190
|
/** Changes the text of a stored message and shows the change to the sockets that may have the room. */
|
|
183
|
-
async edit(
|
|
184
|
-
|
|
185
|
-
|
|
191
|
+
async edit(
|
|
192
|
+
room: ChannelKey,
|
|
193
|
+
id: number,
|
|
194
|
+
text: string,
|
|
195
|
+
mark = false,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const stored = await this.#options.log.edit(room, id, text, mark);
|
|
198
|
+
if (stored) this.#showChange(stored);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Shows a stored message as it is now to the sockets that may have its room. */
|
|
202
|
+
#showChange(stored: RoomMessage): void {
|
|
203
|
+
const { room } = stored;
|
|
204
|
+
const message = this.delivery.shown(stored);
|
|
186
205
|
this.#options.sockets.deliver(
|
|
187
206
|
this.#options.isHostRoom(room)
|
|
188
|
-
? { type: "host_edited", room
|
|
189
|
-
: { type: "edited", room
|
|
207
|
+
? { type: "host_edited", room, message }
|
|
208
|
+
: { type: "edited", room, message },
|
|
190
209
|
this.#options.audienceOf(room),
|
|
191
210
|
);
|
|
192
211
|
}
|
|
193
212
|
|
|
213
|
+
react(channel: ChannelKey, messageId: string, emoji: string): Promise<void> {
|
|
214
|
+
return this.delivery.react(channel, messageId, emoji);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
unreact(
|
|
218
|
+
channel: ChannelKey,
|
|
219
|
+
messageId: string,
|
|
220
|
+
emoji: string,
|
|
221
|
+
): Promise<void> {
|
|
222
|
+
return this.delivery.unreact(channel, messageId, emoji);
|
|
223
|
+
}
|
|
224
|
+
|
|
194
225
|
/** Whether a turn works in the room and whether Stop applies, as `activity` frames say. */
|
|
195
226
|
activityOf(room: ChannelKey): RoomActivity {
|
|
196
227
|
const found = this.#activity.get(room);
|
package/src/app/zip.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/** One file of a zip: the name it is stored under and its bytes. */
|
|
2
|
+
export interface ZipEntry {
|
|
3
|
+
name: string;
|
|
4
|
+
data: Uint8Array;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** General purpose flag bit 11: the entry's name is UTF-8. */
|
|
8
|
+
const UTF8_NAME = 0x0800;
|
|
9
|
+
/** The version a store-only, UTF-8-named entry needs: 2.0. */
|
|
10
|
+
const VERSION = 20;
|
|
11
|
+
/** "Made by" Unix, spec 2.0: unzip then reads names as they are instead of as a DOS code page. */
|
|
12
|
+
const MADE_BY = (3 << 8) | VERSION;
|
|
13
|
+
/** A regular file readable by all, writable by its owner, in the Unix half of the attributes. */
|
|
14
|
+
const FILE_MODE = 0o100644 << 16;
|
|
15
|
+
/** The most bytes or entries a zip without the 64-bit extension can describe. */
|
|
16
|
+
const MAX_BYTES = 0xffff_ffff;
|
|
17
|
+
const MAX_ENTRIES = 0xffff;
|
|
18
|
+
|
|
19
|
+
/** The DOS date and time of `at`, as zip headers keep them, in UTC. */
|
|
20
|
+
function dosTime(at: Date): { time: number; date: number } {
|
|
21
|
+
const year = Math.max(at.getUTCFullYear(), 1980);
|
|
22
|
+
return {
|
|
23
|
+
time:
|
|
24
|
+
(at.getUTCHours() << 11) |
|
|
25
|
+
(at.getUTCMinutes() << 5) |
|
|
26
|
+
Math.floor(at.getUTCSeconds() / 2),
|
|
27
|
+
date:
|
|
28
|
+
((year - 1980) << 9) | ((at.getUTCMonth() + 1) << 5) | at.getUTCDate(),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Packs the entries into one zip, stored without compression: a local header and the bytes of
|
|
34
|
+
* each entry, then the central directory that lists them. Throws past what a zip without the
|
|
35
|
+
* 64-bit extension holds; the caller caps the total well below that.
|
|
36
|
+
*/
|
|
37
|
+
export function zipStore(
|
|
38
|
+
entries: readonly ZipEntry[],
|
|
39
|
+
at = new Date(),
|
|
40
|
+
): Uint8Array {
|
|
41
|
+
if (entries.length > MAX_ENTRIES)
|
|
42
|
+
throw new Error("too many files for one zip");
|
|
43
|
+
const encoder = new TextEncoder();
|
|
44
|
+
const { time, date } = dosTime(at);
|
|
45
|
+
const locals: Uint8Array[] = [];
|
|
46
|
+
const centrals: Uint8Array[] = [];
|
|
47
|
+
let offset = 0;
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
const name = encoder.encode(entry.name);
|
|
50
|
+
const crc = Bun.hash.crc32(entry.data);
|
|
51
|
+
const size = entry.data.byteLength;
|
|
52
|
+
if (size > MAX_BYTES || offset > MAX_BYTES)
|
|
53
|
+
throw new Error("the files are too large for one zip");
|
|
54
|
+
const local = new Uint8Array(30 + name.byteLength);
|
|
55
|
+
const l = new DataView(local.buffer);
|
|
56
|
+
l.setUint32(0, 0x04034b50, true);
|
|
57
|
+
l.setUint16(4, VERSION, true);
|
|
58
|
+
l.setUint16(6, UTF8_NAME, true);
|
|
59
|
+
l.setUint16(8, 0, true);
|
|
60
|
+
l.setUint16(10, time, true);
|
|
61
|
+
l.setUint16(12, date, true);
|
|
62
|
+
l.setUint32(14, crc, true);
|
|
63
|
+
l.setUint32(18, size, true);
|
|
64
|
+
l.setUint32(22, size, true);
|
|
65
|
+
l.setUint16(26, name.byteLength, true);
|
|
66
|
+
l.setUint16(28, 0, true);
|
|
67
|
+
local.set(name, 30);
|
|
68
|
+
const central = new Uint8Array(46 + name.byteLength);
|
|
69
|
+
const c = new DataView(central.buffer);
|
|
70
|
+
c.setUint32(0, 0x02014b50, true);
|
|
71
|
+
c.setUint16(4, MADE_BY, true);
|
|
72
|
+
c.setUint16(6, VERSION, true);
|
|
73
|
+
c.setUint16(8, UTF8_NAME, true);
|
|
74
|
+
c.setUint16(10, 0, true);
|
|
75
|
+
c.setUint16(12, time, true);
|
|
76
|
+
c.setUint16(14, date, true);
|
|
77
|
+
c.setUint32(16, crc, true);
|
|
78
|
+
c.setUint32(20, size, true);
|
|
79
|
+
c.setUint32(24, size, true);
|
|
80
|
+
c.setUint16(28, name.byteLength, true);
|
|
81
|
+
// Extra and comment lengths, disk number and internal attributes stay 0.
|
|
82
|
+
c.setUint32(38, FILE_MODE >>> 0, true);
|
|
83
|
+
c.setUint32(42, offset, true);
|
|
84
|
+
central.set(name, 46);
|
|
85
|
+
locals.push(local, entry.data);
|
|
86
|
+
centrals.push(central);
|
|
87
|
+
offset += local.byteLength + size;
|
|
88
|
+
}
|
|
89
|
+
const directorySize = centrals.reduce(
|
|
90
|
+
(sum, part) => sum + part.byteLength,
|
|
91
|
+
0,
|
|
92
|
+
);
|
|
93
|
+
if (offset + directorySize > MAX_BYTES)
|
|
94
|
+
throw new Error("the files are too large for one zip");
|
|
95
|
+
const end = new Uint8Array(22);
|
|
96
|
+
const e = new DataView(end.buffer);
|
|
97
|
+
e.setUint32(0, 0x06054b50, true);
|
|
98
|
+
e.setUint16(8, entries.length, true);
|
|
99
|
+
e.setUint16(10, entries.length, true);
|
|
100
|
+
e.setUint32(12, directorySize, true);
|
|
101
|
+
e.setUint32(16, offset, true);
|
|
102
|
+
const out = new Uint8Array(offset + directorySize + end.byteLength);
|
|
103
|
+
let cursor = 0;
|
|
104
|
+
for (const part of [...locals, ...centrals, end]) {
|
|
105
|
+
out.set(part, cursor);
|
|
106
|
+
cursor += part.byteLength;
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The names made unique the way a download folder does: `a.pdf`, `a-2.pdf`, `a-3.pdf`. */
|
|
112
|
+
export function uniqueNames(names: readonly string[]): string[] {
|
|
113
|
+
const taken = new Set<string>();
|
|
114
|
+
return names.map((name) => {
|
|
115
|
+
const dot = name.lastIndexOf(".");
|
|
116
|
+
const [stem, ext] =
|
|
117
|
+
dot > 0 ? [name.slice(0, dot), name.slice(dot)] : [name, ""];
|
|
118
|
+
let candidate = name;
|
|
119
|
+
for (let n = 2; taken.has(candidate.toLowerCase()); n++)
|
|
120
|
+
candidate = `${stem}-${n}${ext}`;
|
|
121
|
+
taken.add(candidate.toLowerCase());
|
|
122
|
+
return candidate;
|
|
123
|
+
});
|
|
124
|
+
}
|
package/src/auth/sessions.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
2
|
import type { SQL } from "bun";
|
|
3
3
|
import type { ActorFacts, Migration } from "pi-roundtable";
|
|
4
|
+
import { jsonValuesMigration } from "../host/stored-json.ts";
|
|
4
5
|
|
|
5
6
|
/** A session's idle limit slides at most this often, so a busy page costs no write per request. */
|
|
6
7
|
const SLIDE_AFTER_MS = 3_600_000;
|
|
@@ -84,6 +85,13 @@ export class WebSessions {
|
|
|
84
85
|
this.#options = options;
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/** Its jsonb columns stored as the JSON values themselves, not as the text of them. */
|
|
89
|
+
static readonly jsonValuesMigration: Migration = jsonValuesMigration(
|
|
90
|
+
"web-sessions-json-values",
|
|
91
|
+
"web_sessions",
|
|
92
|
+
["actor"],
|
|
93
|
+
);
|
|
94
|
+
|
|
87
95
|
static readonly migration: Migration = {
|
|
88
96
|
name: "web-sessions",
|
|
89
97
|
up: async (sql) => {
|
|
@@ -120,7 +128,7 @@ export class WebSessions {
|
|
|
120
128
|
const expires = this.#limit(now);
|
|
121
129
|
await this.#sql`
|
|
122
130
|
INSERT INTO web_sessions (id_hash, principal_id, actor, created_at, last_seen_at, verified_at, expires_at)
|
|
123
|
-
VALUES (${hashOf(token)}, ${principalId}, ${JSON.stringify(actor)}::jsonb, ${now}, ${now}, ${now}, ${expires})`;
|
|
131
|
+
VALUES (${hashOf(token)}, ${principalId}, ${JSON.stringify(actor)}::text::jsonb, ${now}, ${now}, ${now}, ${expires})`;
|
|
124
132
|
return {
|
|
125
133
|
token,
|
|
126
134
|
session: {
|
package/src/host/cards.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
2
|
import type { Migration } from "pi-roundtable";
|
|
3
3
|
import type { HostCard, StoredHostCard } from "../web-app.ts";
|
|
4
|
-
import { storedJson } from "./stored-json.ts";
|
|
4
|
+
import { jsonValuesMigration, storedJson } from "./stored-json.ts";
|
|
5
5
|
|
|
6
6
|
interface Row {
|
|
7
7
|
id: string;
|
|
@@ -33,6 +33,13 @@ export class HostCardLog {
|
|
|
33
33
|
this.#sql = sql;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Its jsonb columns stored as the JSON values themselves, not as the text of them. */
|
|
37
|
+
static readonly jsonValuesMigration: Migration = jsonValuesMigration(
|
|
38
|
+
"web-host-cards-json-values",
|
|
39
|
+
"web_host_cards",
|
|
40
|
+
["card"],
|
|
41
|
+
);
|
|
42
|
+
|
|
36
43
|
static readonly migration: Migration = {
|
|
37
44
|
name: "web-host-cards",
|
|
38
45
|
up: async (sql) => {
|
|
@@ -62,7 +69,7 @@ export class HostCardLog {
|
|
|
62
69
|
const id = crypto.randomUUID();
|
|
63
70
|
const [row]: Row[] = await this.#sql`
|
|
64
71
|
INSERT INTO web_host_cards (id, room, card, state, keep)
|
|
65
|
-
VALUES (${id}, ${room}, ${JSON.stringify(card)}::jsonb, ${card.state ?? null}, ${card.keep === true})
|
|
72
|
+
VALUES (${id}, ${room}, ${JSON.stringify(card)}::text::jsonb, ${card.state ?? null}, ${card.keep === true})
|
|
66
73
|
RETURNING *`;
|
|
67
74
|
if (!row) throw new Error("the card log stored nothing");
|
|
68
75
|
return cardOf(row);
|
|
@@ -76,7 +83,7 @@ export class HostCardLog {
|
|
|
76
83
|
): Promise<StoredHostCard | undefined> {
|
|
77
84
|
const [row]: Row[] = await this.#sql`
|
|
78
85
|
UPDATE web_host_cards
|
|
79
|
-
SET card = ${JSON.stringify(card)}::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
|
|
86
|
+
SET card = ${JSON.stringify(card)}::text::jsonb, state = ${card.state ?? null}, keep = ${card.keep === true}, updated_at = now()
|
|
80
87
|
WHERE id = ${id} AND room = ${room} RETURNING *`;
|
|
81
88
|
return row && cardOf(row);
|
|
82
89
|
}
|
package/src/host/stored-json.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Migration } from "pi-roundtable";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* A jsonb value as the driver returned it: already an object, or the text of one when the
|
|
3
5
|
* driver left it as text. Anything else the database holds here is a stored value gone wrong,
|
|
@@ -11,3 +13,24 @@ export function storedJson<T>(value: T | string, what: string): T {
|
|
|
11
13
|
throw new Error(`the stored ${what} is not valid JSON`);
|
|
12
14
|
}
|
|
13
15
|
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Turns the jsonb columns that hold the text of a value, as a JSON string, into the value
|
|
19
|
+
* itself. Inserts once bound `${JSON.stringify(x)}::jsonb`, which Postgres stores as a jsonb
|
|
20
|
+
* string; none of these columns holds a string on purpose.
|
|
21
|
+
*/
|
|
22
|
+
export function jsonValuesMigration(
|
|
23
|
+
name: string,
|
|
24
|
+
table: string,
|
|
25
|
+
columns: readonly string[],
|
|
26
|
+
): Migration {
|
|
27
|
+
return {
|
|
28
|
+
name,
|
|
29
|
+
up: async (sql) => {
|
|
30
|
+
for (const column of columns)
|
|
31
|
+
await sql.unsafe(
|
|
32
|
+
`UPDATE ${table} SET ${column} = (${column} #>> '{}')::jsonb WHERE jsonb_typeof(${column}) = 'string'`,
|
|
33
|
+
);
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/i18n/zh-tw.ts
CHANGED
|
@@ -601,6 +601,10 @@ export const ZH_TW: Readonly<Record<string, string>> = {
|
|
|
601
601
|
"Quote reply": "引用回覆",
|
|
602
602
|
"Select text": "選取文字",
|
|
603
603
|
"Copy all": "全部複製",
|
|
604
|
+
"Its turn has already started, so it can no longer be changed.":
|
|
605
|
+
"這一輪已經開始,無法再更改。",
|
|
606
|
+
"Added to this turn": "已插入這一輪",
|
|
607
|
+
Queued: "排隊中",
|
|
604
608
|
Share: "分享",
|
|
605
609
|
"Knight profile": "騎士資料",
|
|
606
610
|
"Copy details": "複製細節",
|
|
@@ -675,6 +679,8 @@ export const ZH_TW: Readonly<Record<string, string>> = {
|
|
|
675
679
|
"Next room with unread messages": "下一個有未讀訊息的聊天室",
|
|
676
680
|
"Close the open menu or sheet, or leave the message box":
|
|
677
681
|
"關閉開啟的選單或面板;都沒有時離開訊息輸入框",
|
|
682
|
+
"Edit your latest message that is still waiting, from an empty message box":
|
|
683
|
+
"在空的訊息輸入框編輯你最新一則還在排隊的訊息",
|
|
678
684
|
"Show these shortcuts": "顯示這份快速鍵清單",
|
|
679
685
|
"In a message box the arrow shortcuts switch rooms only when it is empty or the caret is at its very start (up) or very end (down).":
|
|
680
686
|
"在訊息輸入框裡,方向鍵快速鍵只在框內沒有文字,或游標在最開頭(上)、最結尾(下)時才會切換聊天室。",
|
|
@@ -835,6 +841,10 @@ export const ZH_TW: Readonly<Record<string, string>> = {
|
|
|
835
841
|
// 伺服器回應
|
|
836
842
|
"There is no such room, knight or file.": "找不到這個聊天室、騎士或檔案。",
|
|
837
843
|
"The body is too large.": "內容太大。",
|
|
844
|
+
"None of this message's files are kept any more.":
|
|
845
|
+
"這則訊息的檔案都已不再保留。",
|
|
846
|
+
"These files are too large to download together; download them one by one.":
|
|
847
|
+
"這些檔案太大,無法一起下載;請逐一下載。",
|
|
838
848
|
"The body is not JSON.": "內容不是 JSON。",
|
|
839
849
|
"The body is not an object.": "內容不是物件。",
|
|
840
850
|
"Only an owner may do this.": "只有擁有者可以這麼做。",
|
|
@@ -1265,6 +1275,10 @@ export const ZH_TW: Readonly<Record<string, string>> = {
|
|
|
1265
1275
|
edited: "已編輯",
|
|
1266
1276
|
"{count} file was removed": "已移除 {count} 個檔案",
|
|
1267
1277
|
"{count} files were removed": "已移除 {count} 個檔案",
|
|
1278
|
+
"Attachments ({count})": "附件({count})",
|
|
1279
|
+
"Download all": "全部下載",
|
|
1280
|
+
"{count} attachment": "{count} 個附件",
|
|
1281
|
+
"{count} attachments": "{count} 個附件",
|
|
1268
1282
|
"{count} unread message": "{count} 則未讀訊息",
|
|
1269
1283
|
"{count} unread messages": "{count} 則未讀訊息",
|
|
1270
1284
|
"New messages": "新訊息",
|
package/src/notices/mount.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { NoticeStore } from "./store.ts";
|
|
|
37
37
|
/** The tables of notices, push delivery, preferences and waivers. */
|
|
38
38
|
export const NOTICE_MIGRATIONS: readonly Migration[] = [
|
|
39
39
|
NoticeStore.migration,
|
|
40
|
+
NoticeStore.jsonValuesMigration,
|
|
40
41
|
PushOutbox.migration,
|
|
41
42
|
PushPrefsStore.migration,
|
|
42
43
|
OnboardingWaivers.migration,
|
package/src/notices/store.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SQL } from "bun";
|
|
2
2
|
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import { jsonValuesMigration } from "../host/stored-json.ts";
|
|
3
4
|
import type { OpenTarget } from "../web-app.ts";
|
|
4
5
|
|
|
5
6
|
/** Where a tap on a notice goes. */
|
|
@@ -85,6 +86,13 @@ export class NoticeStore {
|
|
|
85
86
|
this.#sql = sql;
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/** Its jsonb columns stored as the JSON values themselves, not as the text of them. */
|
|
90
|
+
static readonly jsonValuesMigration: Migration = jsonValuesMigration(
|
|
91
|
+
"web-notices-json-values",
|
|
92
|
+
"web_notices",
|
|
93
|
+
["open"],
|
|
94
|
+
);
|
|
95
|
+
|
|
88
96
|
static readonly migration: Migration = {
|
|
89
97
|
name: "web-notices",
|
|
90
98
|
up: async (sql) => {
|
|
@@ -130,7 +138,7 @@ export class NoticeStore {
|
|
|
130
138
|
await tx`
|
|
131
139
|
INSERT INTO web_notices (tag, title, body, open, urgency, ackable)
|
|
132
140
|
VALUES (${notice.tag}, ${notice.title}, ${notice.body ?? null},
|
|
133
|
-
${notice.open ? JSON.stringify(notice.open) : null}::jsonb,
|
|
141
|
+
${notice.open ? JSON.stringify(notice.open) : null}::text::jsonb,
|
|
134
142
|
${notice.urgency}, ${notice.ackable})
|
|
135
143
|
ON CONFLICT (tag) DO UPDATE SET
|
|
136
144
|
title = EXCLUDED.title, body = EXCLUDED.body, open = EXCLUDED.open,
|