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