@ixo/oracle-runtime-workers 0.30.0 → 0.32.0

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.
Files changed (53) hide show
  1. package/package.json +7 -5
  2. package/src/attachments/channel-links.ts +135 -0
  3. package/src/attachments/channel.ts +203 -0
  4. package/src/attachments/download.ts +23 -6
  5. package/src/attachments/index.ts +23 -0
  6. package/src/attachments/pipeline.ts +120 -42
  7. package/src/attachments/retention.ts +11 -3
  8. package/src/attachments/sanitize.ts +20 -8
  9. package/src/attachments/types.ts +7 -0
  10. package/src/attachments/view.ts +24 -7
  11. package/src/channels/contract.ts +69 -0
  12. package/src/channels/turns.ts +23 -1
  13. package/src/core/main-agent-types.ts +2 -0
  14. package/src/core/middlewares/tool-repetition-guard.ts +6 -3
  15. package/src/core/middlewares/turn-boundary.ts +6 -1
  16. package/src/core/runtime-context.ts +3 -0
  17. package/src/core/tool-not-run.ts +30 -0
  18. package/src/core/wrap-plugin-tool.ts +9 -1
  19. package/src/delivery/plan.ts +3 -1
  20. package/src/do/contracts.ts +3 -0
  21. package/src/do/turn-attachments.ts +103 -0
  22. package/src/do/user-oracle-do.ts +220 -57
  23. package/src/hub-actions/chain-tx-refusals.ts +217 -0
  24. package/src/hub-actions/client.ts +170 -26
  25. package/src/hub-actions/hub-text.ts +82 -0
  26. package/src/hub-actions/request.ts +39 -29
  27. package/src/hub-actions/store.ts +5 -1
  28. package/src/hub-actions/transfer.ts +99 -32
  29. package/src/index.ts +1 -0
  30. package/src/plugin-api/index.ts +1 -0
  31. package/src/plugin-api/types.ts +102 -20
  32. package/src/plugins/attachments/view-attachment-tool.ts +7 -3
  33. package/src/plugins/hub-actions/chain-tx-catalog.ts +1239 -0
  34. package/src/plugins/hub-actions/hub-actions.plugin.ts +490 -232
  35. package/src/plugins/ixo-chain/blocksync.ts +6 -2
  36. package/src/plugins/ixo-chain/http.ts +16 -8
  37. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +1 -1
  38. package/src/plugins/ixo-chain/kinds/chain/index.ts +2 -0
  39. package/src/plugins/ixo-chain/kinds/chain/validators.ts +151 -0
  40. package/src/plugins/ixo-chain/rest.ts +7 -1
  41. package/src/plugins/yoma/catalog.ts +780 -0
  42. package/src/plugins/yoma/clef.ts +98 -0
  43. package/src/plugins/yoma/drafts.ts +339 -0
  44. package/src/plugins/yoma/engine.ts +978 -0
  45. package/src/plugins/yoma/index.ts +13 -0
  46. package/src/plugins/yoma/judge.ts +358 -0
  47. package/src/plugins/yoma/permission.ts +62 -0
  48. package/src/plugins/yoma/reads.ts +195 -0
  49. package/src/plugins/yoma/submission.ts +531 -0
  50. package/src/plugins/yoma/text.ts +16 -0
  51. package/src/plugins/yoma/work.ts +190 -0
  52. package/src/plugins/yoma/yoma.plugin.ts +2171 -0
  53. package/src/sqlite/serialization.ts +2 -0
