@cxpinsight/survey-spec 0.1.1 → 0.3.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/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +26 -1
- package/dist/pageFlow.d.ts +43 -0
- package/dist/pageFlow.d.ts.map +1 -0
- package/dist/pageFlow.js +127 -0
- package/dist/validate.d.ts +54 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +243 -0
- package/dist/voice.d.ts +118 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +632 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -12,5 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { evaluateExpression, evaluateCondition, isParseable } from './expressionEval';
|
|
14
14
|
export { resolveVariable } from './resolveVariable';
|
|
15
|
+
export { splitIntoPages } from './pageFlow';
|
|
16
|
+
export { validateAnswer } from './validate';
|
|
17
|
+
export type { ValidatableQuestion } from './validate';
|
|
18
|
+
export type { Page, PageItem, PageFlowOptions, FlowQuestion, FlowSection } from './pageFlow';
|
|
19
|
+
export { normalizeTranscript, matchBoolean, matchOption, matchOptions, rankOptions, matchNumberInRange, matchDate, distinctiveLabels, voiceSupport, voiceCanHandle, auditVoiceQuestions, voiceDisclaimer, skipNoteText, scaffoldFor, buildPromptText, shortCode, optionLabelOf, optionValueOf, voiceOptionsOf, optionLabelsOf, } from './voice';
|
|
20
|
+
export type { VoiceSupport, VoiceQuestion, VoiceOption, VoiceScaffold } from './voice';
|
|
15
21
|
export type { AnswersMap, VariablesMap, EvalContext } from './types';
|
|
16
22
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACtF,OAAO,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACpD,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,YAAY,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AACtD,YAAY,EAAE,IAAI,EAAE,QAAQ,EAAE,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AAC7F,OAAO,EACL,mBAAmB,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EACzE,kBAAkB,EAAE,SAAS,EAAE,iBAAiB,EAChD,YAAY,EAAE,cAAc,EAAE,mBAAmB,EACjD,eAAe,EAAE,YAAY,EAAE,WAAW,EAAE,eAAe,EAAE,SAAS,EACtE,aAAa,EAAE,aAAa,EAAE,cAAc,EAAE,cAAc,GAC7D,MAAM,SAAS,CAAC;AACjB,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACvF,YAAY,EAAE,UAAU,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
|
|
3
|
+
exports.optionLabelsOf = exports.voiceOptionsOf = exports.optionValueOf = exports.optionLabelOf = exports.shortCode = exports.buildPromptText = exports.scaffoldFor = exports.skipNoteText = exports.voiceDisclaimer = exports.auditVoiceQuestions = exports.voiceCanHandle = exports.voiceSupport = exports.distinctiveLabels = exports.matchDate = exports.matchNumberInRange = exports.rankOptions = exports.matchOptions = exports.matchOption = exports.matchBoolean = exports.normalizeTranscript = exports.validateAnswer = exports.splitIntoPages = exports.resolveVariable = exports.isParseable = exports.evaluateCondition = exports.evaluateExpression = void 0;
|
|
4
4
|
/**
|
|
5
5
|
* @cxpinsight/survey-spec
|
|
6
6
|
*
|
|
@@ -19,3 +19,28 @@ Object.defineProperty(exports, "evaluateCondition", { enumerable: true, get: fun
|
|
|
19
19
|
Object.defineProperty(exports, "isParseable", { enumerable: true, get: function () { return expressionEval_1.isParseable; } });
|
|
20
20
|
var resolveVariable_1 = require("./resolveVariable");
|
|
21
21
|
Object.defineProperty(exports, "resolveVariable", { enumerable: true, get: function () { return resolveVariable_1.resolveVariable; } });
|
|
22
|
+
var pageFlow_1 = require("./pageFlow");
|
|
23
|
+
Object.defineProperty(exports, "splitIntoPages", { enumerable: true, get: function () { return pageFlow_1.splitIntoPages; } });
|
|
24
|
+
var validate_1 = require("./validate");
|
|
25
|
+
Object.defineProperty(exports, "validateAnswer", { enumerable: true, get: function () { return validate_1.validateAnswer; } });
|
|
26
|
+
var voice_1 = require("./voice");
|
|
27
|
+
Object.defineProperty(exports, "normalizeTranscript", { enumerable: true, get: function () { return voice_1.normalizeTranscript; } });
|
|
28
|
+
Object.defineProperty(exports, "matchBoolean", { enumerable: true, get: function () { return voice_1.matchBoolean; } });
|
|
29
|
+
Object.defineProperty(exports, "matchOption", { enumerable: true, get: function () { return voice_1.matchOption; } });
|
|
30
|
+
Object.defineProperty(exports, "matchOptions", { enumerable: true, get: function () { return voice_1.matchOptions; } });
|
|
31
|
+
Object.defineProperty(exports, "rankOptions", { enumerable: true, get: function () { return voice_1.rankOptions; } });
|
|
32
|
+
Object.defineProperty(exports, "matchNumberInRange", { enumerable: true, get: function () { return voice_1.matchNumberInRange; } });
|
|
33
|
+
Object.defineProperty(exports, "matchDate", { enumerable: true, get: function () { return voice_1.matchDate; } });
|
|
34
|
+
Object.defineProperty(exports, "distinctiveLabels", { enumerable: true, get: function () { return voice_1.distinctiveLabels; } });
|
|
35
|
+
Object.defineProperty(exports, "voiceSupport", { enumerable: true, get: function () { return voice_1.voiceSupport; } });
|
|
36
|
+
Object.defineProperty(exports, "voiceCanHandle", { enumerable: true, get: function () { return voice_1.voiceCanHandle; } });
|
|
37
|
+
Object.defineProperty(exports, "auditVoiceQuestions", { enumerable: true, get: function () { return voice_1.auditVoiceQuestions; } });
|
|
38
|
+
Object.defineProperty(exports, "voiceDisclaimer", { enumerable: true, get: function () { return voice_1.voiceDisclaimer; } });
|
|
39
|
+
Object.defineProperty(exports, "skipNoteText", { enumerable: true, get: function () { return voice_1.skipNoteText; } });
|
|
40
|
+
Object.defineProperty(exports, "scaffoldFor", { enumerable: true, get: function () { return voice_1.scaffoldFor; } });
|
|
41
|
+
Object.defineProperty(exports, "buildPromptText", { enumerable: true, get: function () { return voice_1.buildPromptText; } });
|
|
42
|
+
Object.defineProperty(exports, "shortCode", { enumerable: true, get: function () { return voice_1.shortCode; } });
|
|
43
|
+
Object.defineProperty(exports, "optionLabelOf", { enumerable: true, get: function () { return voice_1.optionLabelOf; } });
|
|
44
|
+
Object.defineProperty(exports, "optionValueOf", { enumerable: true, get: function () { return voice_1.optionValueOf; } });
|
|
45
|
+
Object.defineProperty(exports, "voiceOptionsOf", { enumerable: true, get: function () { return voice_1.voiceOptionsOf; } });
|
|
46
|
+
Object.defineProperty(exports, "optionLabelsOf", { enumerable: true, get: function () { return voice_1.optionLabelsOf; } });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { EvalContext } from './types';
|
|
2
|
+
export interface FlowQuestion {
|
|
3
|
+
id: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
/** Multi-flow (spec §28). Undefined means the root flow. */
|
|
6
|
+
flow?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface FlowSection {
|
|
9
|
+
id: string;
|
|
10
|
+
questionIds?: string[];
|
|
11
|
+
hidden?: boolean;
|
|
12
|
+
showWhen?: string;
|
|
13
|
+
hideWhen?: string;
|
|
14
|
+
/** true → its own page. Absent/false → an inline heading in the current page. */
|
|
15
|
+
pageBreak?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export type PageItem<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection> = {
|
|
18
|
+
kind: 'question';
|
|
19
|
+
question: Q;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'section-heading';
|
|
22
|
+
section: S;
|
|
23
|
+
};
|
|
24
|
+
export interface Page<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection> {
|
|
25
|
+
section?: S;
|
|
26
|
+
items: PageItem<Q, S>[];
|
|
27
|
+
questions: Q[];
|
|
28
|
+
}
|
|
29
|
+
export interface PageFlowOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Multi-flow filter. `undefined` = the root flow (a root respondent never
|
|
32
|
+
* sees fork questions inline); a string = that named flow; `null` bypasses
|
|
33
|
+
* the filter entirely, which mobile uses for cross-flow lookups such as
|
|
34
|
+
* resolving a `rejoinAt` target.
|
|
35
|
+
*/
|
|
36
|
+
flow?: string | null;
|
|
37
|
+
presentationMode?: string;
|
|
38
|
+
}
|
|
39
|
+
export declare function splitIntoPages<Q extends FlowQuestion = FlowQuestion, S extends FlowSection = FlowSection>(survey: {
|
|
40
|
+
questions?: Q[];
|
|
41
|
+
sections?: S[];
|
|
42
|
+
}, ctx: EvalContext, options?: PageFlowOptions): Page<Q, S>[];
|
|
43
|
+
//# sourceMappingURL=pageFlow.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pageFlow.d.ts","sourceRoot":"","sources":["../src/pageFlow.ts"],"names":[],"mappings":"AAwCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAK3C,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iFAAiF;IACjF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAMD,MAAM,MAAM,QAAQ,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW,IAC3F;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,QAAQ,EAAE,CAAC,CAAA;CAAE,GACjC;IAAE,IAAI,EAAE,iBAAiB,CAAC;IAAC,OAAO,EAAE,CAAC,CAAA;CAAE,CAAC;AAE5C,MAAM,WAAW,IAAI,CAAC,CAAC,SAAS,YAAY,GAAG,YAAY,EAAE,CAAC,SAAS,WAAW,GAAG,WAAW;IAC9F,OAAO,CAAC,EAAE,CAAC,CAAC;IACZ,KAAK,EAAE,QAAQ,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CAAC;IACxB,SAAS,EAAE,CAAC,EAAE,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAoBD,wBAAgB,cAAc,CAC5B,CAAC,SAAS,YAAY,GAAG,YAAY,EACrC,CAAC,SAAS,WAAW,GAAG,WAAW,EAEnC,MAAM,EAAE;IAAE,SAAS,CAAC,EAAE,CAAC,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,EAAE,CAAA;CAAE,EAC3C,GAAG,EAAE,WAAW,EAChB,OAAO,GAAE,eAAoB,GAC5B,IAAI,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,CA+Dd"}
|
package/dist/pageFlow.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.splitIntoPages = splitIntoPages;
|
|
4
|
+
/**
|
|
5
|
+
* Page flow — how a flat `questions[]` plus a `sections[]` overlay becomes pages.
|
|
6
|
+
*
|
|
7
|
+
* This was written three times and the implementations disagreed on 6 of 17
|
|
8
|
+
* fixtures (`tools/survey-conformance/pageFlow.js`). The differences were not
|
|
9
|
+
* cosmetic: a survey with a loose question before a section was asked in a
|
|
10
|
+
* DIFFERENT ORDER on React than on mobile, and the answers were recorded as
|
|
11
|
+
* though it were the same instrument.
|
|
12
|
+
*
|
|
13
|
+
* ## What this module settles, and what it deliberately does not
|
|
14
|
+
*
|
|
15
|
+
* Two of the three divergences were bugs and are closed here:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Page order follows QUESTION order.** A section is emitted when the walk
|
|
18
|
+
* first reaches a question that belongs to it. Mobile previously walked
|
|
19
|
+
* `sections[]` and appended unclaimed questions at the end, so section order
|
|
20
|
+
* in the definition — which the builder does not treat as meaningful — could
|
|
21
|
+
* reorder the survey.
|
|
22
|
+
*
|
|
23
|
+
* 2. **Section conditions are evaluated.** `showWhen` / `hideWhen` on a section
|
|
24
|
+
* are honoured, not just `hidden`. Mobile ignored them, so a section
|
|
25
|
+
* conditioned away still split pages there.
|
|
26
|
+
*
|
|
27
|
+
* The third was a product decision rather than a defect, and it has been made:
|
|
28
|
+
* in typeform and conversation modes each question gets its own page, and a
|
|
29
|
+
* section does not group them. That is mobile's long-standing behaviour and
|
|
30
|
+
* matches what the builder does — it hides the add-page control in these modes
|
|
31
|
+
* and turns section headers into intro pages, so the author is already working
|
|
32
|
+
* one question at a time.
|
|
33
|
+
*
|
|
34
|
+
* It carried a `typeformSections` parameter while the question was open. The
|
|
35
|
+
* parameter is gone now the answer exists: a configurable behaviour nobody
|
|
36
|
+
* configures is a second code path that only gets exercised by accident.
|
|
37
|
+
*
|
|
38
|
+
* REACT CHANGES because of this. A typeform survey whose questions are grouped
|
|
39
|
+
* into sections previously showed one page per section there and now shows one
|
|
40
|
+
* per question, so its page count goes up and its progress bar moves in smaller
|
|
41
|
+
* steps. Mobile is unaffected.
|
|
42
|
+
*/
|
|
43
|
+
const expressionEval_1 = require("./expressionEval");
|
|
44
|
+
const isPageBreak = (q) => String(q.type || '').toLowerCase() === 'pagebreak';
|
|
45
|
+
/** Sections that survive `hidden`, `showWhen` and `hideWhen`, keyed for lookup. */
|
|
46
|
+
function activeSections(sections, ctx) {
|
|
47
|
+
const claimed = new Map();
|
|
48
|
+
const active = new Map();
|
|
49
|
+
for (const s of sections) {
|
|
50
|
+
if (s.hidden === true)
|
|
51
|
+
continue;
|
|
52
|
+
if (!(0, expressionEval_1.evaluateCondition)(s.showWhen, ctx))
|
|
53
|
+
continue;
|
|
54
|
+
if (s.hideWhen && (0, expressionEval_1.evaluateCondition)(s.hideWhen, ctx))
|
|
55
|
+
continue;
|
|
56
|
+
for (const id of s.questionIds || [])
|
|
57
|
+
claimed.set(id, s.id);
|
|
58
|
+
active.set(s.id, s);
|
|
59
|
+
}
|
|
60
|
+
// A question claimed by a SKIPPED section is deliberately left unclaimed, so
|
|
61
|
+
// it falls through into whichever page it lands in rather than disappearing.
|
|
62
|
+
return { claimed, active };
|
|
63
|
+
}
|
|
64
|
+
function splitIntoPages(survey, ctx, options = {}) {
|
|
65
|
+
const { flow, presentationMode } = options;
|
|
66
|
+
const raw = survey.questions || [];
|
|
67
|
+
const all = flow === null
|
|
68
|
+
? raw
|
|
69
|
+
: raw.filter((q) => (q.flow || undefined) === (flow || undefined));
|
|
70
|
+
const sections = survey.sections || [];
|
|
71
|
+
const { claimed, active } = activeSections(sections, ctx);
|
|
72
|
+
// One question per page. Sections do not group in these modes — see the
|
|
73
|
+
// header. `claimed` is still computed above because section conditions can
|
|
74
|
+
// hide a question's section, and a question whose section is skipped still
|
|
75
|
+
// renders; it just gets its own page like every other.
|
|
76
|
+
if (presentationMode === 'typeform' || presentationMode === 'conversation') {
|
|
77
|
+
return all
|
|
78
|
+
.filter((q) => !isPageBreak(q))
|
|
79
|
+
.map((q) => ({ items: [{ kind: 'question', question: q }], questions: [q] }));
|
|
80
|
+
}
|
|
81
|
+
const pages = [];
|
|
82
|
+
const emitted = new Set();
|
|
83
|
+
let current = [];
|
|
84
|
+
const flush = () => {
|
|
85
|
+
if (!current.length)
|
|
86
|
+
return;
|
|
87
|
+
pages.push({
|
|
88
|
+
items: current,
|
|
89
|
+
questions: current
|
|
90
|
+
.filter((it) => it.kind === 'question')
|
|
91
|
+
.map((it) => it.question),
|
|
92
|
+
});
|
|
93
|
+
current = [];
|
|
94
|
+
};
|
|
95
|
+
for (const q of all) {
|
|
96
|
+
// A pageBreak splits the current page and never renders itself. One inside
|
|
97
|
+
// a section is ignored — the section already controls its own boundary.
|
|
98
|
+
if (isPageBreak(q)) {
|
|
99
|
+
if (!claimed.has(q.id))
|
|
100
|
+
flush();
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const sid = claimed.get(q.id);
|
|
104
|
+
if (!sid) {
|
|
105
|
+
current.push({ kind: 'question', question: q });
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (emitted.has(sid))
|
|
109
|
+
continue;
|
|
110
|
+
const section = active.get(sid);
|
|
111
|
+
if (!section)
|
|
112
|
+
continue;
|
|
113
|
+
const qs = all.filter((qq) => claimed.get(qq.id) === sid && !isPageBreak(qq));
|
|
114
|
+
if (section.pageBreak === true) {
|
|
115
|
+
flush();
|
|
116
|
+
pages.push({ section, items: qs.map((qq) => ({ kind: 'question', question: qq })), questions: qs });
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
current.push({ kind: 'section-heading', section });
|
|
120
|
+
for (const qq of qs)
|
|
121
|
+
current.push({ kind: 'question', question: qq });
|
|
122
|
+
}
|
|
123
|
+
emitted.add(sid);
|
|
124
|
+
}
|
|
125
|
+
flush();
|
|
126
|
+
return pages;
|
|
127
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer validation — whether a respondent may submit.
|
|
3
|
+
*
|
|
4
|
+
* A divergence here means the same answer is accepted on one channel and
|
|
5
|
+
* rejected on another. The respondent hits a wall that does not exist for
|
|
6
|
+
* someone who arrived by a different route, and an analyst comparing the two
|
|
7
|
+
* sets of responses is comparing two instruments.
|
|
8
|
+
*
|
|
9
|
+
* There were three implementations and all three disagreed:
|
|
10
|
+
*
|
|
11
|
+
* • The WEB SDK had none. `survey.js` checked emptiness inline at the Next
|
|
12
|
+
* button and nothing else, so it accepted 8 of 32 answers the others
|
|
13
|
+
* rejected — including whitespace against a required question, `nope` as an
|
|
14
|
+
* email address, and any string against `minLength`.
|
|
15
|
+
*
|
|
16
|
+
* • REACT and MOBILE agreed on 26 fixtures and still differed, because every
|
|
17
|
+
* one of those fixtures used `type: 'text'` with an `inputType`. React's
|
|
18
|
+
* dispatcher has no case for `email`, `number`, `csat` or `ces` as question
|
|
19
|
+
* TYPES, so a question of `type: 'email'` was format-checked on the phone
|
|
20
|
+
* and waved through on a link.
|
|
21
|
+
*
|
|
22
|
+
* This module is the union, and each half was chosen for a reason:
|
|
23
|
+
*
|
|
24
|
+
* • `isEmpty` is React's, because it is type-aware. The web renderer's values
|
|
25
|
+
* are compound — `{ choice }`, `{ choices }`, `{ text, attachments }` — and
|
|
26
|
+
* mobile's primitives pass through the same code unharmed. Mobile's
|
|
27
|
+
* primitive-only version would have called `{ choice: undefined }` non-empty
|
|
28
|
+
* and let a blank required radio through.
|
|
29
|
+
*
|
|
30
|
+
* • The type dispatcher is mobile's, because it is the superset, and the
|
|
31
|
+
* effective format is derived from `inputType || type` so `type: 'email'`
|
|
32
|
+
* validates as an email whether or not the author also set an inputType.
|
|
33
|
+
*
|
|
34
|
+
* Order is spec §8 and it matters: required, then type-specific format, then
|
|
35
|
+
* the author's `rules[]`. An empty answer should read "this is required", not
|
|
36
|
+
* "that is not a valid email" — which sounds like the shape is wrong rather
|
|
37
|
+
* than the field being blank.
|
|
38
|
+
*/
|
|
39
|
+
export interface ValidatableQuestion {
|
|
40
|
+
type?: string;
|
|
41
|
+
inputType?: string;
|
|
42
|
+
required?: boolean;
|
|
43
|
+
isRequired?: boolean;
|
|
44
|
+
minLength?: number;
|
|
45
|
+
maxLength?: number;
|
|
46
|
+
minValue?: number;
|
|
47
|
+
maxValue?: number;
|
|
48
|
+
minCount?: number;
|
|
49
|
+
maxCount?: number;
|
|
50
|
+
rules?: unknown;
|
|
51
|
+
}
|
|
52
|
+
/** Null when the answer is valid; otherwise the message to show. */
|
|
53
|
+
export declare function validateAnswer(question: ValidatableQuestion, value: unknown): string | null;
|
|
54
|
+
//# sourceMappingURL=validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAYH,MAAM,WAAW,mBAAmB;IAClC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,oEAAoE;AACpE,wBAAgB,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAgB3F"}
|
package/dist/validate.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Answer validation — whether a respondent may submit.
|
|
4
|
+
*
|
|
5
|
+
* A divergence here means the same answer is accepted on one channel and
|
|
6
|
+
* rejected on another. The respondent hits a wall that does not exist for
|
|
7
|
+
* someone who arrived by a different route, and an analyst comparing the two
|
|
8
|
+
* sets of responses is comparing two instruments.
|
|
9
|
+
*
|
|
10
|
+
* There were three implementations and all three disagreed:
|
|
11
|
+
*
|
|
12
|
+
* • The WEB SDK had none. `survey.js` checked emptiness inline at the Next
|
|
13
|
+
* button and nothing else, so it accepted 8 of 32 answers the others
|
|
14
|
+
* rejected — including whitespace against a required question, `nope` as an
|
|
15
|
+
* email address, and any string against `minLength`.
|
|
16
|
+
*
|
|
17
|
+
* • REACT and MOBILE agreed on 26 fixtures and still differed, because every
|
|
18
|
+
* one of those fixtures used `type: 'text'` with an `inputType`. React's
|
|
19
|
+
* dispatcher has no case for `email`, `number`, `csat` or `ces` as question
|
|
20
|
+
* TYPES, so a question of `type: 'email'` was format-checked on the phone
|
|
21
|
+
* and waved through on a link.
|
|
22
|
+
*
|
|
23
|
+
* This module is the union, and each half was chosen for a reason:
|
|
24
|
+
*
|
|
25
|
+
* • `isEmpty` is React's, because it is type-aware. The web renderer's values
|
|
26
|
+
* are compound — `{ choice }`, `{ choices }`, `{ text, attachments }` — and
|
|
27
|
+
* mobile's primitives pass through the same code unharmed. Mobile's
|
|
28
|
+
* primitive-only version would have called `{ choice: undefined }` non-empty
|
|
29
|
+
* and let a blank required radio through.
|
|
30
|
+
*
|
|
31
|
+
* • The type dispatcher is mobile's, because it is the superset, and the
|
|
32
|
+
* effective format is derived from `inputType || type` so `type: 'email'`
|
|
33
|
+
* validates as an email whether or not the author also set an inputType.
|
|
34
|
+
*
|
|
35
|
+
* Order is spec §8 and it matters: required, then type-specific format, then
|
|
36
|
+
* the author's `rules[]`. An empty answer should read "this is required", not
|
|
37
|
+
* "that is not a valid email" — which sounds like the shape is wrong rather
|
|
38
|
+
* than the field being blank.
|
|
39
|
+
*/
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
exports.validateAnswer = validateAnswer;
|
|
42
|
+
/**
|
|
43
|
+
* Practical email format — matches what the HTML5 email validator accepts.
|
|
44
|
+
* Not RFC 5322-strict: that regex is a page long and rejects addresses real
|
|
45
|
+
* mail servers deliver to every day.
|
|
46
|
+
*/
|
|
47
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
48
|
+
/** URL with a scheme. No guessing at whether `http://` was meant. */
|
|
49
|
+
const URL_RE = /^https?:\/\/[\w.\-]+(?:\.[\w.\-]+)+(?:[/?#][^\s]*)?$/i;
|
|
50
|
+
/** Null when the answer is valid; otherwise the message to show. */
|
|
51
|
+
function validateAnswer(question, value) {
|
|
52
|
+
// BOTH spellings. The v1 vocabulary and the builder both emit `isRequired`,
|
|
53
|
+
// and a renderer reading only `required` let a required question be submitted
|
|
54
|
+
// blank while the other channels rejected it.
|
|
55
|
+
const empty = isEmpty(question, value);
|
|
56
|
+
if ((question.required || question.isRequired) && empty) {
|
|
57
|
+
return 'This field is required';
|
|
58
|
+
}
|
|
59
|
+
// An optional field left blank is valid. Complaining about the shape of an
|
|
60
|
+
// answer nobody gave is noise.
|
|
61
|
+
if (empty)
|
|
62
|
+
return null;
|
|
63
|
+
const typeErr = validateByType(question, value);
|
|
64
|
+
if (typeErr)
|
|
65
|
+
return typeErr;
|
|
66
|
+
return validateRules(question, value);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Emptiness, per value shape.
|
|
70
|
+
*
|
|
71
|
+
* `0` and `false` are ANSWERS. A rating of zero and a "no" on a yes/no question
|
|
72
|
+
* are both real responses, and treating them as blank rejects a respondent who
|
|
73
|
+
* answered.
|
|
74
|
+
*/
|
|
75
|
+
function isEmpty(question, value) {
|
|
76
|
+
if (value === undefined || value === null)
|
|
77
|
+
return true;
|
|
78
|
+
if (typeof value === 'string')
|
|
79
|
+
return value.trim() === '';
|
|
80
|
+
if (typeof value === 'number')
|
|
81
|
+
return Number.isNaN(value);
|
|
82
|
+
if (typeof value === 'boolean')
|
|
83
|
+
return false;
|
|
84
|
+
if (Array.isArray(value))
|
|
85
|
+
return value.length === 0;
|
|
86
|
+
if (typeof value === 'object') {
|
|
87
|
+
const v = value;
|
|
88
|
+
if (question.type === 'radio')
|
|
89
|
+
return !v.choice;
|
|
90
|
+
if (question.type === 'checkbox') {
|
|
91
|
+
return !Array.isArray(v.choices) || v.choices.length === 0;
|
|
92
|
+
}
|
|
93
|
+
if (question.type === 'multiText') {
|
|
94
|
+
return Object.values(v).every((x) => x === undefined || x === null || (typeof x === 'string' && x.trim() === ''));
|
|
95
|
+
}
|
|
96
|
+
if (question.type === 'text' || question.type === 'comment') {
|
|
97
|
+
// allowAttachments makes this { text, attachments }. Empty means both:
|
|
98
|
+
// a respondent who attached a file and typed nothing has answered.
|
|
99
|
+
const textEmpty = typeof v.text !== 'string' || v.text.trim() === '';
|
|
100
|
+
const noFiles = !Array.isArray(v.attachments) || v.attachments.length === 0;
|
|
101
|
+
return textEmpty && noFiles;
|
|
102
|
+
}
|
|
103
|
+
// matrixDropdown / matrixDynamic / panelDynamic / signature — any content
|
|
104
|
+
// counts as answered.
|
|
105
|
+
return Object.keys(v).length === 0;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
function validateByType(question, value) {
|
|
110
|
+
switch (question.type) {
|
|
111
|
+
case 'text':
|
|
112
|
+
case 'comment':
|
|
113
|
+
case 'email':
|
|
114
|
+
case 'number':
|
|
115
|
+
return validateTextish(question, value);
|
|
116
|
+
case 'checkbox':
|
|
117
|
+
return validateCheckbox(question, value);
|
|
118
|
+
case 'rating':
|
|
119
|
+
case 'csat':
|
|
120
|
+
case 'ces':
|
|
121
|
+
case 'nps':
|
|
122
|
+
return validateNumericRange(question, value);
|
|
123
|
+
default:
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function validateTextish(question, value) {
|
|
128
|
+
// Unwrap the compound { text, attachments } shape the web renderer uses.
|
|
129
|
+
let raw;
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
raw = value;
|
|
132
|
+
}
|
|
133
|
+
else if (value && typeof value === 'object' && 'text' in value) {
|
|
134
|
+
const t = value.text;
|
|
135
|
+
raw = typeof t === 'string' ? t : '';
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
raw = String(value ?? '');
|
|
139
|
+
}
|
|
140
|
+
const trimmed = raw.trim();
|
|
141
|
+
// An author who picks the "Email" question type has declared the format just
|
|
142
|
+
// as much as one who sets inputType on a text question.
|
|
143
|
+
const inputType = question.inputType
|
|
144
|
+
|| (question.type === 'email' ? 'email' : question.type === 'number' ? 'number' : undefined);
|
|
145
|
+
if (inputType === 'email' && !EMAIL_RE.test(trimmed)) {
|
|
146
|
+
return 'Please enter a valid email address (e.g., you@example.com)';
|
|
147
|
+
}
|
|
148
|
+
if (inputType === 'url' && !URL_RE.test(trimmed)) {
|
|
149
|
+
return 'Please enter a full URL, including https://';
|
|
150
|
+
}
|
|
151
|
+
if (inputType === 'number' && Number.isNaN(Number(trimmed))) {
|
|
152
|
+
return 'Please enter a number';
|
|
153
|
+
}
|
|
154
|
+
if (inputType === 'tel' && !/^[\d\s+\-()]{3,}$/.test(trimmed)) {
|
|
155
|
+
// Deliberately permissive. Country-specific phone validation inline is a
|
|
156
|
+
// losing game that mostly rejects real numbers.
|
|
157
|
+
return 'Please enter a valid phone number';
|
|
158
|
+
}
|
|
159
|
+
if (typeof question.maxLength === 'number' && trimmed.length > question.maxLength) {
|
|
160
|
+
return `Please keep this under ${question.maxLength} characters`;
|
|
161
|
+
}
|
|
162
|
+
if (typeof question.minLength === 'number' && trimmed.length < question.minLength) {
|
|
163
|
+
return `Please enter at least ${question.minLength} characters`;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
function validateCheckbox(question, value) {
|
|
168
|
+
// Web sends { choices }, mobile sends an array. Both are the same answer.
|
|
169
|
+
const choices = Array.isArray(value)
|
|
170
|
+
? value
|
|
171
|
+
: (value && typeof value === 'object' && Array.isArray(value.choices)
|
|
172
|
+
? value.choices
|
|
173
|
+
: []);
|
|
174
|
+
const count = choices.length;
|
|
175
|
+
if (typeof question.minCount === 'number' && count < question.minCount) {
|
|
176
|
+
return `Please select at least ${question.minCount}`;
|
|
177
|
+
}
|
|
178
|
+
if (typeof question.maxCount === 'number' && count > question.maxCount) {
|
|
179
|
+
return `Please select at most ${question.maxCount}`;
|
|
180
|
+
}
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
function validateNumericRange(question, value) {
|
|
184
|
+
if (typeof value !== 'number')
|
|
185
|
+
return null;
|
|
186
|
+
if (typeof question.minValue === 'number' && value < question.minValue) {
|
|
187
|
+
return `Must be at least ${question.minValue}`;
|
|
188
|
+
}
|
|
189
|
+
if (typeof question.maxValue === 'number' && value > question.maxValue) {
|
|
190
|
+
return `Must be at most ${question.maxValue}`;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function validateRules(question, value) {
|
|
195
|
+
const rules = Array.isArray(question.rules) ? question.rules : [];
|
|
196
|
+
if (rules.length === 0)
|
|
197
|
+
return null;
|
|
198
|
+
const str = typeof value === 'string' ? value : String(value ?? '');
|
|
199
|
+
for (const r of rules) {
|
|
200
|
+
const err = applyRule(r, str);
|
|
201
|
+
// The author's own message wins when they wrote one.
|
|
202
|
+
if (err)
|
|
203
|
+
return r.message || err;
|
|
204
|
+
}
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
function applyRule(rule, str) {
|
|
208
|
+
switch (rule.type) {
|
|
209
|
+
case 'minLength':
|
|
210
|
+
return typeof rule.value === 'number' && str.length < rule.value
|
|
211
|
+
? `At least ${rule.value} characters` : null;
|
|
212
|
+
case 'maxLength':
|
|
213
|
+
return typeof rule.value === 'number' && str.length > rule.value
|
|
214
|
+
? `At most ${rule.value} characters` : null;
|
|
215
|
+
case 'min':
|
|
216
|
+
return typeof rule.value === 'number' && Number(str) < rule.value
|
|
217
|
+
? `Must be at least ${rule.value}` : null;
|
|
218
|
+
case 'max':
|
|
219
|
+
return typeof rule.value === 'number' && Number(str) > rule.value
|
|
220
|
+
? `Must be at most ${rule.value}` : null;
|
|
221
|
+
case 'email':
|
|
222
|
+
return EMAIL_RE.test(str) ? null : 'Please enter a valid email address';
|
|
223
|
+
case 'url':
|
|
224
|
+
return URL_RE.test(str) ? null : 'Please enter a valid URL';
|
|
225
|
+
case 'pattern': {
|
|
226
|
+
if (typeof rule.value !== 'string')
|
|
227
|
+
return null;
|
|
228
|
+
try {
|
|
229
|
+
// An unparseable pattern is an authoring mistake. Failing OPEN is
|
|
230
|
+
// deliberate: blocking every respondent on a survey because of a typo
|
|
231
|
+
// in a regex is worse than letting the answer through.
|
|
232
|
+
return new RegExp(rule.value).test(str) ? null : "Format doesn't match";
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
// Unknown rule types skip rather than block — the spec can add one
|
|
240
|
+
// before every renderer knows about it.
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
package/dist/voice.d.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning speech into an answer.
|
|
3
|
+
*
|
|
4
|
+
* This is the point where a respondent's VOICE becomes stored data, and a
|
|
5
|
+
* divergence between channels is invisible afterwards: the row looks fine, it
|
|
6
|
+
* is just a different value than the same sentence produced somewhere else.
|
|
7
|
+
* Three implementations existed — the React renderer's handsFreeVoice.tsx, the
|
|
8
|
+
* web SDK's voice.js (ported from it) and the mobile renderer's
|
|
9
|
+
* voiceParsers.ts (an older, stricter copy). Mobile had drifted: no fuzzy
|
|
10
|
+
* tolerance, no filler stripping, no letter shortcuts, no Arabic orthographic
|
|
11
|
+
* folding, English-only ordinals. "grean" for Green resolved on a link and on
|
|
12
|
+
* a website and failed on the phone, which is the channel where hands-free
|
|
13
|
+
* matters most.
|
|
14
|
+
*
|
|
15
|
+
* ── The seam ─────────────────────────────────────────────────────────────
|
|
16
|
+
*
|
|
17
|
+
* These functions return an option INDEX, never an answer. That is deliberate.
|
|
18
|
+
* The three renderers genuinely disagree about answer shape and are each right
|
|
19
|
+
* about their own: React's radio widget stores `{ choice: value }` and
|
|
20
|
+
* normalises it at submit, web and mobile store the bare value. A shared
|
|
21
|
+
* function returning "the answer" would have to pick one and break two.
|
|
22
|
+
* Matching a sentence to an option is the part that must be identical;
|
|
23
|
+
* wrapping an index in a shape is the part that must not be.
|
|
24
|
+
*
|
|
25
|
+
* tools/survey-conformance/voice.js runs the same spoken answers through all
|
|
26
|
+
* three and fails on any disagreement.
|
|
27
|
+
*/
|
|
28
|
+
export declare function shortCode(lang?: string): string;
|
|
29
|
+
/** Lower-case, fold Arabic orthography, strip punctuation, normalise spelling. */
|
|
30
|
+
export declare function normalizeTranscript(s: unknown): string;
|
|
31
|
+
/**
|
|
32
|
+
* Strip the prefix every option shares, so "Option A / Option B / Option C"
|
|
33
|
+
* reduces to "a" / "b" / "c" and a respondent who just says "C" is understood.
|
|
34
|
+
*/
|
|
35
|
+
export declare function distinctiveLabels(labels: string[]): string[];
|
|
36
|
+
export interface VoiceOption {
|
|
37
|
+
value?: unknown;
|
|
38
|
+
label?: string;
|
|
39
|
+
text?: string;
|
|
40
|
+
alt?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface VoiceQuestion {
|
|
43
|
+
type?: string;
|
|
44
|
+
title?: string;
|
|
45
|
+
text?: string;
|
|
46
|
+
label?: string;
|
|
47
|
+
options?: unknown;
|
|
48
|
+
choices?: unknown;
|
|
49
|
+
yesLabel?: string;
|
|
50
|
+
noLabel?: string;
|
|
51
|
+
minValue?: number;
|
|
52
|
+
maxValue?: number;
|
|
53
|
+
minLabel?: string;
|
|
54
|
+
maxLabel?: string;
|
|
55
|
+
isRequired?: boolean;
|
|
56
|
+
required?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export declare function optionLabelOf(o: VoiceOption): string;
|
|
59
|
+
export declare function optionValueOf(o: VoiceOption): unknown;
|
|
60
|
+
export declare function voiceOptionsOf(q: VoiceQuestion): VoiceOption[];
|
|
61
|
+
export declare function optionLabelsOf(q: VoiceQuestion): string[];
|
|
62
|
+
/** Yes/no, including the question's own yes/no labels. `null` = neither. */
|
|
63
|
+
export declare function matchBoolean(q: VoiceQuestion, transcript: string): boolean | null;
|
|
64
|
+
/** Single choice. Returns the option index, or -1 when nothing matched. */
|
|
65
|
+
export declare function matchOption(q: VoiceQuestion, transcript: string): number;
|
|
66
|
+
/** Multi-choice. Returns ascending option indices; empty when nothing matched. */
|
|
67
|
+
export declare function matchOptions(q: VoiceQuestion, transcript: string): number[];
|
|
68
|
+
/**
|
|
69
|
+
* Ranking. Returns option indices in the order they were spoken, or an empty
|
|
70
|
+
* array when fewer than two were heard — one name is not a ranking.
|
|
71
|
+
*/
|
|
72
|
+
export declare function rankOptions(q: VoiceQuestion, transcript: string): number[];
|
|
73
|
+
/** A number inside [min, max], from digits, number words, or a homophone. */
|
|
74
|
+
export declare function matchNumberInRange(transcript: string, min: number, max: number): number | null;
|
|
75
|
+
/** A spoken date as ISO `YYYY-MM-DD`, or null. */
|
|
76
|
+
export declare function matchDate(transcript: string): string | null;
|
|
77
|
+
/**
|
|
78
|
+
* What a hands-free session can do with a question type.
|
|
79
|
+
*
|
|
80
|
+
* full — speak the scaffold, capture, parse into a value.
|
|
81
|
+
* matrix — walk it row by row; each row is a single choice.
|
|
82
|
+
* read — display-only. Read it and advance; there is nothing to capture.
|
|
83
|
+
* assist — read it aloud, then hand off to the visual UI. The respondent can
|
|
84
|
+
* answer, just not by speaking.
|
|
85
|
+
* none — voice cannot produce this answer at all (a signature, a file).
|
|
86
|
+
*
|
|
87
|
+
* `assist` and `none` are one bucket to web and mobile, which collapse both to
|
|
88
|
+
* 'partial'. They are separate here because React distinguishes them and the
|
|
89
|
+
* spec should not force a renderer to lose a distinction it makes.
|
|
90
|
+
*/
|
|
91
|
+
export type VoiceSupport = 'full' | 'matrix' | 'read' | 'assist' | 'none';
|
|
92
|
+
export declare function voiceSupport(type: string): VoiceSupport;
|
|
93
|
+
/** Can a voice-only session get through this question unaided? */
|
|
94
|
+
export declare function voiceCanHandle(type: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* blocking = required questions voice can't handle → the session must not start.
|
|
97
|
+
* skippable = optional ones → announced and skipped.
|
|
98
|
+
*/
|
|
99
|
+
export declare function auditVoiceQuestions<Q extends VoiceQuestion>(questions: Q[]): {
|
|
100
|
+
blocking: Q[];
|
|
101
|
+
skippable: Q[];
|
|
102
|
+
};
|
|
103
|
+
export interface VoiceScaffold {
|
|
104
|
+
scale: (min: number, max: number, minL?: string, maxL?: string) => string;
|
|
105
|
+
binary: (y: string, n: string) => string;
|
|
106
|
+
options: (o: string[]) => string;
|
|
107
|
+
multi: (o: string[]) => string;
|
|
108
|
+
ranking: (o: string[]) => string;
|
|
109
|
+
date: () => string;
|
|
110
|
+
matrixStem: (stem: string) => string;
|
|
111
|
+
matrixRow: (row: string, cols: string[]) => string;
|
|
112
|
+
}
|
|
113
|
+
export declare function scaffoldFor(lang?: string): VoiceScaffold;
|
|
114
|
+
export declare function voiceDisclaimer(n: number, lang?: string): string;
|
|
115
|
+
export declare function skipNoteText(lang?: string): string;
|
|
116
|
+
/** What the session says out loud to ask this question. */
|
|
117
|
+
export declare function buildPromptText(q: VoiceQuestion, lang?: string): string;
|
|
118
|
+
//# sourceMappingURL=voice.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice.d.ts","sourceRoot":"","sources":["../src/voice.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH,wBAAgB,SAAS,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAE/C;AA6CD,kFAAkF;AAClF,wBAAgB,mBAAmB,CAAC,CAAC,EAAE,OAAO,GAAG,MAAM,CAWtD;AAwFD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAmB5D;AAgED,MAAM,WAAW,WAAW;IAAG,KAAK,CAAC,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE;AAC7F,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAC9C,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IACrC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3E,UAAU,CAAC,EAAE,OAAO,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAC;CAC1C;AAED,wBAAgB,aAAa,CAAC,CAAC,EAAE,WAAW,GAAG,MAAM,CAEpD;AACD,wBAAgB,aAAa,CAAC,CAAC,EAAE,WAAW,GAAG,OAAO,CAErD;AACD,wBAAgB,cAAc,CAAC,CAAC,EAAE,aAAa,GAAG,WAAW,EAAE,CAI9D;AACD,wBAAgB,cAAc,CAAC,CAAC,EAAE,aAAa,GAAG,MAAM,EAAE,CAEzD;AAID,4EAA4E;AAC5E,wBAAgB,YAAY,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,GAAG,IAAI,CAiBjF;AAED,2EAA2E;AAC3E,wBAAgB,WAAW,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAkBxE;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAyB3E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,CAAC,EAAE,aAAa,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAc1E;AAED,6EAA6E;AAC7E,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CA+B9F;AAED,kDAAkD;AAClD,wBAAgB,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAe3D;AAID;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,YAAY,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;AAE1E,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAqBvD;AAED,kEAAkE;AAClE,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAGpD;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,CAAC,SAAS,aAAa,EAAE,SAAS,EAAE,CAAC,EAAE,GAAG;IAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;IAAC,SAAS,EAAE,CAAC,EAAE,CAAA;CAAE,CAU9G;AAID,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IAC1E,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,MAAM,CAAC;IACzC,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC;IACjC,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC;IAC/B,OAAO,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC;IACjC,IAAI,EAAE,MAAM,MAAM,CAAC;IACnB,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACrC,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,MAAM,CAAC;CACpD;AA+BD,wBAAgB,WAAW,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,aAAa,CAExD;AAED,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAGhE;AAED,wBAAgB,YAAY,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAGlD;AAED,2DAA2D;AAC3D,wBAAgB,eAAe,CAAC,CAAC,EAAE,aAAa,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CA6BvE"}
|
package/dist/voice.js
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Turning speech into an answer.
|
|
4
|
+
*
|
|
5
|
+
* This is the point where a respondent's VOICE becomes stored data, and a
|
|
6
|
+
* divergence between channels is invisible afterwards: the row looks fine, it
|
|
7
|
+
* is just a different value than the same sentence produced somewhere else.
|
|
8
|
+
* Three implementations existed — the React renderer's handsFreeVoice.tsx, the
|
|
9
|
+
* web SDK's voice.js (ported from it) and the mobile renderer's
|
|
10
|
+
* voiceParsers.ts (an older, stricter copy). Mobile had drifted: no fuzzy
|
|
11
|
+
* tolerance, no filler stripping, no letter shortcuts, no Arabic orthographic
|
|
12
|
+
* folding, English-only ordinals. "grean" for Green resolved on a link and on
|
|
13
|
+
* a website and failed on the phone, which is the channel where hands-free
|
|
14
|
+
* matters most.
|
|
15
|
+
*
|
|
16
|
+
* ── The seam ─────────────────────────────────────────────────────────────
|
|
17
|
+
*
|
|
18
|
+
* These functions return an option INDEX, never an answer. That is deliberate.
|
|
19
|
+
* The three renderers genuinely disagree about answer shape and are each right
|
|
20
|
+
* about their own: React's radio widget stores `{ choice: value }` and
|
|
21
|
+
* normalises it at submit, web and mobile store the bare value. A shared
|
|
22
|
+
* function returning "the answer" would have to pick one and break two.
|
|
23
|
+
* Matching a sentence to an option is the part that must be identical;
|
|
24
|
+
* wrapping an index in a shape is the part that must not be.
|
|
25
|
+
*
|
|
26
|
+
* tools/survey-conformance/voice.js runs the same spoken answers through all
|
|
27
|
+
* three and fails on any disagreement.
|
|
28
|
+
*/
|
|
29
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
30
|
+
exports.shortCode = shortCode;
|
|
31
|
+
exports.normalizeTranscript = normalizeTranscript;
|
|
32
|
+
exports.distinctiveLabels = distinctiveLabels;
|
|
33
|
+
exports.optionLabelOf = optionLabelOf;
|
|
34
|
+
exports.optionValueOf = optionValueOf;
|
|
35
|
+
exports.voiceOptionsOf = voiceOptionsOf;
|
|
36
|
+
exports.optionLabelsOf = optionLabelsOf;
|
|
37
|
+
exports.matchBoolean = matchBoolean;
|
|
38
|
+
exports.matchOption = matchOption;
|
|
39
|
+
exports.matchOptions = matchOptions;
|
|
40
|
+
exports.rankOptions = rankOptions;
|
|
41
|
+
exports.matchNumberInRange = matchNumberInRange;
|
|
42
|
+
exports.matchDate = matchDate;
|
|
43
|
+
exports.voiceSupport = voiceSupport;
|
|
44
|
+
exports.voiceCanHandle = voiceCanHandle;
|
|
45
|
+
exports.auditVoiceQuestions = auditVoiceQuestions;
|
|
46
|
+
exports.scaffoldFor = scaffoldFor;
|
|
47
|
+
exports.voiceDisclaimer = voiceDisclaimer;
|
|
48
|
+
exports.skipNoteText = skipNoteText;
|
|
49
|
+
exports.buildPromptText = buildPromptText;
|
|
50
|
+
/* ── Locale ─────────────────────────────────────────────────────────────── */
|
|
51
|
+
function shortCode(lang) {
|
|
52
|
+
return String(lang || 'en').split('-')[0].toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
/* ── Transcript normalization ───────────────────────────────────────────── */
|
|
55
|
+
// Contractions expand BEFORE punctuation is stripped: otherwise "I'd say C"
|
|
56
|
+
// loses the apostrophe and leaves a bare "d" token, which the letter shortcut
|
|
57
|
+
// reads as option D — a silently wrong answer.
|
|
58
|
+
const CONTRACTIONS = [
|
|
59
|
+
[/\bcan['’]t\b/g, 'can not'], [/\bwon['’]t\b/g, 'will not'],
|
|
60
|
+
[/n['’]t\b/g, ' not'], [/['’]ll\b/g, ' will'], [/['’]re\b/g, ' are'],
|
|
61
|
+
[/['’]ve\b/g, ' have'], [/['’]m\b/g, ' am'], [/['’]d\b/g, ' would'],
|
|
62
|
+
[/['’]s\b/g, ''],
|
|
63
|
+
];
|
|
64
|
+
// Whole-word pairs, never suffix regexes. A rule like -our→-or also rewrites
|
|
65
|
+
// "four"→"for" and corrupts the number table; -re→-er turns "more" into
|
|
66
|
+
// "moer". Boring beats clever here.
|
|
67
|
+
const EN_SPELLING = {
|
|
68
|
+
colour: 'color', colours: 'colors', coloured: 'colored',
|
|
69
|
+
favour: 'favor', favours: 'favors', favourite: 'favorite', favourites: 'favorites',
|
|
70
|
+
flavour: 'flavor', flavours: 'flavors',
|
|
71
|
+
honour: 'honor', humour: 'humor', labour: 'labor',
|
|
72
|
+
neighbour: 'neighbor', neighbours: 'neighbors',
|
|
73
|
+
behaviour: 'behavior', behaviours: 'behaviors',
|
|
74
|
+
harbour: 'harbor', rumour: 'rumor', odour: 'odor',
|
|
75
|
+
armour: 'armor', vapour: 'vapor', endeavour: 'endeavor',
|
|
76
|
+
centre: 'center', centres: 'centers', theatre: 'theater',
|
|
77
|
+
metre: 'meter', metres: 'meters', litre: 'liter', litres: 'liters',
|
|
78
|
+
fibre: 'fiber', calibre: 'caliber',
|
|
79
|
+
organisation: 'organization', organisations: 'organizations',
|
|
80
|
+
organise: 'organize', organised: 'organized',
|
|
81
|
+
realise: 'realize', realised: 'realized',
|
|
82
|
+
recognise: 'recognize', recognised: 'recognized',
|
|
83
|
+
apologise: 'apologize', apologised: 'apologized',
|
|
84
|
+
personalise: 'personalize', personalised: 'personalized',
|
|
85
|
+
customise: 'customize', customised: 'customized',
|
|
86
|
+
analyse: 'analyze', analysed: 'analyzed',
|
|
87
|
+
catalogue: 'catalog', dialogue: 'dialog',
|
|
88
|
+
grey: 'gray', licence: 'license', defence: 'defense',
|
|
89
|
+
practise: 'practice', programme: 'program',
|
|
90
|
+
travelling: 'traveling', travelled: 'traveled', traveller: 'traveler',
|
|
91
|
+
cancelled: 'canceled', cancelling: 'canceling',
|
|
92
|
+
enquire: 'inquire', enquiry: 'inquiry', enquiries: 'inquiries',
|
|
93
|
+
};
|
|
94
|
+
/** Lower-case, fold Arabic orthography, strip punctuation, normalise spelling. */
|
|
95
|
+
function normalizeTranscript(s) {
|
|
96
|
+
let out = String(s == null ? '' : s).toLowerCase();
|
|
97
|
+
for (const [re, to] of CONTRACTIONS)
|
|
98
|
+
out = out.replace(re, to);
|
|
99
|
+
// Hamza carriers, ta marbuta, alef maqsura, tashkeel and tatweel all fold,
|
|
100
|
+
// so "طلب مساعده" matches "طلب مساعدة".
|
|
101
|
+
out = out.replace(/[ً-ْٰـ]/g, '').replace(/[أإآٱ]/g, 'ا').replace(/ى/g, 'ي').replace(/ة/g, 'ه');
|
|
102
|
+
try {
|
|
103
|
+
out = out.replace(/[^\p{L}\p{N}\s]/gu, ' ');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
out = out.replace(/[^a-z0-9-ۿ\s]/gi, ' ');
|
|
107
|
+
}
|
|
108
|
+
out = out.replace(/\s+/g, ' ').trim();
|
|
109
|
+
if (!out)
|
|
110
|
+
return out;
|
|
111
|
+
return out.split(' ').map((w) => (Object.prototype.hasOwnProperty.call(EN_SPELLING, w) ? EN_SPELLING[w] : w)).join(' ');
|
|
112
|
+
}
|
|
113
|
+
// Tables are keyed by NORMALIZED text. Writing 'أربعة' by hand while input
|
|
114
|
+
// folds to 'اربعه' is exactly the mismatch that silently killed Arabic number
|
|
115
|
+
// parsing in an earlier version.
|
|
116
|
+
const normList = (arr) => arr.map(normalizeTranscript).filter(Boolean);
|
|
117
|
+
const normPairs = (pairs) => pairs.map(([k, v]) => [normalizeTranscript(k), v]).filter(([k]) => !!k);
|
|
118
|
+
const normObj = (obj) => {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const k of Object.keys(obj)) {
|
|
121
|
+
const n = normalizeTranscript(k);
|
|
122
|
+
if (n)
|
|
123
|
+
out[n] = obj[k];
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
const YES_WORDS = normList(['yes', 'yeah', 'yep', 'yup', 'yah', 'sure', 'ok', 'okay', 'true', 'correct', 'right',
|
|
128
|
+
'affirmative', 'absolutely', 'definitely', 'certainly', 'indeed', 'of course', 'i do', 'i would',
|
|
129
|
+
'agreed', 'agree', 'positive', 'for sure', 'why not',
|
|
130
|
+
'نعم', 'أجل', 'اجل', 'صحيح', 'أيوه', 'ايوه', 'اكيد', 'أكيد', 'طبعا', 'بالتأكيد', 'موافق',
|
|
131
|
+
'sí', 'si', 'oui', 'ja', 'sì']);
|
|
132
|
+
const NO_WORDS = normList(['no', 'nope', 'nah', 'naw', 'false', 'incorrect', 'negative', 'never',
|
|
133
|
+
'not really', 'no way', 'i do not', 'i would not', 'disagree', 'wrong', 'nay',
|
|
134
|
+
'لا', 'كلا', 'مش', 'ابدا', 'أبدا', 'غير صحيح', 'لأ', 'non', 'nein']);
|
|
135
|
+
const phrasesOf = (list) => list.filter((w) => w.indexOf(' ') !== -1);
|
|
136
|
+
const YES_PHRASES = phrasesOf(YES_WORDS);
|
|
137
|
+
const NO_PHRASES = phrasesOf(NO_WORDS);
|
|
138
|
+
// Strong ordinals outrank weak cardinals: in "the third one", "one" is a
|
|
139
|
+
// pronoun, not a position, and scoring both equally resolved to option 1.
|
|
140
|
+
const ORDINALS_STRONG = normPairs([
|
|
141
|
+
['first', 0], ['second', 1], ['third', 2], ['fourth', 3], ['fifth', 4], ['sixth', 5],
|
|
142
|
+
['اول', 0], ['الاول', 0], ['الاولي', 0],
|
|
143
|
+
['ثاني', 1], ['الثاني', 1], ['الثانيه', 1],
|
|
144
|
+
['ثالث', 2], ['الثالث', 2], ['الثالثه', 2],
|
|
145
|
+
['رابع', 3], ['الرابع', 3], ['الرابعه', 3],
|
|
146
|
+
['خامس', 4], ['الخامس', 4], ['الخامسه', 4],
|
|
147
|
+
['سادس', 5], ['السادس', 5], ['السادسه', 5],
|
|
148
|
+
]);
|
|
149
|
+
const ORDINALS_WEAK = normPairs([
|
|
150
|
+
['one', 0], ['1', 0], ['two', 1], ['2', 1], ['three', 2], ['3', 2],
|
|
151
|
+
['four', 3], ['4', 3], ['five', 4], ['5', 4], ['six', 5], ['6', 5],
|
|
152
|
+
['واحد', 0], ['اثنين', 1], ['اتنين', 1], ['ثلاثه', 2], ['تلاته', 2], ['اربعه', 3], ['خمسه', 4], ['سته', 5],
|
|
153
|
+
]);
|
|
154
|
+
// Letter shortcuts — Latin by position, Arabic in abjad order.
|
|
155
|
+
const LETTER_INDEX = normObj({ a: 0, b: 1, c: 2, d: 3, e: 4, f: 5, 'ا': 0, 'ب': 1, 'ج': 2, 'د': 3, 'ه': 4, 'و': 5 });
|
|
156
|
+
// Discourse markers stripped before letter/ordinal detection, so "um, I would
|
|
157
|
+
// say C please" reduces to "c". Articles are NOT included — they appear inside
|
|
158
|
+
// real option labels.
|
|
159
|
+
const FILLERS = normList(['um', 'uhm', 'uh', 'er', 'erm', 'hmm', 'mm', 'ah', 'oh', 'like', 'well', 'so',
|
|
160
|
+
'actually', 'basically', 'literally', 'maybe', 'probably', 'definitely', 'honestly', 'personally',
|
|
161
|
+
'please', 'thanks', 'thank you', 'ok', 'okay', 'right',
|
|
162
|
+
'يعني', 'اه', 'ايه', 'طيب', 'اوك', 'من فضلك', 'لو سمحت']);
|
|
163
|
+
const FILLER_PHRASES = [/\bi would say\b/g, /\bi think\b/g, /\bi guess\b/g, /\bi mean\b/g,
|
|
164
|
+
/\blet me say\b/g, /\blets say\b/g, /\byou know\b/g, /\bkind of\b/g, /\bsort of\b/g,
|
|
165
|
+
/\bmy answer is\b/g, /\bthe answer is\b/g, /\bi choose\b/g, /\bi pick\b/g, /\bi select\b/g,
|
|
166
|
+
/\bgo with\b/g, /\bاختار\b/g, /\bاعتقد\b/g, /\bجوابي\b/g];
|
|
167
|
+
function stripFillers(said) {
|
|
168
|
+
let out = said;
|
|
169
|
+
for (const re of FILLER_PHRASES)
|
|
170
|
+
out = out.replace(re, ' ');
|
|
171
|
+
const kept = out.split(/\s+/).filter((t) => t && FILLERS.indexOf(t) === -1);
|
|
172
|
+
return kept.join(' ').trim() || said;
|
|
173
|
+
}
|
|
174
|
+
// Connectors between multiple picks — "the staff and the prices" is two
|
|
175
|
+
// answers, and matching the joined phrase as one string finds neither.
|
|
176
|
+
const CONNECTOR_RE = /\s+(?:and|also|plus|as well as|و|وكذلك|وايضا|ايضا)\s+|\s*[,،]\s*/g;
|
|
177
|
+
function editDistance(a, b) {
|
|
178
|
+
if (a === b)
|
|
179
|
+
return 0;
|
|
180
|
+
if (!a.length)
|
|
181
|
+
return b.length;
|
|
182
|
+
if (!b.length)
|
|
183
|
+
return a.length;
|
|
184
|
+
let prev = [];
|
|
185
|
+
for (let j = 0; j <= b.length; j++)
|
|
186
|
+
prev[j] = j;
|
|
187
|
+
for (let i = 1; i <= a.length; i++) {
|
|
188
|
+
const cur = [i];
|
|
189
|
+
for (let j = 1; j <= b.length; j++) {
|
|
190
|
+
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
|
|
191
|
+
}
|
|
192
|
+
prev = cur;
|
|
193
|
+
}
|
|
194
|
+
return prev[b.length];
|
|
195
|
+
}
|
|
196
|
+
function similarity(a, b) {
|
|
197
|
+
const longest = Math.max(a.length, b.length);
|
|
198
|
+
return longest === 0 ? 1 : 1 - editDistance(a, b) / longest;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Strip the prefix every option shares, so "Option A / Option B / Option C"
|
|
202
|
+
* reduces to "a" / "b" / "c" and a respondent who just says "C" is understood.
|
|
203
|
+
*/
|
|
204
|
+
function distinctiveLabels(labels) {
|
|
205
|
+
if (labels.length < 2)
|
|
206
|
+
return labels.slice();
|
|
207
|
+
const words = labels.map((l) => l.split(' '));
|
|
208
|
+
const shortest = words.reduce((m, w) => Math.min(m, w.length), Infinity);
|
|
209
|
+
let shared = 0;
|
|
210
|
+
while (shared < shortest - 1) {
|
|
211
|
+
let same = true;
|
|
212
|
+
for (let i = 1; i < words.length; i++)
|
|
213
|
+
if (words[i][shared] !== words[0][shared]) {
|
|
214
|
+
same = false;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!same)
|
|
218
|
+
break;
|
|
219
|
+
shared++;
|
|
220
|
+
}
|
|
221
|
+
if (shared === 0)
|
|
222
|
+
return labels.slice();
|
|
223
|
+
const stripped = [];
|
|
224
|
+
for (const w of words) {
|
|
225
|
+
const t = w.slice(shared).join(' ');
|
|
226
|
+
if (!t)
|
|
227
|
+
return labels.slice(); // stripping would empty one — keep them all
|
|
228
|
+
stripped.push(t);
|
|
229
|
+
}
|
|
230
|
+
return stripped;
|
|
231
|
+
}
|
|
232
|
+
const lookupPair = (pairs, token) => {
|
|
233
|
+
for (const [k, v] of pairs)
|
|
234
|
+
if (k === token)
|
|
235
|
+
return v;
|
|
236
|
+
return -1;
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* How strongly does this utterance point at this option? Higher wins.
|
|
240
|
+
* The bands matter more than the numbers: an exact label always beats a
|
|
241
|
+
* distinctive-suffix hit, which always beats a letter or ordinal, which always
|
|
242
|
+
* beats a fuzzy near-miss.
|
|
243
|
+
*/
|
|
244
|
+
function scoreOption(said, label, distinctive, index) {
|
|
245
|
+
if (!said)
|
|
246
|
+
return 0;
|
|
247
|
+
if (said === label)
|
|
248
|
+
return 1000 + label.length;
|
|
249
|
+
if (said === distinctive)
|
|
250
|
+
return 900 + distinctive.length;
|
|
251
|
+
const tokens = stripFillers(said).split(' ');
|
|
252
|
+
if (label && said.indexOf(label) !== -1)
|
|
253
|
+
return 700 + label.length;
|
|
254
|
+
if (distinctive) {
|
|
255
|
+
const hit = distinctive.length >= 3 ? (said.indexOf(distinctive) !== -1) : (tokens.indexOf(distinctive) !== -1);
|
|
256
|
+
if (hit)
|
|
257
|
+
return 600 + distinctive.length;
|
|
258
|
+
}
|
|
259
|
+
if (said.length >= 3 && label.indexOf(said) !== -1)
|
|
260
|
+
return 500 + said.length;
|
|
261
|
+
for (const t of tokens) {
|
|
262
|
+
if (LETTER_INDEX[t] === index)
|
|
263
|
+
return 470;
|
|
264
|
+
if (lookupPair(ORDINALS_STRONG, t) === index)
|
|
265
|
+
return 460;
|
|
266
|
+
}
|
|
267
|
+
for (const t of tokens)
|
|
268
|
+
if (lookupPair(ORDINALS_WEAK, t) === index)
|
|
269
|
+
return 430;
|
|
270
|
+
const target = distinctive || label;
|
|
271
|
+
if (target.length >= 3) {
|
|
272
|
+
// Short labels need a tighter threshold: at 5 characters a single edit is
|
|
273
|
+
// already 20% of the word, and "blue"/"blur" are different answers.
|
|
274
|
+
const threshold = target.length <= 5 ? 0.8 : 0.72;
|
|
275
|
+
const sim = Math.max(similarity(said, target), similarity(said, label));
|
|
276
|
+
if (sim >= threshold)
|
|
277
|
+
return 100 + Math.round(sim * 100);
|
|
278
|
+
}
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
function matchOptionIndex(said, labels, distinctives) {
|
|
282
|
+
let bestIdx = -1, bestScore = 0;
|
|
283
|
+
for (let i = 0; i < labels.length; i++) {
|
|
284
|
+
const sc = scoreOption(said, labels[i], distinctives[i], i);
|
|
285
|
+
if (sc > bestScore) {
|
|
286
|
+
bestScore = sc;
|
|
287
|
+
bestIdx = i;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return bestIdx;
|
|
291
|
+
}
|
|
292
|
+
const NUMBER_WORDS = normObj({
|
|
293
|
+
zero: 0, nought: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10,
|
|
294
|
+
first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7, eighth: 8, ninth: 9, tenth: 10,
|
|
295
|
+
'صفر': 0, 'واحد': 1, 'واحدة': 1, 'اثنان': 2, 'اثنين': 2, 'اتنين': 2, 'ثلاثة': 3, 'ثلاث': 3, 'تلاتة': 3, 'أربعة': 4, 'اربعة': 4,
|
|
296
|
+
'خمسة': 5, 'خمس': 5, 'ستة': 6, 'سبعة': 7, 'سبع': 7, 'ثمانية': 8, 'ثماني': 8, 'تمانية': 8, 'تسعة': 9, 'عشرة': 10, 'عشر': 10,
|
|
297
|
+
});
|
|
298
|
+
// What speech recognition emits INSTEAD of a number. These are ordinary words,
|
|
299
|
+
// so they only apply when the utterance is essentially just the answer.
|
|
300
|
+
const NUMBER_HOMOPHONES = normObj({
|
|
301
|
+
oh: 0, won: 1, wun: 1, to: 2, too: 2, tu: 2, tree: 3, free: 3,
|
|
302
|
+
for: 4, fore: 4, forth: 4, fife: 5, sex: 6, sicks: 6, ate: 8, nein: 9,
|
|
303
|
+
});
|
|
304
|
+
function optionLabelOf(o) {
|
|
305
|
+
return String((o && (o.label != null ? o.label : (o.text != null ? o.text : (o.value != null ? o.value : o.alt)))) || '');
|
|
306
|
+
}
|
|
307
|
+
function optionValueOf(o) {
|
|
308
|
+
return (o && o.value != null) ? o.value : optionLabelOf(o);
|
|
309
|
+
}
|
|
310
|
+
function voiceOptionsOf(q) {
|
|
311
|
+
if (Array.isArray(q.options))
|
|
312
|
+
return q.options;
|
|
313
|
+
if (Array.isArray(q.choices))
|
|
314
|
+
return q.choices;
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
function optionLabelsOf(q) {
|
|
318
|
+
return voiceOptionsOf(q).map(optionLabelOf).filter(Boolean);
|
|
319
|
+
}
|
|
320
|
+
/* ── Matchers — every one returns an index or a primitive, never an answer ── */
|
|
321
|
+
/** Yes/no, including the question's own yes/no labels. `null` = neither. */
|
|
322
|
+
function matchBoolean(q, transcript) {
|
|
323
|
+
const norm = normalizeTranscript(transcript);
|
|
324
|
+
// Multi-word entries can never match token-by-token. Negatives are scanned
|
|
325
|
+
// first: "not really" must not be caught by the positive pass.
|
|
326
|
+
for (const p of NO_PHRASES)
|
|
327
|
+
if (norm.indexOf(p) !== -1)
|
|
328
|
+
return false;
|
|
329
|
+
for (const p of YES_PHRASES)
|
|
330
|
+
if (norm.indexOf(p) !== -1)
|
|
331
|
+
return true;
|
|
332
|
+
const yes = typeof q.yesLabel === 'string' ? normalizeTranscript(q.yesLabel) : '';
|
|
333
|
+
const no = typeof q.noLabel === 'string' ? normalizeTranscript(q.noLabel) : '';
|
|
334
|
+
for (const t of norm.split(' ').filter(Boolean)) {
|
|
335
|
+
if (YES_WORDS.indexOf(t) !== -1)
|
|
336
|
+
return true;
|
|
337
|
+
if (NO_WORDS.indexOf(t) !== -1)
|
|
338
|
+
return false;
|
|
339
|
+
if (yes && t === yes)
|
|
340
|
+
return true;
|
|
341
|
+
if (no && t === no)
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (yes && norm.indexOf(yes) !== -1)
|
|
345
|
+
return true;
|
|
346
|
+
if (no && norm.indexOf(no) !== -1)
|
|
347
|
+
return false;
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
/** Single choice. Returns the option index, or -1 when nothing matched. */
|
|
351
|
+
function matchOption(q, transcript) {
|
|
352
|
+
const options = voiceOptionsOf(q);
|
|
353
|
+
if (!options.length)
|
|
354
|
+
return -1;
|
|
355
|
+
const said = normalizeTranscript(transcript);
|
|
356
|
+
const labels = options.map((o) => normalizeTranscript(optionLabelOf(o)));
|
|
357
|
+
const dist = distinctiveLabels(labels);
|
|
358
|
+
let idx = matchOptionIndex(said, labels, dist);
|
|
359
|
+
if (idx < 0) {
|
|
360
|
+
// Retry per clause — "um, C I think" still scores, and a stray leading
|
|
361
|
+
// filler doesn't sink the match.
|
|
362
|
+
for (const clause of said.split(CONNECTOR_RE)) {
|
|
363
|
+
const c = (clause || '').trim();
|
|
364
|
+
if (!c)
|
|
365
|
+
continue;
|
|
366
|
+
idx = matchOptionIndex(c, labels, dist);
|
|
367
|
+
if (idx >= 0)
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return idx;
|
|
372
|
+
}
|
|
373
|
+
/** Multi-choice. Returns ascending option indices; empty when nothing matched. */
|
|
374
|
+
function matchOptions(q, transcript) {
|
|
375
|
+
const options = voiceOptionsOf(q);
|
|
376
|
+
if (!options.length)
|
|
377
|
+
return [];
|
|
378
|
+
const said = normalizeTranscript(transcript);
|
|
379
|
+
const labels = options.map((o) => normalizeTranscript(optionLabelOf(o)));
|
|
380
|
+
const dist = distinctiveLabels(labels);
|
|
381
|
+
const picked = [];
|
|
382
|
+
const add = (n) => { if (n >= 0 && picked.indexOf(n) === -1)
|
|
383
|
+
picked.push(n); };
|
|
384
|
+
// Clause-driven: "the staff and the prices" is two answers, and scoring the
|
|
385
|
+
// joined phrase as one string finds neither.
|
|
386
|
+
for (const clause of said.split(CONNECTOR_RE)) {
|
|
387
|
+
const c = (clause || '').trim();
|
|
388
|
+
if (c)
|
|
389
|
+
add(matchOptionIndex(c, labels, dist));
|
|
390
|
+
}
|
|
391
|
+
// Then sweep for labels mentioned outright, catching picks the connector
|
|
392
|
+
// split missed. Literal containment ONLY here — a fuzzy near-miss must not
|
|
393
|
+
// be able to over-select, because an unwanted extra pick is worse than a
|
|
394
|
+
// missed one when the respondent can't see what was recorded.
|
|
395
|
+
for (let i = 0; i < labels.length; i++) {
|
|
396
|
+
const target = dist[i] || labels[i];
|
|
397
|
+
if (target.length >= 3 && said.indexOf(target) !== -1)
|
|
398
|
+
add(i);
|
|
399
|
+
else if (labels[i].length >= 3 && said.indexOf(labels[i]) !== -1)
|
|
400
|
+
add(i);
|
|
401
|
+
}
|
|
402
|
+
return picked.sort((a, b) => a - b);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Ranking. Returns option indices in the order they were spoken, or an empty
|
|
406
|
+
* array when fewer than two were heard — one name is not a ranking.
|
|
407
|
+
*/
|
|
408
|
+
function rankOptions(q, transcript) {
|
|
409
|
+
const options = voiceOptionsOf(q);
|
|
410
|
+
if (!options.length)
|
|
411
|
+
return [];
|
|
412
|
+
const norm = normalizeTranscript(transcript);
|
|
413
|
+
const found = [];
|
|
414
|
+
for (let i = 0; i < options.length; i++) {
|
|
415
|
+
const nl = normalizeTranscript(optionLabelOf(options[i]));
|
|
416
|
+
if (!nl)
|
|
417
|
+
continue;
|
|
418
|
+
const pos = norm.indexOf(nl);
|
|
419
|
+
if (pos >= 0)
|
|
420
|
+
found.push({ i, pos });
|
|
421
|
+
}
|
|
422
|
+
if (found.length < 2)
|
|
423
|
+
return [];
|
|
424
|
+
found.sort((a, b) => a.pos - b.pos);
|
|
425
|
+
return found.map((f) => f.i);
|
|
426
|
+
}
|
|
427
|
+
/** A number inside [min, max], from digits, number words, or a homophone. */
|
|
428
|
+
function matchNumberInRange(transcript, min, max) {
|
|
429
|
+
// Arabic-Indic and Extended Arabic-Indic digits fold to ASCII first —
|
|
430
|
+
// common when speech recognition transcribes spoken numbers in the native
|
|
431
|
+
// script.
|
|
432
|
+
const ascii = String(transcript)
|
|
433
|
+
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)))
|
|
434
|
+
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)));
|
|
435
|
+
const norm = normalizeTranscript(ascii);
|
|
436
|
+
// Every digit run in order, so "5 out of 10" on a 0-10 scale lands on 5
|
|
437
|
+
// rather than falling through to the word table.
|
|
438
|
+
const digits = norm.match(/\d+(?:\.\d+)?/g);
|
|
439
|
+
if (digits)
|
|
440
|
+
for (const m of digits) {
|
|
441
|
+
const n = Math.round(Number(m));
|
|
442
|
+
if (n >= min && n <= max)
|
|
443
|
+
return n;
|
|
444
|
+
}
|
|
445
|
+
// Word forms matched as whole tokens, never substrings — otherwise
|
|
446
|
+
// "fourteen" matches "four".
|
|
447
|
+
for (const t of norm.split(/\s+/).filter(Boolean)) {
|
|
448
|
+
const n = NUMBER_WORDS[t];
|
|
449
|
+
if (n !== undefined && n >= min && n <= max)
|
|
450
|
+
return n;
|
|
451
|
+
}
|
|
452
|
+
// Homophone rescue, short utterances only: a bare "for" on a 0-10 scale was
|
|
453
|
+
// almost certainly "four"; "for" inside a sentence is a preposition.
|
|
454
|
+
const core = stripFillers(norm).split(/\s+/).filter(Boolean);
|
|
455
|
+
if (core.length <= 2) {
|
|
456
|
+
for (const t of core) {
|
|
457
|
+
const n = NUMBER_HOMOPHONES[t];
|
|
458
|
+
if (n !== undefined && n >= min && n <= max)
|
|
459
|
+
return n;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
/** A spoken date as ISO `YYYY-MM-DD`, or null. */
|
|
465
|
+
function matchDate(transcript) {
|
|
466
|
+
const raw = String(transcript).trim();
|
|
467
|
+
// Strip filler words and ordinal suffixes ("January 5th" → "January 5") that
|
|
468
|
+
// trip the Date constructor; speech engines produce them constantly.
|
|
469
|
+
const cleaned = raw.replace(/\b(the|of|on|at)\b/gi, ' ').replace(/(\d+)(st|nd|rd|th)\b/gi, '$1').replace(/\s+/g, ' ').trim();
|
|
470
|
+
for (const c of [raw, cleaned, raw.replace(/(\d+)(st|nd|rd|th)\b/gi, '$1')]) {
|
|
471
|
+
if (!c)
|
|
472
|
+
continue;
|
|
473
|
+
const d = new Date(c);
|
|
474
|
+
if (!isNaN(d.getTime())) {
|
|
475
|
+
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
476
|
+
const day = String(d.getDate()).padStart(2, '0');
|
|
477
|
+
return `${d.getFullYear()}-${m}-${day}`;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
function voiceSupport(type) {
|
|
483
|
+
switch (String(type || '').toLowerCase()) {
|
|
484
|
+
case 'text':
|
|
485
|
+
case 'textbox':
|
|
486
|
+
case 'shorttext':
|
|
487
|
+
case 'comment':
|
|
488
|
+
case 'textarea':
|
|
489
|
+
case 'longtext':
|
|
490
|
+
case 'binary':
|
|
491
|
+
case 'boolean':
|
|
492
|
+
case 'yesno':
|
|
493
|
+
case 'yes_no':
|
|
494
|
+
case 'radio':
|
|
495
|
+
case 'radiogroup':
|
|
496
|
+
case 'dropdown':
|
|
497
|
+
case 'select':
|
|
498
|
+
case 'rating':
|
|
499
|
+
case 'nps':
|
|
500
|
+
case 'checkbox':
|
|
501
|
+
case 'imagepicker':
|
|
502
|
+
case 'image_picker':
|
|
503
|
+
case 'ranking':
|
|
504
|
+
case 'date':
|
|
505
|
+
return 'full';
|
|
506
|
+
case 'matrix':
|
|
507
|
+
return 'matrix';
|
|
508
|
+
case 'content':
|
|
509
|
+
case 'html':
|
|
510
|
+
case 'image':
|
|
511
|
+
case 'expression':
|
|
512
|
+
case 'pagebreak':
|
|
513
|
+
case 'page_break':
|
|
514
|
+
return 'read';
|
|
515
|
+
case 'multitext':
|
|
516
|
+
case 'multi_text':
|
|
517
|
+
case 'multipletext':
|
|
518
|
+
case 'matrixdropdown':
|
|
519
|
+
case 'matrix_dropdown':
|
|
520
|
+
case 'matrixdynamic':
|
|
521
|
+
case 'matrix_dynamic':
|
|
522
|
+
return 'assist';
|
|
523
|
+
case 'paneldynamic':
|
|
524
|
+
case 'panel_dynamic':
|
|
525
|
+
case 'signature':
|
|
526
|
+
case 'fileupload':
|
|
527
|
+
case 'file_upload':
|
|
528
|
+
case 'file':
|
|
529
|
+
return 'none';
|
|
530
|
+
default:
|
|
531
|
+
return 'none';
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** Can a voice-only session get through this question unaided? */
|
|
535
|
+
function voiceCanHandle(type) {
|
|
536
|
+
const s = voiceSupport(type);
|
|
537
|
+
return s === 'full' || s === 'matrix' || s === 'read';
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* blocking = required questions voice can't handle → the session must not start.
|
|
541
|
+
* skippable = optional ones → announced and skipped.
|
|
542
|
+
*/
|
|
543
|
+
function auditVoiceQuestions(questions) {
|
|
544
|
+
const blocking = [], skippable = [];
|
|
545
|
+
for (const q of (Array.isArray(questions) ? questions : [])) {
|
|
546
|
+
if (!q)
|
|
547
|
+
continue;
|
|
548
|
+
const t = String(q.type || '').toLowerCase();
|
|
549
|
+
if (t === 'pagebreak' || t === 'page_break')
|
|
550
|
+
continue;
|
|
551
|
+
if (voiceCanHandle(String(q.type || '')))
|
|
552
|
+
continue;
|
|
553
|
+
if (q.isRequired || q.required)
|
|
554
|
+
blocking.push(q);
|
|
555
|
+
else
|
|
556
|
+
skippable.push(q);
|
|
557
|
+
}
|
|
558
|
+
return { blocking, skippable };
|
|
559
|
+
}
|
|
560
|
+
const SCAFFOLDS = {
|
|
561
|
+
en: {
|
|
562
|
+
scale: (min, max, minL, maxL) => {
|
|
563
|
+
const r = `Answer with a number from ${min} to ${max}`;
|
|
564
|
+
return (minL && maxL) ? `${r}. ${min} means ${minL}, ${max} means ${maxL}.` : `${r}.`;
|
|
565
|
+
},
|
|
566
|
+
binary: (y, n) => `Please answer ${y || 'yes'} or ${n || 'no'}.`,
|
|
567
|
+
options: (o) => `Options are: ${o.join(', ')}.`,
|
|
568
|
+
multi: (o) => `Multiple choices allowed. Options are: ${o.join(', ')}. Say the ones that apply.`,
|
|
569
|
+
ranking: (o) => `Rank in order of preference: ${o.join(', ')}.`,
|
|
570
|
+
date: () => 'Please say the date.',
|
|
571
|
+
matrixStem: (stem) => `${stem}. I’ll ask you row by row.`,
|
|
572
|
+
matrixRow: (row, cols) => `${row}. Options: ${cols.join(', ')}.`,
|
|
573
|
+
},
|
|
574
|
+
ar: {
|
|
575
|
+
scale: (min, max, minL, maxL) => {
|
|
576
|
+
const r = `أجب برقم من ${min} إلى ${max}`;
|
|
577
|
+
return (minL && maxL) ? `${r}. ${min} يعني ${minL}، ${max} يعني ${maxL}.` : `${r}.`;
|
|
578
|
+
},
|
|
579
|
+
binary: (y, n) => `يرجى الإجابة ${y || 'نعم'} أو ${n || 'لا'}.`,
|
|
580
|
+
options: (o) => `الخيارات: ${o.join('، ')}.`,
|
|
581
|
+
multi: (o) => `يمكن اختيار أكثر من إجابة. الخيارات: ${o.join('، ')}. قل الخيارات التي تنطبق.`,
|
|
582
|
+
ranking: (o) => `رتب حسب الأفضلية: ${o.join('، ')}.`,
|
|
583
|
+
date: () => 'يرجى قول التاريخ.',
|
|
584
|
+
matrixStem: (stem) => `${stem}. سأسألك عن كل صف على حدة.`,
|
|
585
|
+
matrixRow: (row, cols) => `${row}. الخيارات: ${cols.join('، ')}.`,
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
function scaffoldFor(lang) {
|
|
589
|
+
return SCAFFOLDS[shortCode(lang)] || SCAFFOLDS.en;
|
|
590
|
+
}
|
|
591
|
+
function voiceDisclaimer(n, lang) {
|
|
592
|
+
if (shortCode(lang) === 'ar')
|
|
593
|
+
return 'ملاحظة: ' + n + (n > 1 ? ' أسئلة' : ' سؤال') + ' لا يمكن الإجابة عليها صوتياً وسيتم تخطيها.';
|
|
594
|
+
return 'Heads up — ' + n + ' question' + (n > 1 ? 's' : '') + " can't be answered by voice and will be skipped.";
|
|
595
|
+
}
|
|
596
|
+
function skipNoteText(lang) {
|
|
597
|
+
if (shortCode(lang) === 'ar')
|
|
598
|
+
return 'هذا السؤال يحتاج إدخالاً يدوياً — سيتم تخطيه.';
|
|
599
|
+
return 'This one needs manual input — skipping it.';
|
|
600
|
+
}
|
|
601
|
+
/** What the session says out loud to ask this question. */
|
|
602
|
+
function buildPromptText(q, lang) {
|
|
603
|
+
const s = scaffoldFor(lang);
|
|
604
|
+
const base = String(q.title || q.text || q.label || '').replace(/<[^>]*>/g, '').trim();
|
|
605
|
+
const type = String(q.type || '').toLowerCase();
|
|
606
|
+
if (['binary', 'boolean', 'yesno', 'yes_no'].indexOf(type) !== -1) {
|
|
607
|
+
return `${base}. ${s.binary(typeof q.yesLabel === 'string' ? q.yesLabel : '', typeof q.noLabel === 'string' ? q.noLabel : '')}`;
|
|
608
|
+
}
|
|
609
|
+
if (type === 'rating') {
|
|
610
|
+
return `${base}. ${s.scale(typeof q.minValue === 'number' ? q.minValue : 1, typeof q.maxValue === 'number' ? q.maxValue : 5, typeof q.minLabel === 'string' ? q.minLabel : undefined, typeof q.maxLabel === 'string' ? q.maxLabel : undefined)}`;
|
|
611
|
+
}
|
|
612
|
+
if (type === 'nps') {
|
|
613
|
+
// Always 0-10 regardless of the question's own min/max — every NPS
|
|
614
|
+
// renderer hardcodes that scale.
|
|
615
|
+
return `${base}. ${s.scale(0, 10, typeof q.minLabel === 'string' ? q.minLabel : 'Not at all likely', typeof q.maxLabel === 'string' ? q.maxLabel : 'Extremely likely')}`;
|
|
616
|
+
}
|
|
617
|
+
if (['radio', 'radiogroup', 'dropdown', 'select', 'imagepicker', 'image_picker'].indexOf(type) !== -1) {
|
|
618
|
+
const o = optionLabelsOf(q);
|
|
619
|
+
return o.length ? `${base}. ${s.options(o)}` : base;
|
|
620
|
+
}
|
|
621
|
+
if (type === 'checkbox') {
|
|
622
|
+
const o = optionLabelsOf(q);
|
|
623
|
+
return o.length ? `${base}. ${s.multi(o)}` : base;
|
|
624
|
+
}
|
|
625
|
+
if (type === 'ranking') {
|
|
626
|
+
const o = optionLabelsOf(q);
|
|
627
|
+
return o.length ? `${base}. ${s.ranking(o)}` : base;
|
|
628
|
+
}
|
|
629
|
+
if (type === 'date')
|
|
630
|
+
return `${base}. ${s.date()}`;
|
|
631
|
+
return base;
|
|
632
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cxpinsight/survey-spec",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The survey specification the renderers share
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "The survey specification the renderers share \u2014 expression evaluation first. Source of truth for behaviour that must be identical on web, links and mobile.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"files": [
|