@colixsystems/widget-sdk 0.218.0 → 0.219.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.
@@ -0,0 +1,449 @@
1
+ // Signing a file with BankID, and signing rounds. One source for both entries:
2
+ // index.js and index.native.js re-export the same three hooks. What a server
3
+ // answer does to the state lives in ./signing-state.js, which needs no React.
4
+ //
5
+ // Every hook here answers for one SCOPE: the signed-in user plus the file (or
6
+ // the set of files). State is stored with the scope it was read for and shown
7
+ // only while that is still the scope, so one file's or one user's answer is
8
+ // never rendered under another, not even for a single render.
9
+ import { useCallback, useEffect, useRef, useState } from "react";
10
+ import { PermissionError, toPermissionError, useWidgetContextOrThrow } from "./hooks.js";
11
+ import {
12
+ NO_SIGN_ORDER,
13
+ chunkFileIds,
14
+ currentRoundOrNull,
15
+ indexOverview,
16
+ readRefusalDetail,
17
+ reportedRoundId,
18
+ roundChangedUnderneath,
19
+ signOrderStateAfterAnswer,
20
+ signOrderStateAfterCancel,
21
+ signOrderStateAfterInitiate,
22
+ signedOrderState,
23
+ uniqueFileIds,
24
+ } from "./signing-state.js";
25
+
26
+ const userIdOf = (ctx) => (ctx.user && ctx.user.id) || "";
27
+
28
+ /**
29
+ * Drive a BankID signing flow for one Filestore file. Returns
30
+ * `{ status, qr, autoStartToken, message, signerName, signedAt, hintCode,
31
+ * nameRefusal, verdict, loading, error, initiate, refresh, cancel, verify }`.
32
+ *
33
+ * `initiate()` opens a sign order against the file (the backend hashes its
34
+ * bytes SERVER-SIDE and binds the digest into the BankID signature); `refresh()`
35
+ * polls the order (surfacing a fresh animated-QR frame while pending — render
36
+ * it with the `Image` primitive); `verify()` re-runs the offline verification
37
+ * and returns the verdict (`{ valid, content_status, … }`). `cancel()` aborts
38
+ * the order and resolves to what became of it, which is not always
39
+ * "cancelled": a signature already being added to the document stays pending
40
+ * with `hintCode: "sealing"`. `hintCode` is where the order stands;
41
+ * `nameRefusal` (`{ reason, characters }`) is set when it failed because the
42
+ * signer's name cannot be shown. `autoStartToken` is set only while opening
43
+ * BankID can still help. Reads
44
+ * `ctx.filestore.signatures.{initiate,status,cancel,verify}`. When `fileId` is
45
+ * null/empty the hook collapses to a no-op.
46
+ *
47
+ * Pass `existingSignatureId` for a file that is ALREADY signed: the hook seeds
48
+ * that order id (status `complete`) so the widget can show the signed state and
49
+ * `verify()` it WITHOUT opening a new order — re-signing then requires an
50
+ * explicit `initiate()`.
51
+ */
52
+ export function useFileSignature(fileId, existingSignatureId = null) {
53
+ const ctx = useWidgetContextOrThrow("useFileSignature");
54
+ if (!ctx.filestore || !ctx.filestore.signatures) {
55
+ throw new Error(
56
+ "useFileSignature: host did not inject a filestore client (ctx.filestore.signatures)",
57
+ );
58
+ }
59
+ const ready = Boolean(fileId);
60
+ const scope = `${userIdOf(ctx)}|${fileId || ""}`;
61
+ const sigRef = useRef(ctx.filestore.signatures);
62
+ sigRef.current = ctx.filestore.signatures;
63
+ const fileIdRef = useRef(fileId);
64
+ fileIdRef.current = fileId;
65
+ const scopeRef = useRef(scope);
66
+ scopeRef.current = scope;
67
+ // Seed the order from an already-completed signature so verify() works
68
+ // without initiating; initiate() (an explicit re-sign) overwrites it.
69
+ const orderRef = useRef({ scope, id: existingSignatureId || null });
70
+ // Bumped whenever an older poll's answer must no longer be applied.
71
+ const answersRef = useRef(0);
72
+
73
+ const seeded = () => ({
74
+ scope,
75
+ order: existingSignatureId ? signedOrderState() : NO_SIGN_ORDER,
76
+ verdict: null,
77
+ error: null,
78
+ });
79
+ const [held, setHeld] = useState(seeded);
80
+ const [loadingScope, setLoadingScope] = useState(null);
81
+ // Another file or another user: the order that was open is not theirs.
82
+ const current = held.scope === scope ? held : seeded();
83
+ const currentRef = useRef(current);
84
+ currentRef.current = current;
85
+ if (orderRef.current.scope !== scope) {
86
+ orderRef.current = { scope, id: existingSignatureId || null };
87
+ answersRef.current += 1;
88
+ }
89
+
90
+ /** Apply `change` to the held state, unless the hook has moved to another scope. */
91
+ const apply = useCallback((forScope, change) => {
92
+ if (scopeRef.current !== forScope) return;
93
+ setHeld((previous) => {
94
+ const base = previous.scope === forScope ? previous : currentRef.current;
95
+ return { ...base, ...change(base) };
96
+ });
97
+ }, []);
98
+
99
+ const initiate = useCallback(async () => {
100
+ const forScope = scopeRef.current;
101
+ if (!fileIdRef.current) return null;
102
+ answersRef.current += 1;
103
+ setLoadingScope(forScope);
104
+ apply(forScope, () => ({ error: null, verdict: null }));
105
+ try {
106
+ const res = await sigRef.current.initiate(fileIdRef.current);
107
+ if (scopeRef.current === forScope) {
108
+ orderRef.current = { scope: forScope, id: res && res.signature_id ? res.signature_id : null };
109
+ answersRef.current += 1;
110
+ }
111
+ apply(forScope, () => ({ order: signOrderStateAfterInitiate(res) }));
112
+ return res;
113
+ } catch (err) {
114
+ const refusal = toPermissionError(err);
115
+ apply(forScope, () => ({ error: refusal }));
116
+ throw refusal;
117
+ } finally {
118
+ setLoadingScope((loadingFor) => (loadingFor === forScope ? null : loadingFor));
119
+ }
120
+ }, [apply]);
121
+
122
+ const refresh = useCallback(async () => {
123
+ const forScope = scopeRef.current;
124
+ const { id } = orderRef.current;
125
+ if (!id) return;
126
+ const asked = answersRef.current;
127
+ try {
128
+ const res = await sigRef.current.status(id);
129
+ // A cancel or a new order since this poll was sent: its answer is old news.
130
+ if (answersRef.current !== asked) return;
131
+ apply(forScope, (base) => ({ order: signOrderStateAfterAnswer(base.order, res) }));
132
+ } catch (err) {
133
+ if (answersRef.current !== asked) return;
134
+ apply(forScope, () => ({ error: toPermissionError(err) }));
135
+ }
136
+ }, [apply]);
137
+
138
+ const cancel = useCallback(async () => {
139
+ const forScope = scopeRef.current;
140
+ const { id } = orderRef.current;
141
+ if (!id) return undefined;
142
+ answersRef.current += 1;
143
+ try {
144
+ const res = await sigRef.current.cancel(id);
145
+ answersRef.current += 1;
146
+ apply(forScope, (base) => ({ order: signOrderStateAfterCancel(base.order, res) }));
147
+ return res;
148
+ } catch (err) {
149
+ throw toPermissionError(err);
150
+ }
151
+ }, [apply]);
152
+
153
+ const verify = useCallback(async () => {
154
+ const forScope = scopeRef.current;
155
+ const { id } = orderRef.current;
156
+ if (!id) return null;
157
+ try {
158
+ const v = await sigRef.current.verify(id);
159
+ apply(forScope, () => ({ verdict: v }));
160
+ return v;
161
+ } catch (err) {
162
+ const refusal = toPermissionError(err);
163
+ apply(forScope, () => ({ error: refusal }));
164
+ throw refusal;
165
+ }
166
+ }, [apply]);
167
+
168
+ if (!ready) {
169
+ return {
170
+ ...NO_SIGN_ORDER,
171
+ verdict: null,
172
+ loading: false,
173
+ error: null,
174
+ initiate: async () => null,
175
+ refresh: async () => undefined,
176
+ cancel: async () => undefined,
177
+ verify: async () => null,
178
+ };
179
+ }
180
+ return {
181
+ ...current.order,
182
+ verdict: current.verdict,
183
+ loading: loadingScope === scope,
184
+ error: current.error,
185
+ initiate,
186
+ refresh,
187
+ cancel,
188
+ verify,
189
+ };
190
+ }
191
+
192
+ /** A refusal with the server's code and message, the refused signers and the wait. */
193
+ function toSigningRoundError(err) {
194
+ const error = toPermissionError(err);
195
+ if (error.errors === undefined) Object.assign(error, readRefusalDetail(err));
196
+ return error;
197
+ }
198
+
199
+ /** A refusal the hook makes itself; `message` is written to be shown to a person. */
200
+ const localRefusal = (code, message) =>
201
+ Object.assign(new PermissionError(code, message, { retryable: false }), { errors: [], retryAfter: null });
202
+
203
+ const noRound = () => localRefusal("NO_SIGNING_ROUND", "This document has no signing round.");
204
+ const previewRequired = () =>
205
+ localRefusal("PREVIEW_REQUIRED", "Open the prepared document before sending it for signing.");
206
+ const actionInProgress = () =>
207
+ localRefusal("ACTION_IN_PROGRESS", "Another change to this signing round is still in progress. Try again in a moment.");
208
+ const noFile = () => localRefusal("VALIDATION", "Choose a document first.");
209
+
210
+ function signingRoundsClientOrThrow(ctx, hookName) {
211
+ const rounds = ctx.filestore && ctx.filestore.signingRounds;
212
+ if (!rounds || typeof rounds.overview !== "function") {
213
+ throw new Error(
214
+ `${hookName}: host did not inject a filestore client (ctx.filestore.signingRounds)`,
215
+ );
216
+ }
217
+ return rounds;
218
+ }
219
+
220
+ const NO_OVERVIEW = Object.freeze({ byFileId: Object.freeze(Object.create(null)), roundsEnabled: null });
221
+
222
+ /**
223
+ * What the signed-in user may know about the signing round of each file in
224
+ * `fileIds`, in one batched read. Returns `{ byFileId, roundsEnabled,
225
+ * loading, error, refresh }`. `byFileId[id]` is `{ file_id, can_manage,
226
+ * can_prepare, round, locked }`; a file the user cannot see has no entry.
227
+ * `roundsEnabled` is false when the workspace cannot start signing rounds,
228
+ * and null until the first answer. Refetches only when the set of ids, or the
229
+ * signed-in user, changes.
230
+ */
231
+ export function useSigningRounds(fileIds) {
232
+ const ctx = useWidgetContextOrThrow("useSigningRounds");
233
+ const roundsRef = useRef(null);
234
+ roundsRef.current = signingRoundsClientOrThrow(ctx, "useSigningRounds");
235
+ const key = uniqueFileIds(fileIds).join(",");
236
+ const scope = `${userIdOf(ctx)}|${key}`;
237
+ const askRef = useRef(null);
238
+ askRef.current = { scope, key, asked: fileIds };
239
+ const runRef = useRef(0);
240
+
241
+ const [held, setHeld] = useState({ scope, overview: NO_OVERVIEW, error: null, settled: key === "" });
242
+ const [refreshing, setRefreshing] = useState(false);
243
+
244
+ const refresh = useCallback(async () => {
245
+ const myRun = ++runRef.current;
246
+ const { scope: forScope, key: forKey, asked } = askRef.current;
247
+ const ids = forKey ? forKey.split(",") : [];
248
+ if (ids.length === 0) {
249
+ setHeld({ scope: forScope, overview: NO_OVERVIEW, error: null, settled: true });
250
+ setRefreshing(false);
251
+ return;
252
+ }
253
+ setRefreshing(true);
254
+ try {
255
+ const pages = await Promise.all(chunkFileIds(ids).map((chunk) => roundsRef.current.overview(chunk)));
256
+ if (runRef.current !== myRun) return;
257
+ setHeld({ scope: forScope, overview: indexOverview(pages, asked), error: null, settled: true });
258
+ } catch (err) {
259
+ if (runRef.current !== myRun) return;
260
+ const error = toSigningRoundError(err);
261
+ // What was read before stays, but only if it was read for these files.
262
+ setHeld((previous) => ({
263
+ scope: forScope,
264
+ overview: previous.scope === forScope ? previous.overview : NO_OVERVIEW,
265
+ error,
266
+ settled: true,
267
+ }));
268
+ }
269
+ setRefreshing(false);
270
+ }, []);
271
+
272
+ useEffect(() => {
273
+ refresh();
274
+ }, [scope, refresh]);
275
+
276
+ const current = held.scope === scope ? held : { overview: NO_OVERVIEW, error: null, settled: key === "" };
277
+ return {
278
+ byFileId: current.overview.byFileId,
279
+ roundsEnabled: current.overview.roundsEnabled,
280
+ loading: key !== "" && (!current.settled || refreshing),
281
+ error: current.error,
282
+ refresh,
283
+ };
284
+ }
285
+
286
+ /**
287
+ * One file's current signing round and the actions of whoever manages the
288
+ * file's folder. Returns `{ round, loading, error, refresh, prepare,
289
+ * preparedFile, confirm, cancel, busy }`.
290
+ *
291
+ * `prepare(signerIds)` drafts a round for those people, in the order their
292
+ * places appear on the signature page; `preparedFile()` answers `{ url,
293
+ * sha256, expires_at, size_bytes }` for the draft's document; `confirm()`
294
+ * opens the round for the document that `preparedFile()` last returned, so it
295
+ * needs no argument; `cancel()` ends a draft or an open round and resolves to
296
+ * the cancelled round. One action runs at a time: while `busy` is true another
297
+ * one is refused with code `ACTION_IN_PROGRESS`, never queued. A refused
298
+ * action sets `error` and rejects with it: `code` and `message` come from the
299
+ * server, `errors` has one entry per refused signer, `retryAfter` is the
300
+ * seconds to wait. When the refusal means the round is no longer what was
301
+ * shown, the round is read again.
302
+ */
303
+ export function useSigningRound(fileId) {
304
+ const ctx = useWidgetContextOrThrow("useSigningRound");
305
+ const roundsRef = useRef(null);
306
+ roundsRef.current = signingRoundsClientOrThrow(ctx, "useSigningRound");
307
+ const scope = `${userIdOf(ctx)}|${fileId || ""}`;
308
+ const askRef = useRef(null);
309
+ askRef.current = { scope, fileId };
310
+ const runRef = useRef(0);
311
+ const busyRef = useRef(false);
312
+ const previewedRef = useRef(null);
313
+
314
+ const [held, setHeld] = useState({ scope, round: null, error: null, settled: !fileId });
315
+ const [reading, setReading] = useState(false);
316
+ const [busy, setBusy] = useState(false);
317
+
318
+ const current = held.scope === scope ? held : { round: null, error: null, settled: !fileId };
319
+ const roundRef = useRef(null);
320
+ roundRef.current = current.round;
321
+
322
+ /** Read the round again. `keep` is a refusal to leave on `error` while doing so. */
323
+ const load = useCallback(async (keep = null) => {
324
+ const myRun = ++runRef.current;
325
+ const { scope: forScope, fileId: id } = askRef.current;
326
+ if (!id) {
327
+ setHeld({ scope: forScope, round: null, error: keep, settled: true });
328
+ setReading(false);
329
+ return;
330
+ }
331
+ setReading(true);
332
+ try {
333
+ const roundId = reportedRoundId(await roundsRef.current.overview([id]), id);
334
+ const round = currentRoundOrNull(roundId ? await roundsRef.current.get(roundId) : null);
335
+ if (runRef.current !== myRun) return;
336
+ setHeld({ scope: forScope, round, error: keep, settled: true });
337
+ } catch (err) {
338
+ if (runRef.current !== myRun) return;
339
+ const error = keep || toSigningRoundError(err);
340
+ setHeld((previous) => ({
341
+ scope: forScope,
342
+ round: previous.scope === forScope ? previous.round : null,
343
+ error,
344
+ settled: true,
345
+ }));
346
+ }
347
+ setReading(false);
348
+ }, []);
349
+
350
+ const refresh = useCallback(() => load(), [load]);
351
+
352
+ useEffect(() => {
353
+ previewedRef.current = null;
354
+ load();
355
+ }, [scope, load]);
356
+
357
+ /**
358
+ * The one way an action runs. It refuses a second action, and it applies
359
+ * what the action answered only while the hook is still on the file and
360
+ * user it started for. `work` resolves to `{ value, round }`; a `round`
361
+ * (null included) replaces the shown round and outdates every read in flight.
362
+ */
363
+ const run = useCallback(
364
+ async (work) => {
365
+ if (busyRef.current) throw actionInProgress();
366
+ const { scope: forScope, fileId: forFile } = askRef.current;
367
+ const stillHere = () => askRef.current.scope === forScope;
368
+ busyRef.current = true;
369
+ setBusy(true);
370
+ setHeld((previous) => (previous.scope === forScope && previous.error ? { ...previous, error: null } : previous));
371
+ try {
372
+ const outcome = await work(roundsRef.current, roundRef.current, forFile);
373
+ if (outcome.round !== undefined && stillHere()) {
374
+ runRef.current += 1;
375
+ setReading(false);
376
+ setHeld({ scope: forScope, round: currentRoundOrNull(outcome.round), error: null, settled: true });
377
+ }
378
+ return outcome.value;
379
+ } catch (err) {
380
+ const refusal = toSigningRoundError(err);
381
+ if (stillHere()) {
382
+ if (roundChangedUnderneath(refusal.code)) load(refusal);
383
+ else setHeld((previous) => (previous.scope === forScope ? { ...previous, error: refusal } : previous));
384
+ }
385
+ throw refusal;
386
+ } finally {
387
+ busyRef.current = false;
388
+ setBusy(false);
389
+ }
390
+ },
391
+ [load],
392
+ );
393
+
394
+ const prepare = useCallback(
395
+ (signerIds) =>
396
+ run(async (rounds, _round, forFile) => {
397
+ if (!forFile) throw noFile();
398
+ const draft = await rounds.prepare(forFile, signerIds);
399
+ return { value: draft, round: draft };
400
+ }),
401
+ [run],
402
+ );
403
+
404
+ const preparedFile = useCallback(
405
+ () =>
406
+ run(async (rounds, round) => {
407
+ if (!round) throw noRound();
408
+ const link = await rounds.preparedFile(round.id);
409
+ previewedRef.current = { roundId: round.id, sha256: link.sha256 };
410
+ return { value: link };
411
+ }),
412
+ [run],
413
+ );
414
+
415
+ const confirm = useCallback(
416
+ () =>
417
+ run(async (rounds, round) => {
418
+ if (!round) throw noRound();
419
+ const previewed = previewedRef.current;
420
+ // Confirming names the exact document the manager was shown.
421
+ if (!previewed || previewed.roundId !== round.id) throw previewRequired();
422
+ const opened = await rounds.confirm(round.id, previewed.sha256);
423
+ return { value: opened, round: opened };
424
+ }),
425
+ [run],
426
+ );
427
+
428
+ const cancel = useCallback(
429
+ () =>
430
+ run(async (rounds, round) => {
431
+ if (!round) throw noRound();
432
+ const cancelled = await rounds.cancel(round.id);
433
+ return { value: cancelled, round: null };
434
+ }),
435
+ [run],
436
+ );
437
+
438
+ return {
439
+ round: current.round,
440
+ loading: Boolean(fileId) && (!current.settled || reading),
441
+ error: current.error,
442
+ refresh,
443
+ prepare,
444
+ preparedFile,
445
+ confirm,
446
+ cancel,
447
+ busy,
448
+ };
449
+ }
@@ -0,0 +1,140 @@
1
+ // The signing hooks' logic that needs no React: what a server answer does to
2
+ // a sign order's state, what a refusal says, and how an overview is indexed.
3
+ // Kept apart from hooks.js so it runs under plain `node --test`.
4
+
5
+ /** The server reads at most this many file ids per overview call. */
6
+ export const OVERVIEW_IDS_PER_CALL = 200;
7
+
8
+ /** The user has signed; the signature is being added to the document. */
9
+ export const SEALING_HINT = "sealing";
10
+
11
+ export const NO_SIGN_ORDER = Object.freeze({
12
+ status: null,
13
+ qr: null,
14
+ autoStartToken: null,
15
+ message: null,
16
+ signerName: null,
17
+ signedAt: null,
18
+ hintCode: null,
19
+ nameRefusal: null,
20
+ });
21
+
22
+ /** The state of an order that completed before the widget mounted. */
23
+ export const signedOrderState = () => ({ ...NO_SIGN_ORDER, status: "complete" });
24
+
25
+ /** A fresh order. A resumed one arrives with the hint and message of where it stands. */
26
+ export function signOrderStateAfterInitiate(answer) {
27
+ const res = answer || {};
28
+ return {
29
+ ...NO_SIGN_ORDER,
30
+ status: res.status || "pending",
31
+ qr: res.qr || null,
32
+ autoStartToken: res.auto_start_token || null,
33
+ message: res.message || null,
34
+ hintCode: res.hint_code || null,
35
+ };
36
+ }
37
+
38
+ /**
39
+ * One poll's answer. The hint and the name refusal describe the order as it
40
+ * is now, so an answer without them clears them; the other fields change only
41
+ * when the answer carries them. A QR code is kept only while there is
42
+ * something to scan.
43
+ */
44
+ export function signOrderStateAfterAnswer(previous, answer) {
45
+ if (!answer) return previous;
46
+ const status = answer.status != null ? answer.status : previous.status;
47
+ const hintCode = answer.hint_code || null;
48
+ return {
49
+ ...previous,
50
+ status,
51
+ // Opening BankID only makes sense while the user has yet to sign.
52
+ autoStartToken: status === "pending" && hintCode !== SEALING_HINT ? previous.autoStartToken : null,
53
+ qr: answer.qr !== undefined ? answer.qr || null : status === "pending" ? previous.qr : null,
54
+ message: answer.message !== undefined ? answer.message || null : previous.message,
55
+ signerName: answer.signer_name !== undefined ? answer.signer_name || null : previous.signerName,
56
+ signedAt: answer.signed_at !== undefined ? answer.signed_at || null : previous.signedAt,
57
+ hintCode,
58
+ nameRefusal: answer.name_refusal || null,
59
+ };
60
+ }
61
+
62
+ /**
63
+ * The answer to a cancel is what became of the order: cancelled, or already
64
+ * ended, or still pending because the signature is being added to the document
65
+ * and can no longer be undone. A host that answers nothing cancelled it.
66
+ */
67
+ export function signOrderStateAfterCancel(previous, answer) {
68
+ const next = signOrderStateAfterAnswer(previous, answer && answer.status ? answer : { status: "cancelled" });
69
+ return next.status === "cancelled" ? { ...next, qr: null, message: null } : next;
70
+ }
71
+
72
+ /**
73
+ * The parts of a refusal a widget shows beside its message, as the filestore
74
+ * client put them on its error: one entry per refused signer (`app_user_id`,
75
+ * `message`, and for a name `reason` and `characters`), and how many seconds
76
+ * to wait before trying again. An error from anywhere else has neither.
77
+ */
78
+ export function readRefusalDetail(err) {
79
+ const errors = err && Array.isArray(err.errors) ? err.errors : [];
80
+ const wait = err ? err.retryAfter : null;
81
+ return { errors, retryAfter: typeof wait === "number" && Number.isFinite(wait) && wait >= 0 ? wait : null };
82
+ }
83
+
84
+ // The round is no longer what the widget is showing: read it again.
85
+ const ROUND_CHANGED_CODES = new Set([
86
+ "SIGNING_ROUND_EXPIRED",
87
+ "SIGNING_ROUND_CLOSED",
88
+ "SIGNING_ROUND_STALE",
89
+ "SIGNING_ROUND_NOT_DRAFT",
90
+ "PREPARED_FILE_MISMATCH",
91
+ ]);
92
+
93
+ export const roundChangedUnderneath = (code) => ROUND_CHANGED_CODES.has(code);
94
+
95
+ /** A cancelled round is not a file's current round. */
96
+ export const currentRoundOrNull = (round) => (round && round.status !== "CANCELLED" ? round : null);
97
+
98
+ const normalId = (id) => id.toLowerCase();
99
+
100
+ /** Distinct ids as the server spells them (lower case), sorted, so the same set always makes the same key. */
101
+ export function uniqueFileIds(fileIds) {
102
+ if (!Array.isArray(fileIds)) return [];
103
+ return [...new Set(fileIds.filter((id) => typeof id === "string" && id).map(normalId))].sort();
104
+ }
105
+
106
+ export function chunkFileIds(ids, size = OVERVIEW_IDS_PER_CALL) {
107
+ const chunks = [];
108
+ for (let i = 0; i < ids.length; i += size) chunks.push(ids.slice(i, i + size));
109
+ return chunks;
110
+ }
111
+
112
+ /**
113
+ * Overview pages as `{ byFileId, roundsEnabled }`. A file the caller cannot
114
+ * see has no entry. `roundsEnabled` is false as soon as one page says so.
115
+ * `byFileId` has no prototype, so no file id can collide with an inherited
116
+ * name; an id the caller spelled in another case finds the same entry.
117
+ */
118
+ export function indexOverview(pages, askedIds = []) {
119
+ const byFileId = Object.create(null);
120
+ let roundsEnabled = pages.length > 0;
121
+ for (const page of pages) {
122
+ for (const entry of (page && Array.isArray(page.data) ? page.data : [])) {
123
+ if (entry && typeof entry.file_id === "string" && entry.file_id) byFileId[entry.file_id] = entry;
124
+ }
125
+ if (!page || !page.meta || page.meta.rounds_enabled !== true) roundsEnabled = false;
126
+ }
127
+ for (const asked of Array.isArray(askedIds) ? askedIds : []) {
128
+ if (typeof asked !== "string" || asked in byFileId) continue;
129
+ const entry = byFileId[normalId(asked)];
130
+ if (entry) byFileId[asked] = entry;
131
+ }
132
+ return { byFileId, roundsEnabled };
133
+ }
134
+
135
+ /** The id of the round the overview reports for `fileId`, or null. */
136
+ export function reportedRoundId(overview, fileId) {
137
+ if (typeof fileId !== "string") return null;
138
+ const entry = indexOverview([overview], [fileId]).byFileId[fileId];
139
+ return entry && entry.round && entry.round.id ? entry.round.id : null;
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.218.0",
3
+ "version": "0.219.1",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",