@@ -0,0 +1,978 @@
1
+ import type * as SurveyCore from 'survey-core';
2
+ import type {
3
+ ItemValue,
4
+ Model,
5
+ Question,
6
+ QuestionPanelDynamicModel,
7
+ QuestionRatingModel,
8
+ QuestionSelectBase,
9
+ } from 'survey-core';
10
+ import { MAX_SECTION_ITEMS } from './drafts';
11
+ import type { ChoiceQuestion } from './judge';
12
+ import { clip } from './text';
13
+
14
+ /**
15
+ * A job's SurveyJS form (an application `#bco` or a claim `#vct`) asked one
16
+ * question at a time in chat, with the form's own rules: `survey-core` 2.3.11
17
+ * (the Portal's version; Jambo renders with 1.9.101) decides which questions
18
+ * are visible, which are required and whether an answer is valid.
19
+ *
20
+ * Answers are kept in SurveyJS's data shape, keyed by question name, exactly
21
+ * what the web form would produce. Repeating sections (`paneldynamic`) are
22
+ * stored as the array of item objects SurveyJS keeps under the section's name;
23
+ * one question inside item 3 is addressed as `shops[2].photo`.
24
+ */
25
+
26
+ /** Jambo's map question (`lib/here/MapGridSelector.tsx`): one cell of a geohash grid. */
27
+ export const MAP_GRID_TYPE = 'map-grid-selector';
28
+
29
+ /** Jambo's default cell size when a form names none: geohash length 7 (about 150 m). */
30
+ const DEFAULT_GRID_PRECISION = 7;
31
+
32
+ /** Choices shown per question; a longer list is cut (Yoma forms have at most a few dozen). */
33
+ const MAX_OPTIONS = 60;
34
+
35
+ /**
36
+ * Jambo's map question as a survey-core question type, registered with the
37
+ * loaded module's `Serializer` and `ElementFactory` so forms that use it
38
+ * open. Defined here rather than at module scope: a class needs its base
39
+ * class when it is defined.
40
+ */
41
+ function defineMapGridQuestion(core: typeof SurveyCore) {
42
+ class QuestionMapGridSelectorModel extends core.Question {
43
+ override getType() {
44
+ return MAP_GRID_TYPE;
45
+ }
46
+ override isEmpty(): boolean {
47
+ const value: unknown = this.value;
48
+ if (value === null || value === undefined) return true;
49
+ return Array.isArray(value) ? value.length === 0 : false;
50
+ }
51
+ get gridPrecision(): number {
52
+ return (
53
+ Number(this.getPropertyValue('gridPrecision')) || DEFAULT_GRID_PRECISION
54
+ );
55
+ }
56
+ set gridPrecision(value: number) {
57
+ this.setPropertyValue('gridPrecision', value);
58
+ }
59
+ get allowMultiple(): boolean {
60
+ return this.getPropertyValue('allowMultiple') === true;
61
+ }
62
+ set allowMultiple(value: boolean) {
63
+ this.setPropertyValue('allowMultiple', value);
64
+ }
65
+ }
66
+
67
+ if (!core.Serializer.findClass(MAP_GRID_TYPE)) {
68
+ core.Serializer.addClass(
69
+ MAP_GRID_TYPE,
70
+ [
71
+ { name: 'gridPrecision:number', default: DEFAULT_GRID_PRECISION },
72
+ { name: 'allowMultiple:boolean', default: false },
73
+ ],
74
+ () => new QuestionMapGridSelectorModel(''),
75
+ 'question',
76
+ );
77
+ core.ElementFactory.Instance.registerElement(
78
+ MAP_GRID_TYPE,
79
+ (name) => new QuestionMapGridSelectorModel(name),
80
+ );
81
+ }
82
+ return QuestionMapGridSelectorModel;
83
+ }
84
+
85
+ /** survey-core once loaded, with the map question registered on it. */
86
+ export interface LoadedSurveyCore {
87
+ core: typeof SurveyCore;
88
+ MapGridQuestion: ReturnType<typeof defineMapGridQuestion>;
89
+ }
90
+
91
+ let loaded: LoadedSurveyCore | undefined;
92
+ let loading: Promise<void> | undefined;
93
+
94
+ /**
95
+ * Loads `survey-core` on first use rather than when the Worker starts:
96
+ * evaluating it (2.5 MB, every question class registered as its module
97
+ * runs) at startup pushed an oracle with this plugin to the Workers
98
+ * startup-time limit. Every function of this module that opens or reads a
99
+ * form model needs it, so callers await this first; it loads once per
100
+ * isolate, and a failed load is tried again on the next call.
101
+ */
102
+ export function loadSurveyCore(): Promise<void> {
103
+ loading ??= import('survey-core').then(
104
+ (core) => {
105
+ loaded = { core, MapGridQuestion: defineMapGridQuestion(core) };
106
+ },
107
+ (error: unknown) => {
108
+ loading = undefined;
109
+ throw error;
110
+ },
111
+ );
112
+ return loading;
113
+ }
114
+
115
+ /** The loaded survey-core; throws when {@link loadSurveyCore} has not finished. */
116
+ export function surveyCore(): LoadedSurveyCore {
117
+ if (!loaded)
118
+ throw new Error(
119
+ 'survey-core is not loaded: await loadSurveyCore() before using the form engine',
120
+ );
121
+ return loaded;
122
+ }
123
+
124
+ /** How a question is answered in chat. */
125
+ export type ChatInput =
126
+ | 'pick-one'
127
+ | 'pick-many'
128
+ | 'yes-no'
129
+ | 'text'
130
+ | 'number'
131
+ | 'date'
132
+ /** `inputType: datetime-local`: a date and a time of day. */
133
+ | 'datetime'
134
+ | 'email'
135
+ | 'phone'
136
+ | 'url'
137
+ | 'rating'
138
+ | 'location'
139
+ | 'file'
140
+ /** Signatures, drawings, matrices: finished on the web, not in chat. */
141
+ | 'web-only';
142
+
143
+ export interface FormQuestion {
144
+ /** Where the answer lives: `name`, or `section[index].name` inside a repeating section. */
145
+ path: string;
146
+ /** The SurveyJS question name. */
147
+ name: string;
148
+ type: string;
149
+ input: ChatInput;
150
+ title: string;
151
+ description?: string;
152
+ required: boolean;
153
+ /**
154
+ * Choice questions: the options as shown, in form order, then the special
155
+ * items the form switches on ("None", "Refuse to answer", "Don't know"),
156
+ * each with the value the form stores for it. `exclusive`: on a checkbox,
157
+ * picked only on its own.
158
+ */
159
+ options?: ChoiceOption[];
160
+ /** How many regular options the form has, when more than {@link MAX_OPTIONS} (the rest are not offered in chat). */
161
+ optionsCut?: number;
162
+ /** The form's "Other" item: its text, and its value. */
163
+ other?: { text: string; value: unknown };
164
+ /** Rating questions: the values the form takes, lowest first. */
165
+ rateValues?: number[];
166
+ rateMin?: number;
167
+ rateMax?: number;
168
+ /** File questions: `image/*`, `.pdf`, ... as the form accepts them. */
169
+ acceptedTypes?: string;
170
+ allowMultiple?: boolean;
171
+ /** Inside a repeating section: which item, 1-based, and the section's title. */
172
+ item?: { section: string; title: string; number: number };
173
+ }
174
+
175
+ export interface ChoiceOption {
176
+ value: unknown;
177
+ text: string;
178
+ /** A checkbox's "None", "Refuse to answer" or "Don't know": only on its own. */
179
+ exclusive?: true;
180
+ }
181
+
182
+ /** A repeating section that is ready for another item, or for the person to say they are done. */
183
+ export interface SectionPrompt {
184
+ section: string;
185
+ title: string;
186
+ items: number;
187
+ min: number;
188
+ max: number;
189
+ }
190
+
191
+ export type FormStep =
192
+ | { kind: 'question'; question: FormQuestion }
193
+ | { kind: 'more'; section: SectionPrompt }
194
+ | { kind: 'done' };
195
+
196
+ /** What the draft keeps besides answers: what the person skipped and the sections they closed. */
197
+ export interface FormProgressState {
198
+ data: Record<string, unknown>;
199
+ /** Paths of optional questions the person chose to skip. */
200
+ skipped: string[];
201
+ /** Repeating sections the person said were finished. */
202
+ closedSections: string[];
203
+ }
204
+
205
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
206
+ typeof value === 'object' && value !== null && !Array.isArray(value);
207
+
208
+ /** The SurveyJS JSON inside an ixo survey template (`{question: {...}}`), or the bare survey. */
209
+ export function surveyJson(template: unknown): Record<string, unknown> {
210
+ const inner =
211
+ isRecord(template) && isRecord(template.question)
212
+ ? template.question
213
+ : template;
214
+ if (!isRecord(inner) || !Array.isArray(inner.pages))
215
+ throw new Error('The form has no pages');
216
+ return inner;
217
+ }
218
+
219
+ export function formTitle(template: unknown): string {
220
+ const title = surveyJson(template).title;
221
+ return typeof title === 'string' ? clip(title, 200) : '';
222
+ }
223
+
224
+ /**
225
+ * The form as a live model. `data`: a saved draft's answers, which replace
226
+ * everything (a draft saves the form's default values when it is started,
227
+ * so they are part of it). Without it the model holds the form's defaults,
228
+ * as the web form starts.
229
+ */
230
+ export function openModel(
231
+ template: unknown,
232
+ data?: Record<string, unknown>,
233
+ ): Model {
234
+ const { core } = surveyCore();
235
+ const model = new core.Model(surveyJson(template));
236
+ if (data) model.data = structuredClone(data);
237
+ return model;
238
+ }
239
+
240
+ /** The model's answers in SurveyJS's data shape. */
241
+ export function dataOf(model: Model): Record<string, unknown> {
242
+ const data: unknown = model.data;
243
+ return isRecord(data) ? data : {};
244
+ }
245
+
246
+ const INPUT_BY_TEXT_TYPE: Record<string, ChatInput> = {
247
+ number: 'number',
248
+ range: 'number',
249
+ date: 'date',
250
+ 'datetime-local': 'datetime',
251
+ email: 'email',
252
+ tel: 'phone',
253
+ url: 'url',
254
+ };
255
+
256
+ /** A rating whose scale is all numbers is a number; one with words is a choice. */
257
+ function numericRating(question: QuestionRatingModel): number[] | undefined {
258
+ const values = question.visibleRateValues.map((item) => item.value);
259
+ if (!values.length) return undefined;
260
+ const numbers = values.map((value) =>
261
+ typeof value === 'number' ? value : Number(value),
262
+ );
263
+ return numbers.every(Number.isFinite) &&
264
+ values.every(
265
+ (value) => typeof value === 'number' || /^-?\d+$/.test(String(value)),
266
+ )
267
+ ? [...numbers].sort((a, b) => a - b)
268
+ : undefined;
269
+ }
270
+
271
+ function inputFor(question: Question): ChatInput | undefined {
272
+ const { core, MapGridQuestion } = surveyCore();
273
+ if (question instanceof core.QuestionTextModel)
274
+ return INPUT_BY_TEXT_TYPE[question.inputType] ?? 'text';
275
+ if (question instanceof MapGridQuestion) return 'location';
276
+ if (question instanceof core.QuestionRatingModel)
277
+ return numericRating(question) ? 'rating' : 'pick-one';
278
+ switch (question.getType()) {
279
+ case 'comment':
280
+ return 'text';
281
+ case 'radiogroup':
282
+ case 'dropdown':
283
+ return 'pick-one';
284
+ case 'checkbox':
285
+ case 'tagbox':
286
+ return 'pick-many';
287
+ case 'boolean':
288
+ return 'yes-no';
289
+ case 'file':
290
+ return 'file';
291
+ // Not asked: shown text, computed values and images carry no answer.
292
+ case 'html':
293
+ case 'expression':
294
+ case 'image':
295
+ return undefined;
296
+ default:
297
+ return 'web-only';
298
+ }
299
+ }
300
+
301
+ /**
302
+ * The special items a choice list carries, by identity (a regular choice
303
+ * may have the value `none` or `other` too): "None", "Refuse to answer" and
304
+ * "Don't know" are offered as options; "Other" through `other`; "Select
305
+ * all" is not offered.
306
+ */
307
+ function specialItems(question: QuestionSelectBase): {
308
+ extras: ItemValue[];
309
+ other: ItemValue;
310
+ skipped: Set<ItemValue>;
311
+ } {
312
+ const extras = [
313
+ question.noneItem,
314
+ question.refuseItem,
315
+ question.dontKnowItem,
316
+ ];
317
+ const skipped = new Set<ItemValue>([...extras, question.otherItem]);
318
+ if (question instanceof surveyCore().core.QuestionCheckboxModel)
319
+ skipped.add(question.selectAllItem);
320
+ return { extras, other: question.otherItem, skipped };
321
+ }
322
+
323
+ const optionOf = (item: ItemValue): ChoiceOption => ({
324
+ value: item.value,
325
+ text: clip(item.text || String(item.value), 200),
326
+ });
327
+
328
+ function describe(
329
+ question: Question,
330
+ path: string,
331
+ item?: FormQuestion['item'],
332
+ ): FormQuestion | undefined {
333
+ const input = inputFor(question);
334
+ if (!input) return undefined;
335
+ const { core, MapGridQuestion } = surveyCore();
336
+ const view: FormQuestion = {
337
+ path,
338
+ name: question.name,
339
+ type: question.getType(),
340
+ input,
341
+ // A form is written by its operator: clipped, and shown as data.
342
+ title: clip(
343
+ question.processedTitle || question.title || question.name,
344
+ 200,
345
+ ),
346
+ ...(question.description
347
+ ? { description: clip(question.description, 500) }
348
+ : {}),
349
+ required: question.isRequired,
350
+ ...(item ? { item } : {}),
351
+ };
352
+ if (question instanceof core.QuestionSelectBase) {
353
+ const { extras, other, skipped } = specialItems(question);
354
+ const shown = new Set<ItemValue>(question.visibleChoices);
355
+ const regular = question.visibleChoices.filter((c) => !skipped.has(c));
356
+ view.options = [
357
+ ...regular.slice(0, MAX_OPTIONS).map(optionOf),
358
+ ...extras
359
+ .filter((c) => shown.has(c))
360
+ .map((c) => ({
361
+ ...optionOf(c),
362
+ ...(view.input === 'pick-many' ? { exclusive: true as const } : {}),
363
+ })),
364
+ ];
365
+ if (regular.length > MAX_OPTIONS) view.optionsCut = regular.length;
366
+ if (shown.has(other))
367
+ view.other = {
368
+ text: clip(other.text || 'Other', 200),
369
+ value: other.value,
370
+ };
371
+ }
372
+ if (question instanceof core.QuestionBooleanModel)
373
+ view.options = [
374
+ {
375
+ value: question.getValueTrue(),
376
+ text: clip(question.labelTrue || 'Yes', 200),
377
+ },
378
+ {
379
+ value: question.getValueFalse(),
380
+ text: clip(question.labelFalse || 'No', 200),
381
+ },
382
+ ];
383
+ if (question instanceof core.QuestionRatingModel) {
384
+ const values = numericRating(question);
385
+ if (values) {
386
+ view.rateValues = values;
387
+ view.rateMin = values[0];
388
+ view.rateMax = values[values.length - 1];
389
+ } else view.options = question.visibleRateValues.map(optionOf);
390
+ }
391
+ if (question instanceof core.QuestionFileModel) {
392
+ if (question.acceptedTypes) view.acceptedTypes = question.acceptedTypes;
393
+ view.allowMultiple = question.allowMultiple;
394
+ }
395
+ if (question instanceof MapGridQuestion)
396
+ view.allowMultiple = question.allowMultiple;
397
+ return view;
398
+ }
399
+
400
+ const sectionTitle = (section: QuestionPanelDynamicModel) =>
401
+ clip(section.processedTitle || section.title || section.name, 200);
402
+
403
+ /**
404
+ * Whether a question is on the form as the person sees it: itself, its
405
+ * panel and page, and for a question inside a repeating section the section
406
+ * and that item (`isVisibleInSurvey`).
407
+ */
408
+ const shown = (question: Question) => question.isVisibleInSurvey;
409
+
410
+ /** One step of the form in order: a question, or the end of a repeating section's items. */
411
+ type Walked =
412
+ | { kind: 'question'; view: FormQuestion }
413
+ | { kind: 'section-end'; section: QuestionPanelDynamicModel };
414
+
415
+ /**
416
+ * The form in order: every visible question that takes an answer, the
417
+ * items of a repeating section inline (each followed by the section's end,
418
+ * where "add another?" is asked), never a question the form hides.
419
+ */
420
+ function* walk(model: Model): Generator<Walked> {
421
+ const { core } = surveyCore();
422
+ for (const question of model.getAllQuestions(false)) {
423
+ if (!shown(question)) continue;
424
+ if (question instanceof core.QuestionPanelDynamicModel) {
425
+ const title = sectionTitle(question);
426
+ for (const [index, panel] of question.panels.entries()) {
427
+ if (!panel.isVisible) continue;
428
+ for (const inner of panel.questions) {
429
+ if (!shown(inner)) continue;
430
+ const view = describe(
431
+ inner,
432
+ `${question.name}[${index}].${inner.name}`,
433
+ {
434
+ section: question.name,
435
+ title,
436
+ number: index + 1,
437
+ },
438
+ );
439
+ if (view) yield { kind: 'question', view };
440
+ }
441
+ }
442
+ yield { kind: 'section-end', section: question };
443
+ continue;
444
+ }
445
+ // A question inside a repeating section's template has no place of its own.
446
+ if (question.parentQuestion) continue;
447
+ const view = describe(question, question.name);
448
+ if (view) yield { kind: 'question', view };
449
+ }
450
+ }
451
+
452
+ /** Every visible question that takes an answer, in form order, items of repeating sections inline. */
453
+ export function visibleQuestions(model: Model): FormQuestion[] {
454
+ const out: FormQuestion[] = [];
455
+ for (const step of walk(model))
456
+ if (step.kind === 'question') out.push(step.view);
457
+ return out;
458
+ }
459
+
460
+ /** The visible repeating sections, in form order. */
461
+ function visibleSections(model: Model): QuestionPanelDynamicModel[] {
462
+ const out: QuestionPanelDynamicModel[] = [];
463
+ for (const step of walk(model))
464
+ if (step.kind === 'section-end') out.push(step.section);
465
+ return out;
466
+ }
467
+
468
+ function parsePath(path: string): {
469
+ section?: string;
470
+ index?: number;
471
+ name: string;
472
+ } {
473
+ const match = /^(.+)\[(\d+)\]\.(.+)$/.exec(path);
474
+ if (!match) return { name: path };
475
+ return { section: match[1], index: Number(match[2]), name: match[3]! };
476
+ }
477
+
478
+ function dynamicSection(
479
+ model: Model,
480
+ name: string,
481
+ ): QuestionPanelDynamicModel | undefined {
482
+ const question = model.getQuestionByName(name);
483
+ return question instanceof surveyCore().core.QuestionPanelDynamicModel
484
+ ? question
485
+ : undefined;
486
+ }
487
+
488
+ /** The live survey-core question at a path, or undefined when the form has none. */
489
+ export function questionAt(model: Model, path: string): Question | undefined {
490
+ const { section, index, name } = parsePath(path);
491
+ if (section === undefined) return model.getQuestionByName(name) ?? undefined;
492
+ const panel = dynamicSection(model, section)?.panels[index!];
493
+ return panel?.getQuestionByName(name) ?? undefined;
494
+ }
495
+
496
+ export function viewAt(model: Model, path: string): FormQuestion | undefined {
497
+ return visibleQuestions(model).find((question) => question.path === path);
498
+ }
499
+
500
+ /** The most items a repeating section takes: the form's own maximum, never above {@link MAX_SECTION_ITEMS}. */
501
+ function maxItems(section: QuestionPanelDynamicModel): number {
502
+ return Math.min(
503
+ section.maxPanelCount || MAX_SECTION_ITEMS,
504
+ MAX_SECTION_ITEMS,
505
+ );
506
+ }
507
+
508
+ /** The fewest items a section needs: its `minPanelCount`, or one when it is required. */
509
+ function minItems(section: QuestionPanelDynamicModel): number {
510
+ return Math.max(section.minPanelCount || 0, section.isRequired ? 1 : 0);
511
+ }
512
+
513
+ /** "Another item?" for a section that can take one and was not closed; below its minimum it is asked even when closed. */
514
+ function sectionPrompt(
515
+ section: QuestionPanelDynamicModel,
516
+ closed: string[],
517
+ ): SectionPrompt | undefined {
518
+ const max = maxItems(section);
519
+ const min = minItems(section);
520
+ if (section.panelCount >= max) return undefined;
521
+ if (closed.includes(section.name) && section.panelCount >= min)
522
+ return undefined;
523
+ return {
524
+ section: section.name,
525
+ title: sectionTitle(section),
526
+ items: section.panelCount,
527
+ min,
528
+ max,
529
+ };
530
+ }
531
+
532
+ /** Whether a question still waits for an answer: empty, and not skipped (a skipped question that has become required is asked again). */
533
+ function waiting(
534
+ model: Model,
535
+ view: FormQuestion,
536
+ skipped: readonly string[],
537
+ ): boolean {
538
+ if (skipped.includes(view.path) && !view.required) return false;
539
+ return questionAt(model, view.path)?.isEmpty() ?? false;
540
+ }
541
+
542
+ /**
543
+ * The next thing to ask, in form order: the first visible question with no
544
+ * answer that was not skipped; at the end of a repeating section's items,
545
+ * whether there is another item (until the section is closed or full),
546
+ * before any question after the section; then done.
547
+ */
548
+ export function nextStep(
549
+ model: Model,
550
+ state: Omit<FormProgressState, 'data'>,
551
+ ): FormStep {
552
+ for (const step of walk(model)) {
553
+ if (step.kind === 'question') {
554
+ if (waiting(model, step.view, state.skipped))
555
+ return { kind: 'question', question: step.view };
556
+ continue;
557
+ }
558
+ const prompt = sectionPrompt(step.section, state.closedSections);
559
+ if (prompt) return { kind: 'more', section: prompt };
560
+ }
561
+ return { kind: 'done' };
562
+ }
563
+
564
+ export function progressOf(
565
+ model: Model,
566
+ skipped: string[],
567
+ ): { answered: number; total: number } {
568
+ const views = visibleQuestions(model).filter(
569
+ (view) => view.input !== 'web-only',
570
+ );
571
+ return {
572
+ answered: views.filter((view) => !waiting(model, view, skipped)).length,
573
+ total: views.length,
574
+ };
575
+ }
576
+
577
+ /** Longest form-author validation message passed on. */
578
+ const MAX_ERROR_CHARS = 200;
579
+
580
+ /** The errors survey-core gives for one question's value, in the form's own words (clipped: the form's author wrote them). */
581
+ function errorsFor(question: Question): string[] {
582
+ question.validate(true);
583
+ // Headless, survey-core may leave an error's text empty (no locale strings
584
+ // rendered yet): getText() fills it, and a plain fallback covers the rest.
585
+ return question.errors.map((error) =>
586
+ clip(
587
+ error.getText() ||
588
+ error.text ||
589
+ (question.isEmpty()
590
+ ? 'This question needs an answer.'
591
+ : 'This answer is not valid.'),
592
+ MAX_ERROR_CHARS,
593
+ ),
594
+ );
595
+ }
596
+
597
+ export type SetResult =
598
+ | { ok: true; data: Record<string, unknown> }
599
+ | { ok: false; errors: string[] };
600
+
601
+ /**
602
+ * Sets one answer on the open model and checks it the way the web form
603
+ * would. `otherText`: the person's own answer for the form's "Other" item
604
+ * (the value holds that item's value), stored where the form stores it (a
605
+ * `-Comment` value, or the value itself when `storeOthersAsComment` is
606
+ * off). Accepted: the model now holds it, and the data comes back with
607
+ * every value SurveyJS keeps (computed expressions included). Refused: the
608
+ * model is put back as it was.
609
+ */
610
+ export function setAnswer(
611
+ model: Model,
612
+ path: string,
613
+ value: unknown,
614
+ otherText?: string,
615
+ ): SetResult {
616
+ const question = questionAt(model, path);
617
+ if (!question || !shown(question))
618
+ return {
619
+ ok: false,
620
+ errors: [`There is no question "${path}" to answer now.`],
621
+ };
622
+ const before = structuredClone(dataOf(model));
623
+ question.value = value;
624
+ if (
625
+ otherText !== undefined &&
626
+ question instanceof surveyCore().core.QuestionSelectBase
627
+ )
628
+ question.otherValue = otherText;
629
+ const errors = errorsFor(question);
630
+ if (errors.length) {
631
+ model.data = before;
632
+ return { ok: false, errors };
633
+ }
634
+ return { ok: true, data: dataOf(model) };
635
+ }
636
+
637
+ export type SectionResult =
638
+ | { ok: true; data: Record<string, unknown> }
639
+ | { ok: false; detail: string };
640
+
641
+ /** Adds an empty item to a repeating section, within its maximum. */
642
+ export function addSectionItem(model: Model, section: string): SectionResult {
643
+ const dynamic = dynamicSection(model, section);
644
+ if (!dynamic || !shown(dynamic))
645
+ return { ok: false, detail: `There is no repeating section "${section}".` };
646
+ if (dynamic.panelCount >= maxItems(dynamic))
647
+ return {
648
+ ok: false,
649
+ detail: `This section takes at most ${maxItems(dynamic)} items.`,
650
+ };
651
+ dynamic.addPanel();
652
+ return { ok: true, data: dataOf(model) };
653
+ }
654
+
655
+ /** Why a section cannot be closed yet, or undefined when it can. */
656
+ export function sectionBelowMinimum(
657
+ model: Model,
658
+ section: string,
659
+ ): string | undefined {
660
+ const dynamic = dynamicSection(model, section);
661
+ if (!dynamic || !shown(dynamic))
662
+ return `There is no repeating section "${section}".`;
663
+ const min = minItems(dynamic);
664
+ return dynamic.panelCount < min
665
+ ? `This section needs at least ${min} item${min === 1 ? '' : 's'}.`
666
+ : undefined;
667
+ }
668
+
669
+ /**
670
+ * Whether the whole form would submit on the web: every visible required
671
+ * question answered (a skipped one included: skipping never hides that it
672
+ * is required), every value valid, and every repeating section with at
673
+ * least its minimum items. Web-only questions count as missing here: such a
674
+ * form cannot be sent from chat.
675
+ */
676
+ export function formErrors(
677
+ model: Model,
678
+ ): Array<{ path: string; errors: string[] }> {
679
+ const out: Array<{ path: string; errors: string[] }> = [];
680
+ for (const section of visibleSections(model)) {
681
+ const below = sectionBelowMinimum(model, section.name);
682
+ if (below) out.push({ path: section.name, errors: [below] });
683
+ }
684
+ for (const view of visibleQuestions(model)) {
685
+ const question = questionAt(model, view.path);
686
+ if (!question) continue;
687
+ const errors = errorsFor(question);
688
+ if (errors.length) out.push({ path: view.path, errors });
689
+ }
690
+ return out;
691
+ }
692
+
693
+ /** Another visible question keeps its answer under the same value name (SurveyJS keeps it then too). */
694
+ function valueKeptByAnother(model: Model, question: Question): boolean {
695
+ if (question.parentQuestion) return false;
696
+ return model
697
+ .getQuestionsByValueName(question.getValueName())
698
+ .some(
699
+ (other) => other !== question && !other.parentQuestion && shown(other),
700
+ );
701
+ }
702
+
703
+ /**
704
+ * Clears the answers of every question the form hides now (itself, its
705
+ * panel or page, a repeating section or one of its items: `isVisibleInSurvey`,
706
+ * nested questions included), whatever the form's `clearInvisibleValues`
707
+ * says: what is read back, shown on the card and sent is the same, and
708
+ * nothing the person cannot see goes out. The saved draft keeps them, so
709
+ * they come back if the person changes the answer that hid them.
710
+ */
711
+ export function clearHiddenAnswers(model: Model): void {
712
+ for (const question of model.getAllQuestions(false, false, true))
713
+ if (
714
+ !question.isEmpty() &&
715
+ !shown(question) &&
716
+ !valueKeptByAnother(model, question)
717
+ )
718
+ question.clearValue();
719
+ }
720
+
721
+ /**
722
+ * The answers the Hub files, from a model whose hidden answers were
723
+ * cleared: what the form keeps, without file questions (sent separately).
724
+ * Changes the model: read anything else from it first.
725
+ */
726
+ export function submittableData(model: Model): Record<string, unknown> {
727
+ for (const view of visibleQuestions(model))
728
+ if (view.input === 'file') questionAt(model, view.path)?.clearValue();
729
+ return dataOf(model);
730
+ }
731
+
732
+ /** A choice question as the judge reads it (`judge.ts`): every option offered, special items last. */
733
+ export function asChoiceQuestion(view: FormQuestion): ChoiceQuestion {
734
+ const options = view.options ?? [];
735
+ const exclusive = options.flatMap((option, index) =>
736
+ option.exclusive ? [index] : [],
737
+ );
738
+ return {
739
+ title: view.title,
740
+ ...(view.description ? { description: view.description } : {}),
741
+ multiple: view.input === 'pick-many',
742
+ options: options.map((option) => option.text),
743
+ ...(exclusive.length ? { exclusive } : {}),
744
+ otherAllowed: view.other !== undefined,
745
+ };
746
+ }
747
+
748
+ /* ── typed answers ────────────────────────────────────────────────────── */
749
+
750
+ export type Typed = { ok: true; value: unknown } | { ok: false; say: string };
751
+
752
+ const pad = (n: number) => String(n).padStart(2, '0');
753
+
754
+ /** A real calendar date `YYYY-MM-DD` (`-`, `/` or `.` between the parts), or undefined. */
755
+ function calendarDate(text: string): string | undefined {
756
+ const match = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(text);
757
+ if (!match) return undefined;
758
+ const [year, month, day] = [
759
+ Number(match[1]),
760
+ Number(match[2]),
761
+ Number(match[3]),
762
+ ];
763
+ const date = new Date(Date.UTC(year, month - 1, day));
764
+ return date.getUTCFullYear() === year &&
765
+ date.getUTCMonth() === month - 1 &&
766
+ date.getUTCDate() === day
767
+ ? `${match[1]}-${pad(month)}-${pad(day)}`
768
+ : undefined;
769
+ }
770
+
771
+ /**
772
+ * A number as people write it: `12`, `-3.5`, `1 500`, `1,5` (a decimal
773
+ * comma). `1,500` is refused: it is 1.5 in some places and 1500 in others.
774
+ */
775
+ export function parseNumber(raw: string | number): number | undefined {
776
+ if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
777
+ const text = raw.trim().replace(/\s/g, '');
778
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
779
+ if (/^-?\d+,\d+$/.test(text) && !/^-?\d{1,3},\d{3}$/.test(text))
780
+ return Number(text.replace(',', '.'));
781
+ return undefined;
782
+ }
783
+
784
+ /**
785
+ * A typed answer (number, rating, date, date and time, email, link, phone)
786
+ * checked and put in the form's own format before the form's rules see it:
787
+ * survey-core takes `abc` for a date and drops it for a number without an
788
+ * error. Anything else is the reply as written, trimmed.
789
+ */
790
+ export function typedAnswer(
791
+ view: FormQuestion,
792
+ reply: string,
793
+ value: string | number | undefined,
794
+ ): Typed {
795
+ const raw = value ?? reply;
796
+ const text = typeof raw === 'string' ? raw.trim() : String(raw);
797
+ switch (view.input) {
798
+ case 'number': {
799
+ const n = parseNumber(raw);
800
+ return n === undefined
801
+ ? {
802
+ ok: false,
803
+ say: 'This needs a number, like 12 or 2.5 (no thousands commas).',
804
+ }
805
+ : { ok: true, value: n };
806
+ }
807
+ case 'rating': {
808
+ const n = parseNumber(raw);
809
+ const allowed = view.rateValues ?? [];
810
+ return n !== undefined && allowed.includes(n)
811
+ ? { ok: true, value: n }
812
+ : {
813
+ ok: false,
814
+ say: `This needs one of these numbers: ${allowed.join(', ')}.`,
815
+ };
816
+ }
817
+ case 'date': {
818
+ const date = calendarDate(text);
819
+ return date
820
+ ? { ok: true, value: date }
821
+ : { ok: false, say: 'This needs a date, as YYYY-MM-DD (2026-10-30).' };
822
+ }
823
+ case 'datetime': {
824
+ const match = /^(.+?)[T\s](\d{1,2}):(\d{2})(?::\d{2})?$/.exec(text);
825
+ const date = match ? calendarDate(match[1]!) : undefined;
826
+ const hours = Number(match?.[2]);
827
+ const minutes = Number(match?.[3]);
828
+ return date && hours < 24 && minutes < 60
829
+ ? { ok: true, value: `${date}T${pad(hours)}:${pad(minutes)}` }
830
+ : {
831
+ ok: false,
832
+ say: 'This needs a date and a time, as YYYY-MM-DD HH:mm (2026-10-30 14:30).',
833
+ };
834
+ }
835
+ case 'email':
836
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text)
837
+ ? { ok: true, value: text }
838
+ : { ok: false, say: 'This needs an email address.' };
839
+ case 'url': {
840
+ try {
841
+ const url = new URL(text);
842
+ if (url.protocol === 'https:' || url.protocol === 'http:')
843
+ return { ok: true, value: text };
844
+ } catch {
845
+ // falls through
846
+ }
847
+ return { ok: false, say: 'This needs a link starting with https://.' };
848
+ }
849
+ case 'phone': {
850
+ const digits = text.replace(/\D/g, '');
851
+ return /^\+?[\d\s().-]+$/.test(text) &&
852
+ digits.length >= 7 &&
853
+ digits.length <= 15
854
+ ? { ok: true, value: text }
855
+ : { ok: false, say: 'This needs a phone number.' };
856
+ }
857
+ default:
858
+ return { ok: true, value: text };
859
+ }
860
+ }
861
+
862
+ const GEOHASH_BASE32 = '0123456789bcdefghjkmnpqrstuvwxyz';
863
+
864
+ /** Standard geohash, as Jambo's `lib/here/grid.service.ts` `encodeGeohash`. */
865
+ export function encodeGeohash(
866
+ lat: number,
867
+ lon: number,
868
+ precision: number,
869
+ ): string {
870
+ let idx = 0;
871
+ let bit = 0;
872
+ let evenBit = true;
873
+ let geohash = '';
874
+ let latMin = -90;
875
+ let latMax = 90;
876
+ let lonMin = -180;
877
+ let lonMax = 180;
878
+ while (geohash.length < precision) {
879
+ if (evenBit) {
880
+ const mid = (lonMin + lonMax) / 2;
881
+ if (lon >= mid) {
882
+ idx = idx * 2 + 1;
883
+ lonMin = mid;
884
+ } else {
885
+ idx *= 2;
886
+ lonMax = mid;
887
+ }
888
+ } else {
889
+ const mid = (latMin + latMax) / 2;
890
+ if (lat >= mid) {
891
+ idx = idx * 2 + 1;
892
+ latMin = mid;
893
+ } else {
894
+ idx *= 2;
895
+ latMax = mid;
896
+ }
897
+ }
898
+ evenBit = !evenBit;
899
+ if (++bit === 5) {
900
+ geohash += GEOHASH_BASE32.charAt(idx);
901
+ bit = 0;
902
+ idx = 0;
903
+ }
904
+ }
905
+ return geohash;
906
+ }
907
+
908
+ /** A shared WhatsApp pin, as the gateway writes it: `📍 <lat>, <long>` then ` — name` and/or `, address`. */
909
+ export function parsePin(
910
+ text: string,
911
+ ): { latitude: number; longitude: number; placeName?: string } | undefined {
912
+ const match =
913
+ /📍\s*(-?\d{1,2}(?:\.\d+)?)\s*,\s*(-?\d{1,3}(?:\.\d+)?)(?:\s*—\s*(.+))?/u.exec(
914
+ text,
915
+ );
916
+ if (!match) return undefined;
917
+ const latitude = Number(match[1]);
918
+ const longitude = Number(match[2]);
919
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude))
920
+ return undefined;
921
+ if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) return undefined;
922
+ const placeName = match[3]?.trim();
923
+ return {
924
+ latitude,
925
+ longitude,
926
+ ...(placeName ? { placeName: clip(placeName, 200) } : {}),
927
+ };
928
+ }
929
+
930
+ /** Geohash lengths a map question may use; anything else is clamped into range. */
931
+ const MIN_GRID_PRECISION = 1;
932
+ const MAX_GRID_PRECISION = 12;
933
+
934
+ /** The value Jambo's map question stores for one picked point. */
935
+ export function gridCell(
936
+ model: Model,
937
+ path: string,
938
+ pin: { latitude: number; longitude: number; placeName?: string },
939
+ ): Record<string, unknown> {
940
+ const question = questionAt(model, path);
941
+ const asked =
942
+ question instanceof surveyCore().MapGridQuestion
943
+ ? question.gridPrecision
944
+ : DEFAULT_GRID_PRECISION;
945
+ const precision = Math.min(
946
+ MAX_GRID_PRECISION,
947
+ Math.max(MIN_GRID_PRECISION, Math.round(asked)),
948
+ );
949
+ return {
950
+ geoId: encodeGeohash(pin.latitude, pin.longitude, precision),
951
+ latitude: pin.latitude,
952
+ longitude: pin.longitude,
953
+ gridPrecision: precision,
954
+ ...(pin.placeName ? { placeName: pin.placeName } : {}),
955
+ };
956
+ }
957
+
958
+ /** Whether a file the person sent fits the question's `acceptedTypes` (`image/*`, `.pdf`, `application/pdf`). */
959
+ export function fileAccepted(
960
+ acceptedTypes: string | undefined,
961
+ mimetype: string,
962
+ filename: string,
963
+ ): boolean {
964
+ if (!acceptedTypes?.trim()) return true;
965
+ const type = mimetype.toLowerCase();
966
+ const name = filename.toLowerCase();
967
+ return acceptedTypes
968
+ .split(',')
969
+ .map((entry) => entry.trim().toLowerCase())
970
+ .filter(Boolean)
971
+ .some((entry) =>
972
+ entry.startsWith('.')
973
+ ? name.endsWith(entry)
974
+ : entry.endsWith('/*')
975
+ ? type.startsWith(entry.slice(0, -1))
976
+ : type === entry,
977
+ );
978
+ }