@esheet/adapters 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,698 @@
1
+ /**
2
+ * Convert SurveyJS schema to eSheet FormDefinition.
3
+ *
4
+ * SurveyJS is well-known with lots of training data, so AI models
5
+ * generate better SurveyJS output. We leverage this by having AI
6
+ * generate SurveyJS, then converting to eSheet format.
7
+ */
8
+ // ---------------------------------------------------------------------------
9
+ // Type Mapping
10
+ // ---------------------------------------------------------------------------
11
+ const TYPE_MAP = {
12
+ text: 'text',
13
+ comment: 'longtext',
14
+ radiogroup: 'radio',
15
+ checkbox: 'check',
16
+ dropdown: 'dropdown',
17
+ tagbox: 'multiselectdropdown',
18
+ boolean: 'boolean',
19
+ rating: 'rating',
20
+ ranking: 'ranking',
21
+ matrix: 'singlematrix',
22
+ matrixdropdown: 'multimatrix',
23
+ matrixdynamic: 'multimatrix',
24
+ signaturepad: 'signature',
25
+ image: 'image',
26
+ html: 'html',
27
+ expression: 'html',
28
+ imagepicker: 'radio',
29
+ file: 'text',
30
+ panel: 'section',
31
+ paneldynamic: 'section',
32
+ multipletext: 'multitext',
33
+ };
34
+ const INPUT_TYPE_MAP = {
35
+ // Direct matches
36
+ text: 'string',
37
+ string: 'string',
38
+ number: 'number',
39
+ email: 'email',
40
+ tel: 'tel',
41
+ date: 'date',
42
+ 'datetime-local': 'datetime-local',
43
+ month: 'month',
44
+ time: 'time',
45
+ url: 'url',
46
+ // SurveyJS aliases
47
+ color: 'string',
48
+ password: 'string',
49
+ range: 'number',
50
+ week: 'date',
51
+ datetime: 'datetime-local',
52
+ datetimelocal: 'datetime-local',
53
+ };
54
+ function mapInputType(surveyInputType) {
55
+ if (typeof surveyInputType !== 'string')
56
+ return 'string';
57
+ const normalized = surveyInputType.toLowerCase().replace(/[_\s]/g, '');
58
+ return INPUT_TYPE_MAP[normalized] ?? 'string';
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Converters
62
+ // ---------------------------------------------------------------------------
63
+ function toKebabCase(str) {
64
+ if (typeof str !== 'string')
65
+ return '';
66
+ return str
67
+ .replace(/([a-z])([A-Z])/g, '$1-$2')
68
+ .replace(/[\s_]+/g, '-')
69
+ .toLowerCase();
70
+ }
71
+ function convertChoice(choice, index) {
72
+ if (typeof choice === 'string') {
73
+ return { id: toKebabCase(choice) || `opt-${index}`, value: choice };
74
+ }
75
+ return {
76
+ id: toKebabCase(choice.value) || `opt-${index}`,
77
+ value: choice.value,
78
+ text: choice.text,
79
+ score: choice.score,
80
+ };
81
+ }
82
+ function convertMatrixRow(item, index) {
83
+ if (typeof item === 'string') {
84
+ return { id: toKebabCase(item) || `item-${index}`, value: item };
85
+ }
86
+ return {
87
+ id: toKebabCase(item.value) || `item-${index}`,
88
+ value: item.text ?? item.value,
89
+ };
90
+ }
91
+ function convertMatrixColumn(item, index) {
92
+ if (typeof item === 'string') {
93
+ return { id: toKebabCase(item) || `item-${index}`, value: item };
94
+ }
95
+ return {
96
+ id: toKebabCase(item.value) || `item-${index}`,
97
+ value: item.text ?? item.value,
98
+ score: item.score,
99
+ };
100
+ }
101
+ /**
102
+ * Parse SurveyJS visibleIf expression into eSheet rules.
103
+ *
104
+ * Simple expressions like `{gender} = 'female'` become field conditions.
105
+ * Complex expressions with arithmetic or multiple fields become expression conditions.
106
+ */
107
+ function parseVisibleIf(expr, effect) {
108
+ if (typeof expr !== 'string')
109
+ return null;
110
+ const trimmed = expr.trim();
111
+ if (!trimmed)
112
+ return null;
113
+ // Count field references - if more than one, it's a complex expression
114
+ const fieldRefs = trimmed.match(/\{[^}]+\}/g) ?? [];
115
+ // Check for arithmetic operators between field refs
116
+ const hasArithmetic = /\{[^}]+\}\s*[+\-*/]\s*\{/.test(trimmed);
117
+ // Simple pattern: {fieldName} op 'value' (single field, no arithmetic)
118
+ if (fieldRefs.length === 1 && !hasArithmetic) {
119
+ // Note: >= and <= must come before > and < in the alternation (regex is first-match)
120
+ const simpleMatch = trimmed.match(/^\{([^}]+)\}\s*(==|!=|<>|>=|<=|=|>|<|empty|notempty)\s*'?([^']*)'?$/i);
121
+ if (simpleMatch) {
122
+ const [, targetId, op, rawExpected] = simpleMatch;
123
+ const expected = rawExpected.trim();
124
+ const operatorMap = {
125
+ '==': 'equals',
126
+ '!=': 'notEquals',
127
+ '<>': 'notEquals',
128
+ '>=': 'greaterThanOrEqual',
129
+ '<=': 'lessThanOrEqual',
130
+ '=': 'equals',
131
+ '>': 'greaterThan',
132
+ '<': 'lessThan',
133
+ empty: 'empty',
134
+ notempty: 'notEmpty',
135
+ };
136
+ const operator = operatorMap[op.toLowerCase()] ?? 'equals';
137
+ // Normalize expected value for boolean comparisons
138
+ // SurveyJS uses true/false, eSheet uses yes/no option IDs
139
+ let normalizedExpected = expected;
140
+ if (operator === 'empty' || operator === 'notEmpty') {
141
+ normalizedExpected = '';
142
+ }
143
+ else if (expected.toLowerCase() === 'true') {
144
+ normalizedExpected = 'yes';
145
+ }
146
+ else if (expected.toLowerCase() === 'false') {
147
+ normalizedExpected = 'no';
148
+ }
149
+ const condition = {
150
+ conditionType: 'field',
151
+ targetId: toKebabCase(targetId),
152
+ operator,
153
+ expected: normalizedExpected,
154
+ };
155
+ return {
156
+ effect,
157
+ logic: 'AND',
158
+ conditions: [condition],
159
+ };
160
+ }
161
+ }
162
+ // Complex expression: use expression condition type
163
+ // Convert SurveyJS field syntax {fieldName} to eSheet syntax
164
+ const eSheetExpr = trimmed.replace(/\{([^}]+)\}/g, (_, fieldName) => `{${toKebabCase(fieldName)}}`);
165
+ return {
166
+ effect,
167
+ logic: 'AND',
168
+ conditions: [
169
+ {
170
+ conditionType: 'expression',
171
+ expression: eSheetExpr,
172
+ },
173
+ ],
174
+ };
175
+ }
176
+ function convertElement(element, ancestorIds = new Set()) {
177
+ const fieldType = TYPE_MAP[element.type] ?? 'text';
178
+ const id = toKebabCase(element.name);
179
+ // Preserve original SurveyJS metadata for lossless round-trip export.
180
+ const _sourceData = {
181
+ surveyType: element.type,
182
+ surveyName: element.name,
183
+ ...(element.visibleIf ? { visibleIf: element.visibleIf } : {}),
184
+ ...(element.enableIf ? { enableIf: element.enableIf } : {}),
185
+ ...(element.requiredIf ? { requiredIf: element.requiredIf } : {}),
186
+ ...(element.placeholder !== undefined
187
+ ? { placeholder: element.placeholder }
188
+ : {}),
189
+ ...(element.defaultValue !== undefined
190
+ ? { defaultValue: element.defaultValue }
191
+ : {}),
192
+ ...(element.defaultValueExpression !== undefined
193
+ ? { defaultValueExpression: element.defaultValueExpression }
194
+ : {}),
195
+ ...(element.min !== undefined ? { min: element.min } : {}),
196
+ ...(element.max !== undefined ? { max: element.max } : {}),
197
+ ...(element.step !== undefined ? { step: element.step } : {}),
198
+ ...(element.rateMin !== undefined ? { rateMin: element.rateMin } : {}),
199
+ ...(element.rateMax !== undefined ? { rateMax: element.rateMax } : {}),
200
+ ...(element.rateStep !== undefined ? { rateStep: element.rateStep } : {}),
201
+ ...(element.minRateDescription !== undefined
202
+ ? { minRateDescription: element.minRateDescription }
203
+ : {}),
204
+ ...(element.maxRateDescription !== undefined
205
+ ? { maxRateDescription: element.maxRateDescription }
206
+ : {}),
207
+ ...(element.showOtherItem !== undefined
208
+ ? { showOtherItem: element.showOtherItem }
209
+ : {}),
210
+ ...(element.otherText !== undefined
211
+ ? { otherText: element.otherText }
212
+ : {}),
213
+ ...(element.showNoneItem !== undefined
214
+ ? { showNoneItem: element.showNoneItem }
215
+ : {}),
216
+ ...(element.noneText !== undefined ? { noneText: element.noneText } : {}),
217
+ ...(element.showSelectAllItem !== undefined
218
+ ? { showSelectAllItem: element.showSelectAllItem }
219
+ : {}),
220
+ ...(element.selectAllText !== undefined
221
+ ? { selectAllText: element.selectAllText }
222
+ : {}),
223
+ ...(element.storeOthersAsComment !== undefined
224
+ ? { storeOthersAsComment: element.storeOthersAsComment }
225
+ : {}),
226
+ ...(element.colCount !== undefined ? { colCount: element.colCount } : {}),
227
+ ...(element.allowMultiple !== undefined
228
+ ? { allowMultiple: element.allowMultiple }
229
+ : {}),
230
+ ...(element.acceptedTypes !== undefined
231
+ ? { acceptedTypes: element.acceptedTypes }
232
+ : {}),
233
+ };
234
+ // Build rules from visibleIf/enableIf/requiredIf
235
+ const rules = [];
236
+ if (element.visibleIf) {
237
+ const rule = parseVisibleIf(element.visibleIf, 'visible');
238
+ if (rule)
239
+ rules.push(rule);
240
+ }
241
+ if (element.enableIf) {
242
+ const rule = parseVisibleIf(element.enableIf, 'enable');
243
+ if (rule)
244
+ rules.push(rule);
245
+ }
246
+ if (element.requiredIf) {
247
+ const rule = parseVisibleIf(element.requiredIf, 'required');
248
+ if (rule)
249
+ rules.push(rule);
250
+ }
251
+ // Base field
252
+ const base = {
253
+ id,
254
+ question: element.title ?? element.name,
255
+ required: element.isRequired ?? false,
256
+ rules,
257
+ _sourceData,
258
+ };
259
+ // Type-specific properties
260
+ switch (fieldType) {
261
+ case 'text':
262
+ return {
263
+ ...base,
264
+ fieldType: 'text',
265
+ inputType: mapInputType(element.inputType),
266
+ };
267
+ case 'longtext':
268
+ return {
269
+ ...base,
270
+ fieldType: 'longtext',
271
+ inputType: 'string',
272
+ };
273
+ case 'radio':
274
+ case 'dropdown':
275
+ case 'check':
276
+ case 'multiselectdropdown':
277
+ return {
278
+ ...base,
279
+ fieldType,
280
+ options: (element.choices ?? []).map(convertChoice),
281
+ };
282
+ case 'boolean':
283
+ return {
284
+ ...base,
285
+ fieldType: 'boolean',
286
+ options: [
287
+ { id: 'yes', value: 'Yes' },
288
+ { id: 'no', value: 'No' },
289
+ ],
290
+ };
291
+ case 'rating': {
292
+ let options;
293
+ if (element.choices && element.choices.length > 0) {
294
+ options = element.choices.map(convertChoice);
295
+ }
296
+ else {
297
+ // Generate from rateMin/rateMax/rateStep
298
+ const min = typeof element.rateMin === 'number' ? element.rateMin : 1;
299
+ const max = typeof element.rateMax === 'number' ? element.rateMax : 5;
300
+ const step = typeof element.rateStep === 'number' && element.rateStep > 0
301
+ ? element.rateStep
302
+ : 1;
303
+ options = [];
304
+ for (let n = min; n <= max; n += step) {
305
+ options.push({ id: String(n), value: String(n) });
306
+ }
307
+ }
308
+ return { ...base, fieldType: 'rating', options };
309
+ }
310
+ case 'ranking':
311
+ return {
312
+ ...base,
313
+ fieldType: 'ranking',
314
+ options: (element.choices ?? []).map(convertChoice),
315
+ };
316
+ case 'singlematrix':
317
+ case 'multimatrix': {
318
+ const rows = (element.rows ?? []).map(convertMatrixRow);
319
+ const columns = (element.columns ?? []).map(convertMatrixColumn);
320
+ // Check if any column has an explicit score
321
+ const hasScores = columns.some((col) => col.score !== undefined);
322
+ return {
323
+ ...base,
324
+ fieldType,
325
+ rows,
326
+ columns,
327
+ // Enable scored mode if any column has scores
328
+ ...(hasScores ? { scored: true } : {}),
329
+ };
330
+ }
331
+ case 'signature':
332
+ return {
333
+ ...base,
334
+ fieldType: 'signature',
335
+ };
336
+ case 'image':
337
+ return {
338
+ ...base,
339
+ fieldType: 'image',
340
+ imageUri: element.imageLink ?? '',
341
+ altText: element.altText,
342
+ };
343
+ case 'html':
344
+ return {
345
+ ...base,
346
+ fieldType: 'html',
347
+ // 'expression' type: render the expression string as HTML placeholder
348
+ htmlContent: element.type === 'expression'
349
+ ? element.expression ?? ''
350
+ : element.html ?? '',
351
+ };
352
+ case 'multitext':
353
+ return {
354
+ ...base,
355
+ fieldType: 'multitext',
356
+ options: (element.items ?? []).map((item, idx) => ({
357
+ id: toKebabCase(item.name) || `item-${idx}`,
358
+ value: item.name,
359
+ text: item.title ?? item.name,
360
+ })),
361
+ };
362
+ case 'section': {
363
+ // Guard against circular nesting (AI can generate panels referencing ancestor IDs)
364
+ if (ancestorIds.has(id)) {
365
+ return { ...base, fieldType: 'text', inputType: 'string' };
366
+ }
367
+ const childAncestors = new Set(ancestorIds).add(id);
368
+ return {
369
+ ...base,
370
+ fieldType: 'section',
371
+ title: element.title ?? element.name,
372
+ fields: (element.elements ?? []).map((el) => convertElement(el, childAncestors)),
373
+ };
374
+ }
375
+ default:
376
+ return {
377
+ ...base,
378
+ fieldType: 'text',
379
+ inputType: 'string',
380
+ };
381
+ }
382
+ }
383
+ /**
384
+ * Convert a SurveyJS schema to eSheet FormDefinition.
385
+ */
386
+ export function convertSurveyJSToESheet(survey) {
387
+ const fields = [];
388
+ // Collect survey-level metadata for round-trip fidelity
389
+ const surveyMeta = {};
390
+ if (survey.locale !== undefined)
391
+ surveyMeta.locale = survey.locale;
392
+ if (survey.logo !== undefined)
393
+ surveyMeta.logo = survey.logo;
394
+ if (survey.logoPosition !== undefined)
395
+ surveyMeta.logoPosition = survey.logoPosition;
396
+ if (survey.showProgressBar !== undefined)
397
+ surveyMeta.showProgressBar = survey.showProgressBar;
398
+ if (survey.progressBarType !== undefined)
399
+ surveyMeta.progressBarType = survey.progressBarType;
400
+ if (survey.completedHtml !== undefined)
401
+ surveyMeta.completedHtml = survey.completedHtml;
402
+ if (survey.showQuestionNumbers !== undefined)
403
+ surveyMeta.showQuestionNumbers = survey.showQuestionNumbers;
404
+ if (survey.questionTitleLocation !== undefined)
405
+ surveyMeta.questionTitleLocation = survey.questionTitleLocation;
406
+ if (survey.calculatedValues !== undefined)
407
+ surveyMeta.calculatedValues = survey.calculatedValues;
408
+ if (survey.triggers !== undefined)
409
+ surveyMeta.triggers = survey.triggers;
410
+ const hasSurveyMeta = Object.keys(surveyMeta).length > 0;
411
+ // Handle pages (convert each page to a section)
412
+ if (survey.pages && survey.pages.length > 0) {
413
+ for (const page of survey.pages) {
414
+ if (!page.elements || page.elements.length === 0)
415
+ continue;
416
+ // If only one page, flatten elements directly
417
+ if (survey.pages.length === 1) {
418
+ fields.push(...page.elements.map((el) => convertElement(el)));
419
+ }
420
+ else {
421
+ // Multiple pages become sections
422
+ const sectionId = toKebabCase(page.name ?? page.title ?? 'page');
423
+ const sectionAncestors = new Set([sectionId]);
424
+ fields.push({
425
+ id: sectionId,
426
+ fieldType: 'section',
427
+ question: '',
428
+ required: false,
429
+ rules: [],
430
+ title: page.title ?? page.name ?? 'Section',
431
+ fields: page.elements.map((el) => convertElement(el, sectionAncestors)),
432
+ });
433
+ }
434
+ }
435
+ }
436
+ // Handle top-level elements (no pages)
437
+ if (survey.elements) {
438
+ fields.push(...survey.elements.map((el) => convertElement(el)));
439
+ }
440
+ return {
441
+ id: toKebabCase(survey.title ?? 'form'),
442
+ title: survey.title ?? 'Untitled Form',
443
+ description: survey.description,
444
+ fields,
445
+ ...(hasSurveyMeta ? { _sourceData: { surveyMeta } } : {}),
446
+ };
447
+ }
448
+ /**
449
+ * System prompt for generating SurveyJS format.
450
+ * AI models have better training data for SurveyJS.
451
+ */
452
+ export const SURVEYJS_SYSTEM_PROMPT = `You are an expert form designer. Output ONLY a valid SurveyJS JSON schema — no markdown, no explanation, no code blocks, raw JSON only. STRUCTURE: {"title":"...","pages":[{"name":"page1","title":"...","elements":[...]}]} FIELD TYPES — always pick most specific: short text→text (add inputType:"email"/"tel"/"date"/"number"/"url"), long text→comment, single choice→radiogroup (NOT text), multiple choice→checkbox (NOT text), long list single→dropdown, long list multi→tagbox, yes/no→boolean (NOT radiogroup), 1-N scale→rating (set rateMin/rateMax/minRateDescription/maxRateDescription), ordered preference→ranking, grid single answer→matrix (rows=items columns=scale labels), grid multi answer→matrixdropdown, signature→signaturepad (NEVER use text), file upload→file, image pick→imagepicker, multiple short inputs→multipletext. RULES: use pages only to group related fields — never use panel type; every page must have a title; add "isRequired":true on important fields; use "visibleIf" for follow-up questions: "{field} = 'value'"; choices format: [{"value":"id","text":"Label"}]. For scored surveys: use numeric strings as column values ("0","1","2","3"), add calculatedValues at top level. OUTPUT: raw JSON only, no prose.`;
453
+ // ---------------------------------------------------------------------------
454
+ // Export: FormDefinition → SurveyJS schema
455
+ // ---------------------------------------------------------------------------
456
+ const FIELD_TYPE_TO_SURVEY = {
457
+ text: 'text',
458
+ longtext: 'comment',
459
+ radio: 'radiogroup',
460
+ check: 'checkbox',
461
+ dropdown: 'dropdown',
462
+ multiselectdropdown: 'tagbox',
463
+ boolean: 'boolean',
464
+ rating: 'rating',
465
+ ranking: 'ranking',
466
+ singlematrix: 'matrix',
467
+ multimatrix: 'matrixdropdown',
468
+ signature: 'signaturepad',
469
+ image: 'image',
470
+ html: 'html',
471
+ multitext: 'multipletext',
472
+ section: 'panel',
473
+ };
474
+ const ESHEET_INPUT_TO_SURVEY = {
475
+ string: 'text',
476
+ number: 'number',
477
+ email: 'email',
478
+ tel: 'tel',
479
+ date: 'date',
480
+ 'datetime-local': 'datetime-local',
481
+ month: 'month',
482
+ time: 'time',
483
+ url: 'url',
484
+ };
485
+ const OPERATOR_TO_SURVEY = {
486
+ equals: '=',
487
+ notEquals: '<>',
488
+ greaterThan: '>',
489
+ lessThan: '<',
490
+ greaterThanOrEqual: '>=',
491
+ lessThanOrEqual: '<=',
492
+ empty: 'empty',
493
+ notEmpty: 'notempty',
494
+ };
495
+ function conditionToSurveyExpr(condition) {
496
+ if (condition.conditionType === 'expression') {
497
+ return condition.expression ?? '';
498
+ }
499
+ const op = OPERATOR_TO_SURVEY[condition.operator ?? 'equals'] ?? '=';
500
+ if (op === 'empty' || op === 'notempty') {
501
+ return `{${condition.targetId}} ${op}`;
502
+ }
503
+ return `{${condition.targetId}} ${op} '${condition.expected}'`;
504
+ }
505
+ function ruleToSurveyExpr(rule) {
506
+ const sep = rule.logic === 'OR' ? ' or ' : ' and ';
507
+ return rule.conditions.map(conditionToSurveyExpr).join(sep);
508
+ }
509
+ function fieldToSurveyElement(field) {
510
+ const meta = field._sourceData;
511
+ // Restore original SurveyJS name if available; otherwise convert id back
512
+ const name = meta?.surveyName ?? field.id;
513
+ // Restore original SurveyJS type if available; otherwise use reverse map
514
+ const type = meta?.surveyType ?? FIELD_TYPE_TO_SURVEY[field.fieldType] ?? 'text';
515
+ const el = { type, name };
516
+ if (field.question !== undefined)
517
+ el.title = field.question;
518
+ if (field.required)
519
+ el.isRequired = true;
520
+ // Restore conditional expressions verbatim from _sourceData for lossless round-trip.
521
+ // Fall back to re-constructing from parsed rules.
522
+ const visibleRule = field.rules?.find((r) => r.effect === 'visible');
523
+ const enableRule = field.rules?.find((r) => r.effect === 'enable');
524
+ const requiredRule = field.rules?.find((r) => r.effect === 'required');
525
+ if (meta?.visibleIf)
526
+ el.visibleIf = meta.visibleIf;
527
+ else if (visibleRule)
528
+ el.visibleIf = ruleToSurveyExpr(visibleRule);
529
+ if (meta?.enableIf)
530
+ el.enableIf = meta.enableIf;
531
+ else if (enableRule)
532
+ el.enableIf = ruleToSurveyExpr(enableRule);
533
+ if (meta?.requiredIf)
534
+ el.requiredIf = meta.requiredIf;
535
+ else if (requiredRule)
536
+ el.requiredIf = ruleToSurveyExpr(requiredRule);
537
+ // Restore extra element props from _sourceData for round-trip fidelity
538
+ if (meta?.placeholder !== undefined)
539
+ el.placeholder = meta.placeholder;
540
+ if (meta?.defaultValue !== undefined)
541
+ el.defaultValue = meta.defaultValue;
542
+ if (meta?.defaultValueExpression !== undefined)
543
+ el.defaultValueExpression = meta.defaultValueExpression;
544
+ if (meta?.min !== undefined)
545
+ el.min = meta.min;
546
+ if (meta?.max !== undefined)
547
+ el.max = meta.max;
548
+ if (meta?.step !== undefined)
549
+ el.step = meta.step;
550
+ if (meta?.rateMin !== undefined)
551
+ el.rateMin = meta.rateMin;
552
+ if (meta?.rateMax !== undefined)
553
+ el.rateMax = meta.rateMax;
554
+ if (meta?.rateStep !== undefined)
555
+ el.rateStep = meta.rateStep;
556
+ if (meta?.minRateDescription !== undefined)
557
+ el.minRateDescription = meta.minRateDescription;
558
+ if (meta?.maxRateDescription !== undefined)
559
+ el.maxRateDescription = meta.maxRateDescription;
560
+ if (meta?.showOtherItem !== undefined)
561
+ el.showOtherItem = meta.showOtherItem;
562
+ if (meta?.otherText !== undefined)
563
+ el.otherText = meta.otherText;
564
+ if (meta?.showNoneItem !== undefined)
565
+ el.showNoneItem = meta.showNoneItem;
566
+ if (meta?.noneText !== undefined)
567
+ el.noneText = meta.noneText;
568
+ if (meta?.showSelectAllItem !== undefined)
569
+ el.showSelectAllItem = meta.showSelectAllItem;
570
+ if (meta?.selectAllText !== undefined)
571
+ el.selectAllText = meta.selectAllText;
572
+ if (meta?.storeOthersAsComment !== undefined)
573
+ el.storeOthersAsComment = meta.storeOthersAsComment;
574
+ if (meta?.colCount !== undefined)
575
+ el.colCount = meta.colCount;
576
+ if (meta?.allowMultiple !== undefined)
577
+ el.allowMultiple = meta.allowMultiple;
578
+ if (meta?.acceptedTypes !== undefined)
579
+ el.acceptedTypes = meta.acceptedTypes;
580
+ // Type-specific properties
581
+ switch (field.fieldType) {
582
+ case 'text':
583
+ case 'longtext': {
584
+ const inputType = field.inputType;
585
+ if (inputType && inputType !== 'string') {
586
+ el.inputType = ESHEET_INPUT_TO_SURVEY[inputType] ?? inputType;
587
+ }
588
+ break;
589
+ }
590
+ case 'radio':
591
+ case 'check':
592
+ case 'dropdown':
593
+ case 'multiselectdropdown':
594
+ case 'ranking':
595
+ case 'rating':
596
+ el.choices = (field.options ?? []).map((o) => o.text !== undefined
597
+ ? {
598
+ value: o.value,
599
+ text: o.text,
600
+ ...(o.score !== undefined ? { score: o.score } : {}),
601
+ }
602
+ : o.value);
603
+ break;
604
+ case 'singlematrix':
605
+ case 'multimatrix':
606
+ el.rows = (field.rows ?? []).map((r) => ({ value: r.id, text: r.value }));
607
+ el.columns = (field.columns ?? []).map((c) => ({
608
+ value: c.id,
609
+ text: c.value,
610
+ ...(c.score !== undefined ? { score: c.score } : {}),
611
+ }));
612
+ break;
613
+ case 'image':
614
+ if (field.imageUri)
615
+ el.imageLink = field.imageUri;
616
+ if (field.altText)
617
+ el.altText = field.altText;
618
+ break;
619
+ case 'html':
620
+ // expression type: restore expression property instead of html
621
+ if (type === 'expression') {
622
+ el.expression = field.htmlContent ?? '';
623
+ }
624
+ else {
625
+ el.html = field.htmlContent ?? '';
626
+ }
627
+ break;
628
+ case 'multitext':
629
+ el.items = (field.options ?? []).map((o) => ({
630
+ name: o.value,
631
+ title: o.text ?? o.value,
632
+ }));
633
+ break;
634
+ case 'section':
635
+ el.elements = (field.fields ?? []).map(fieldToSurveyElement);
636
+ break;
637
+ }
638
+ return el;
639
+ }
640
+ /**
641
+ * Convert an eSheet FormDefinition back to a SurveyJS schema.
642
+ *
643
+ * Preserves original SurveyJS element names, types, and conditional expressions
644
+ * stored in `_sourceData` during import, enabling a lossless round-trip.
645
+ * Fields without `_sourceData` are reverse-mapped from eSheet types.
646
+ *
647
+ * Section fields become pages; non-section fields are placed in a single page.
648
+ */
649
+ export function exportToSurveyJS(form) {
650
+ // Restore survey-level metadata preserved during import
651
+ const surveyMeta = form._sourceData
652
+ ?.surveyMeta ?? {};
653
+ const hasSections = form.fields.some((f) => f.fieldType === 'section');
654
+ if (hasSections) {
655
+ // Each top-level section becomes a page; non-section fields go into a leading page.
656
+ const pages = [];
657
+ const topLevelFields = form.fields.filter((f) => f.fieldType !== 'section');
658
+ if (topLevelFields.length > 0) {
659
+ pages.push({
660
+ name: 'page1',
661
+ elements: topLevelFields.map(fieldToSurveyElement),
662
+ });
663
+ }
664
+ for (const field of form.fields) {
665
+ if (field.fieldType !== 'section')
666
+ continue;
667
+ const meta = field._sourceData;
668
+ pages.push({
669
+ name: meta?.surveyName ?? field.id,
670
+ title: field.title ?? field.question,
671
+ elements: (field.fields ?? []).map(fieldToSurveyElement),
672
+ });
673
+ }
674
+ return {
675
+ title: form.title,
676
+ ...(form.description ? { description: form.description } : {}),
677
+ ...surveyMeta,
678
+ pages,
679
+ };
680
+ }
681
+ // No sections — single page
682
+ return {
683
+ title: form.title,
684
+ ...(form.description ? { description: form.description } : {}),
685
+ ...surveyMeta,
686
+ pages: [{ name: 'page1', elements: form.fields.map(fieldToSurveyElement) }],
687
+ };
688
+ }
689
+ /**
690
+ * Convert a SurveyJS schema to eSheet FormDefinition.
691
+ * Primary named export matching the importFromMcp / exportToMcp naming convention.
692
+ */
693
+ export const importFromSurveyJS = convertSurveyJSToESheet;
694
+ /**
695
+ * Convert a SurveyJS schema to eSheet FormDefinition.
696
+ * Alias for importFromSurveyJS / convertSurveyJSToESheet.
697
+ */
698
+ export const convertSurveyJS = convertSurveyJSToESheet;