@form-engine-ts/core 4.4.0 → 4.6.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.cjs CHANGED
@@ -31,6 +31,8 @@ __export(index_exports, {
31
31
  calculatePageVisibility: () => calculatePageVisibility,
32
32
  cloneVersionToDraft: () => cloneVersionToDraft,
33
33
  collectSchemaLocales: () => collectSchemaLocales,
34
+ collectTranslationSlots: () => collectTranslationSlots,
35
+ computeSourceTextHash: () => computeSourceTextHash,
34
36
  createCloneTransitionPlan: () => createCloneTransitionPlan,
35
37
  createDeleteDraftTransitionPlan: () => createDeleteDraftTransitionPlan,
36
38
  createPublishTransitionPlan: () => createPublishTransitionPlan,
@@ -45,16 +47,21 @@ __export(index_exports, {
45
47
  escapeCsvCell: () => escapeCsvCell,
46
48
  exportResponsesToCsv: () => exportResponsesToCsv,
47
49
  exportResponsesToCsvStream: () => exportResponsesToCsvStream,
50
+ getTranslationStatus: () => getTranslationStatus,
51
+ isDisplayConditionGroupSatisfied: () => isDisplayConditionGroupSatisfied,
48
52
  isDisplayConditionSatisfied: () => isDisplayConditionSatisfied,
53
+ isManualTranslationMetadata: () => isManualTranslationMetadata,
49
54
  isQuestionVisible: () => isQuestionVisible,
50
55
  iterateSubmissionPages: () => iterateSubmissionPages,
51
56
  jsonValuesEqual: () => jsonValuesEqual,
52
57
  matchesSubmissionFilter: () => matchesSubmissionFilter,
53
58
  matchesSubmissionPageFilters: () => matchesSubmissionPageFilters,
59
+ migrateSchemaTranslationMetadata: () => migrateSchemaTranslationMetadata,
54
60
  normalizeSubmissionPageSize: () => normalizeSubmissionPageSize,
55
61
  pipeResponsesToCsvStream: () => pipeResponsesToCsvStream,
56
62
  populateSchemaTranslations: () => populateSchemaTranslations,
57
63
  publishDraft: () => publishDraft,
64
+ removeLocaleFromSchema: () => removeLocaleFromSchema,
58
65
  resolveFormTranslation: () => resolveFormTranslation,
59
66
  resolveLocalizedSchema: () => resolveLocalizedSchema,
60
67
  sanitizeSchema: () => sanitizeSchema,
@@ -116,6 +123,19 @@ function collectSchemaLocales(schema) {
116
123
  }
117
124
 
118
125
  // src/sanitization.ts
126
+ function displayRuleSourceIds(field) {
127
+ if (field.displayRule === void 0)
128
+ return field.displayCondition?.questionId === void 0 ? [] : [field.displayCondition.questionId];
129
+ const ids = [];
130
+ const visit = (group) => {
131
+ for (const condition of group.conditions) {
132
+ if ("logic" in condition) visit(condition);
133
+ else ids.push(condition.fieldId);
134
+ }
135
+ };
136
+ visit(field.displayRule.condition);
137
+ return ids;
138
+ }
119
139
  function registeredEntries(value, registeredLocales) {
120
140
  if (value === void 0) return void 0;
121
141
  const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
@@ -183,26 +203,28 @@ function cyclicQuestionIds(fields) {
183
203
  if (!firstById.has(field.id)) firstById.set(field.id, field);
184
204
  }
185
205
  const cyclic = /* @__PURE__ */ new Set();
186
- const resolved = /* @__PURE__ */ new Set();
187
- for (const startId of firstById.keys()) {
188
- if (resolved.has(startId)) continue;
189
- const path = [];
190
- const pathIndex = /* @__PURE__ */ new Map();
191
- let currentId = startId;
192
- while (currentId !== void 0 && firstById.has(currentId) && !resolved.has(currentId)) {
193
- const existingIndex = pathIndex.get(currentId);
194
- if (existingIndex !== void 0) {
195
- for (const id of path.slice(existingIndex)) cyclic.add(id);
196
- break;
197
- }
198
- pathIndex.set(currentId, path.length);
199
- path.push(currentId);
200
- const field = firstById.get(currentId);
201
- const sourceId = field?.displayCondition?.questionId;
202
- currentId = sourceId === currentId ? void 0 : sourceId;
206
+ const visiting = /* @__PURE__ */ new Set();
207
+ const visited = /* @__PURE__ */ new Set();
208
+ const path = [];
209
+ const visit = (id) => {
210
+ if (visiting.has(id)) {
211
+ const start = path.indexOf(id);
212
+ for (const member of path.slice(start < 0 ? 0 : start)) cyclic.add(member);
213
+ return;
203
214
  }
204
- for (const id of path) resolved.add(id);
205
- }
215
+ if (visited.has(id)) return;
216
+ visiting.add(id);
217
+ path.push(id);
218
+ const field = firstById.get(id);
219
+ if (field === void 0) return;
220
+ for (const sourceId of displayRuleSourceIds(field)) {
221
+ if (sourceId !== id && firstById.has(sourceId)) visit(sourceId);
222
+ }
223
+ path.pop();
224
+ visiting.delete(id);
225
+ visited.add(id);
226
+ };
227
+ for (const id of firstById.keys()) visit(id);
206
228
  return cyclic;
207
229
  }
208
230
  function validateSchemaStructure(schema) {
@@ -234,20 +256,20 @@ function validateSchemaStructure(schema) {
234
256
  }
235
257
  }
236
258
  for (const field of schema.fields) {
237
- const sourceId = field.displayCondition?.questionId;
238
- if (sourceId === void 0) continue;
239
- if (sourceId === field.id) {
240
- issues.push({
241
- type: "self_condition_reference",
242
- questionId: field.id,
243
- message: `Question "${field.id}" cannot depend on itself.`
244
- });
245
- } else if (!questionIds.has(sourceId)) {
246
- issues.push({
247
- type: "dangling_condition_reference",
248
- questionId: field.id,
249
- message: `Question "${field.id}" references missing question "${sourceId}".`
250
- });
259
+ for (const sourceId of displayRuleSourceIds(field)) {
260
+ if (sourceId === field.id) {
261
+ issues.push({
262
+ type: "self_condition_reference",
263
+ questionId: field.id,
264
+ message: `Question "${field.id}" cannot depend on itself.`
265
+ });
266
+ } else if (!questionIds.has(sourceId)) {
267
+ issues.push({
268
+ type: "dangling_condition_reference",
269
+ questionId: field.id,
270
+ message: `Question "${field.id}" references missing question "${sourceId}".`
271
+ });
272
+ }
251
273
  }
252
274
  }
253
275
  const cyclic = cyclicQuestionIds(schema.fields);
@@ -256,6 +278,11 @@ function validateSchemaStructure(schema) {
256
278
  issues.push({
257
279
  type: "cyclic_condition_reference",
258
280
  questionId: field.id,
281
+ cycle: [
282
+ field.id,
283
+ ...displayRuleSourceIds(field).filter((sourceId) => cyclic.has(sourceId)).slice(0, 1),
284
+ field.id
285
+ ],
259
286
  message: `Question "${field.id}" participates in a display-condition cycle.`
260
287
  });
261
288
  }
@@ -270,11 +297,17 @@ function sanitizeSchema(schema, options = {}) {
270
297
  const cyclic = cyclicQuestionIds(schema.fields);
271
298
  const sanitizedFields = schema.fields.map((sourceField) => {
272
299
  const field = sanitizeFieldConstraints(sanitizeFieldLocales(sourceField, registeredLocales), options.policy);
273
- const sourceId = field.displayCondition?.questionId;
274
- if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
275
- return field;
300
+ let sanitized = field;
301
+ const ruleSources = displayRuleSourceIds(field);
302
+ if (field.displayRule !== void 0 && (cyclic.has(field.id) || ruleSources.some((sourceId2) => sourceId2 === field.id || !existingQuestionIds.has(sourceId2)))) {
303
+ const { displayRule: _displayRule, ...withoutRule } = sanitized;
304
+ sanitized = withoutRule;
305
+ }
306
+ const sourceId = sanitized.displayCondition?.questionId;
307
+ if (sourceId !== void 0 && (!existingQuestionIds.has(sourceId) || sourceId === sanitized.id || cyclic.has(sanitized.id))) {
308
+ const { displayCondition: _displayCondition, ...withoutCondition } = sanitized;
309
+ return withoutCondition;
276
310
  }
277
- const { displayCondition: _displayCondition, ...sanitized } = field;
278
311
  return sanitized;
279
312
  });
280
313
  const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
@@ -317,7 +350,17 @@ function sanitizeSchema(schema, options = {}) {
317
350
 
318
351
  // src/schema.ts
319
352
  var FIELD_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "number", "rating", "select", "multi-select", "checkbox", "radio"]);
320
- var CONDITION_OPERATORS = /* @__PURE__ */ new Set(["equals", "not_equals", "contains", "not_empty"]);
353
+ var CONDITION_OPERATORS = /* @__PURE__ */ new Set([
354
+ "equals",
355
+ "not_equals",
356
+ "contains",
357
+ "not_contains",
358
+ "is_empty",
359
+ "is_not_empty",
360
+ "greater_than",
361
+ "less_than",
362
+ "not_empty"
363
+ ]);
321
364
  function isRecord(value) {
322
365
  return typeof value === "object" && value !== null && !Array.isArray(value);
323
366
  }
@@ -474,7 +517,7 @@ function validateDisplayCondition(value, path, issues) {
474
517
  return false;
475
518
  }
476
519
  const hasValue = Object.hasOwn(value, "value") && value.value !== void 0;
477
- if (value.operator === "not_empty") {
520
+ if (value.operator === "not_empty" || value.operator === "is_empty" || value.operator === "is_not_empty") {
478
521
  if (hasValue) issue(issues, `${path}.value`, "unexpected_condition_value", "not_empty does not accept a value.");
479
522
  } else if (!hasValue) {
480
523
  issue(issues, `${path}.value`, "missing_condition_value", `${value.operator} requires a value.`);
@@ -485,6 +528,75 @@ function validateDisplayCondition(value, path, issues) {
485
528
  }
486
529
  return true;
487
530
  }
531
+ function validateDisplayConditionGroup(value, path, issues) {
532
+ if (!isRecord(value)) {
533
+ issue(issues, path, "invalid_condition_group", "Expected a display condition group.");
534
+ return false;
535
+ }
536
+ if (value.logic !== "all" && value.logic !== "any") {
537
+ issue(issues, `${path}.logic`, "invalid_condition_logic", "Expected all or any.");
538
+ }
539
+ if (!Array.isArray(value.conditions)) {
540
+ issue(issues, `${path}.conditions`, "invalid_condition_group", "Expected a conditions array.");
541
+ return false;
542
+ }
543
+ value.conditions.forEach((condition, index) => {
544
+ const conditionPath = `${path}.conditions[${index}]`;
545
+ if (isRecord(condition) && Object.hasOwn(condition, "logic")) {
546
+ validateDisplayConditionGroup(condition, conditionPath, issues);
547
+ return;
548
+ }
549
+ if (!isRecord(condition)) {
550
+ issue(issues, conditionPath, "invalid_condition", "Expected a field display condition.");
551
+ return;
552
+ }
553
+ if (!isNonEmptyString(condition.fieldId)) {
554
+ issue(issues, `${conditionPath}.fieldId`, "invalid_condition_source", "Expected a field ID.");
555
+ }
556
+ if (typeof condition.operator !== "string" || !CONDITION_OPERATORS.has(condition.operator)) {
557
+ issue(issues, `${conditionPath}.operator`, "invalid_condition_operator", "Unsupported condition operator.");
558
+ return;
559
+ }
560
+ const needsValue = !["not_empty", "is_empty", "is_not_empty"].includes(condition.operator);
561
+ const hasValue = Object.hasOwn(condition, "value") && condition.value !== void 0;
562
+ if (needsValue && !hasValue)
563
+ issue(issues, `${conditionPath}.value`, "missing_condition_value", "A value is required.");
564
+ });
565
+ return true;
566
+ }
567
+ function validateDisplayRule(value, path, issues) {
568
+ if (!isRecord(value)) {
569
+ issue(issues, path, "invalid_display_rule", "Expected a display rule object.");
570
+ return false;
571
+ }
572
+ if (value.action !== "show" && value.action !== "hide") {
573
+ issue(issues, `${path}.action`, "invalid_display_action", "Expected show or hide.");
574
+ }
575
+ validateDisplayConditionGroup(value.condition, `${path}.condition`, issues);
576
+ return true;
577
+ }
578
+ function validateSubmissionSettings(value, path, issues) {
579
+ if (!isRecord(value)) {
580
+ issue(issues, path, "invalid_submission_settings", "Expected submission settings to be an object.");
581
+ return;
582
+ }
583
+ validateExtensibleNode(value, path, issues);
584
+ if (value.showConfirmationBeforeSubmit !== void 0 && typeof value.showConfirmationBeforeSubmit !== "boolean") {
585
+ issue(issues, `${path}.showConfirmationBeforeSubmit`, "invalid_submission_setting", "Expected a boolean.");
586
+ }
587
+ if (value.confirmationRenderMode !== void 0 && !["dialog", "inline", "replace"].includes(String(value.confirmationRenderMode))) {
588
+ issue(
589
+ issues,
590
+ `${path}.confirmationRenderMode`,
591
+ "invalid_submission_setting",
592
+ "Unsupported confirmation render mode."
593
+ );
594
+ }
595
+ for (const key of ["confirmButtonLabel", "cancelButtonLabel"]) {
596
+ if (value[key] !== void 0 && !isNonEmptyString(value[key]))
597
+ issue(issues, `${path}.${key}`, "invalid_submission_setting", "Expected non-empty text.");
598
+ }
599
+ }
488
600
  function validateField(value, path, issues) {
489
601
  if (!isRecord(value)) {
490
602
  issue(issues, path, "invalid_field", "Expected a field object.");
@@ -508,6 +620,7 @@ function validateField(value, path, issues) {
508
620
  if (value.displayCondition !== void 0) {
509
621
  validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
510
622
  }
623
+ if (value.displayRule !== void 0) validateDisplayRule(value.displayRule, `${path}.displayRule`, issues);
511
624
  if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
512
625
  if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
513
626
  issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
@@ -892,6 +1005,8 @@ function validateFormSchema(input, options = {}) {
892
1005
  }
893
1006
  }
894
1007
  if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
1008
+ if (input.submissionSettings !== void 0)
1009
+ validateSubmissionSettings(input.submissionSettings, "submissionSettings", issues);
895
1010
  if (!Array.isArray(input.fields) || input.fields.length === 0) {
896
1011
  issue(issues, "fields", "invalid_fields", "Expected at least one field.");
897
1012
  } else {
@@ -915,8 +1030,19 @@ function validateFormSchema(input, options = {}) {
915
1030
  continue;
916
1031
  }
917
1032
  const fieldIndex = inputFields.findIndex((field) => isRecord(field) && field.id === structuralIssue.questionId);
918
- const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
919
- issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
1033
+ const currentField = inputFields[fieldIndex];
1034
+ const usesDisplayRule = isRecord(currentField) && currentField.displayRule !== void 0;
1035
+ const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : usesDisplayRule ? "circular_display_condition" : "condition_cycle";
1036
+ const path = usesDisplayRule ? `fields[${fieldIndex}].displayRule` : `fields[${fieldIndex}].displayCondition`;
1037
+ if (usesDisplayRule && structuralIssue.type === "cyclic_condition_reference") {
1038
+ issues.push({
1039
+ path,
1040
+ code,
1041
+ type: code,
1042
+ message: structuralIssue.message,
1043
+ ...structuralIssue.cycle === void 0 ? {} : { cycle: structuralIssue.cycle }
1044
+ });
1045
+ } else issue(issues, path, code, structuralIssue.message);
920
1046
  }
921
1047
  if (input.pages !== void 0) {
922
1048
  if (!Array.isArray(input.pages) || input.pages.length === 0) {
@@ -1025,23 +1151,61 @@ function normalizeString(value) {
1025
1151
  return value.trim().normalize("NFKC").toLowerCase();
1026
1152
  }
1027
1153
  function valuesEqual(left, right) {
1028
- return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
1154
+ if (typeof left === "string" && typeof right === "string") return normalizeString(left) === normalizeString(right);
1155
+ if (Array.isArray(left) && Array.isArray(right)) {
1156
+ return left.length === right.length && left.every((item, index) => valuesEqual(item, right[index]));
1157
+ }
1158
+ return left === right;
1159
+ }
1160
+ function evaluateFieldCondition(condition, currentAnswers) {
1161
+ const sourceId = "fieldId" in condition ? condition.fieldId : condition.questionId;
1162
+ const answer = currentAnswers[sourceId];
1163
+ if (isEmpty(answer) && ["equals", "not_equals", "contains"].includes(condition.operator)) return false;
1164
+ if (condition.operator === "is_empty") return isEmpty(answer);
1165
+ if (condition.operator === "is_not_empty" || condition.operator === "not_empty") return !isEmpty(answer);
1166
+ if (condition.operator === "contains" || condition.operator === "not_contains") {
1167
+ const contains = typeof answer === "string" && typeof condition.value === "string" ? normalizeString(answer).includes(normalizeString(condition.value)) : Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
1168
+ return condition.operator === "not_contains" ? !contains : contains;
1169
+ }
1170
+ if (condition.value === void 0) return false;
1171
+ if (condition.operator === "equals") return valuesEqual(answer, condition.value);
1172
+ if (condition.operator === "not_equals") return !valuesEqual(answer, condition.value);
1173
+ if (condition.operator === "greater_than")
1174
+ return typeof answer === "number" && typeof condition.value === "number" && answer > condition.value;
1175
+ if (condition.operator === "less_than")
1176
+ return typeof answer === "number" && typeof condition.value === "number" && answer < condition.value;
1177
+ return false;
1178
+ }
1179
+ function isDisplayConditionGroupSatisfied(group, currentAnswers) {
1180
+ const results = group.conditions.map(
1181
+ (condition) => "logic" in condition ? isDisplayConditionGroupSatisfied(condition, currentAnswers) : evaluateFieldCondition(condition, currentAnswers)
1182
+ );
1183
+ return group.logic === "all" ? results.every(Boolean) : results.some(Boolean);
1029
1184
  }
1030
1185
  function isQuestionVisible(question, currentAnswers) {
1186
+ if (question.displayRule !== void 0) {
1187
+ const satisfied = isDisplayConditionGroupSatisfied(question.displayRule.condition, currentAnswers);
1188
+ return question.displayRule.action === "hide" ? !satisfied : satisfied;
1189
+ }
1031
1190
  return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
1032
1191
  }
1033
1192
  function isDisplayConditionSatisfied(condition, currentAnswers) {
1034
1193
  if (condition === void 0) return true;
1035
- const answer = currentAnswers[condition.questionId];
1036
- if (isEmpty(answer)) return false;
1037
- if (condition.operator === "not_empty") return true;
1038
- if (condition.value === void 0) return false;
1039
- if (condition.operator === "equals") return valuesEqual(answer, condition.value);
1040
- if (condition.operator === "not_equals") return !valuesEqual(answer, condition.value);
1041
- if (typeof answer === "string" && typeof condition.value === "string") {
1042
- return normalizeString(answer).includes(normalizeString(condition.value));
1043
- }
1044
- return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
1194
+ if ("logic" in condition) return isDisplayConditionGroupSatisfied(condition, currentAnswers);
1195
+ return evaluateFieldCondition(condition, currentAnswers);
1196
+ }
1197
+ function conditionSourceIds(condition) {
1198
+ if (condition === void 0) return [];
1199
+ if ("questionId" in condition) return [condition.questionId];
1200
+ const ids = [];
1201
+ const visit = (group) => {
1202
+ for (const item of group.conditions) {
1203
+ if ("logic" in item) visit(item);
1204
+ else ids.push(item.fieldId);
1205
+ }
1206
+ };
1207
+ visit(condition.condition);
1208
+ return ids;
1045
1209
  }
1046
1210
  function calculateBaseFieldVisibility(schema, currentAnswers) {
1047
1211
  const fields = new Map(schema.fields.map((field) => [field.id, field]));
@@ -1052,10 +1216,12 @@ function calculateBaseFieldVisibility(schema, currentAnswers) {
1052
1216
  if (existing !== void 0) return existing;
1053
1217
  if (resolving.has(field.id)) return false;
1054
1218
  resolving.add(field.id);
1055
- const sourceId = field.displayCondition?.questionId;
1056
- const source = sourceId === void 0 ? void 0 : fields.get(sourceId);
1057
- const sourceVisible = source === void 0 ? sourceId === void 0 : resolve(source);
1058
- const visible = sourceVisible && isQuestionVisible(field, currentAnswers);
1219
+ const sources = field.displayRule === void 0 ? conditionSourceIds(field.displayCondition) : conditionSourceIds(field.displayRule);
1220
+ const sourcesVisible = sources.every((sourceId) => {
1221
+ const source = fields.get(sourceId);
1222
+ return source !== void 0 && source.id !== field.id && resolve(source);
1223
+ });
1224
+ const visible = sourcesVisible && isQuestionVisible(field, currentAnswers);
1059
1225
  resolving.delete(field.id);
1060
1226
  resolved.set(field.id, visible);
1061
1227
  return visible;
@@ -2053,6 +2219,39 @@ function createSubmission(schema, values, options) {
2053
2219
  }
2054
2220
 
2055
2221
  // src/translation.ts
2222
+ var isManualTranslationMetadata = (metadata) => {
2223
+ if (metadata === void 0) return false;
2224
+ if ("isManuallyEdited" in metadata && metadata.isManuallyEdited === true || "isManual" in metadata && metadata.isManual === true)
2225
+ return true;
2226
+ return typeof metadata.translationSource === "string" && metadata.translationSource.toLowerCase() === "manual";
2227
+ };
2228
+ var computeSourceTextHash = (text) => {
2229
+ const normalized = text.normalize("NFKC").trim().replace(/\s+/gu, " ");
2230
+ let hash = 2166136261;
2231
+ for (let index = 0; index < normalized.length; index += 1) {
2232
+ hash ^= normalized.charCodeAt(index);
2233
+ hash = Math.imul(hash, 16777619);
2234
+ }
2235
+ return (hash >>> 0).toString(16).padStart(8, "0");
2236
+ };
2237
+ function getTranslationStatus(sourceText, translatedText, metadata) {
2238
+ return getTranslationStatusWithManualOverride(sourceText, translatedText, metadata, void 0);
2239
+ }
2240
+ function getTranslationStatusWithManualOverride(sourceText, translatedText, metadata, manualOverride) {
2241
+ if (translatedText === void 0 || translatedText.trim() === "") return "missing";
2242
+ if (metadata === void 0) return "translated";
2243
+ const currentHash = computeSourceTextHash(sourceText);
2244
+ const sourceHash = typeof metadata.sourceTextHash === "string" ? metadata.sourceTextHash : void 0;
2245
+ const isManual = manualOverride ?? isManualTranslationMetadata(metadata);
2246
+ if (isManual) return sourceHash === void 0 || sourceHash === currentHash ? "manual" : "manual-stale";
2247
+ return sourceHash === void 0 || sourceHash === currentHash ? "translated" : "stale";
2248
+ }
2249
+ async function translateBatch(adapter, texts, locale, sourceLocale) {
2250
+ if ("translateBatch" in adapter) return adapter.translateBatch(texts, locale, sourceLocale);
2251
+ return texts.map(
2252
+ (text) => adapter.translate(text, locale, sourceLocale === void 0 ? void 0 : { sourceLocale }) ?? text
2253
+ );
2254
+ }
2056
2255
  function mergeLocalizedText(translations, locale, property, value) {
2057
2256
  return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
2058
2257
  }
@@ -2069,19 +2268,27 @@ function withTranslationMetadata(node, locale, property, metadata) {
2069
2268
  }
2070
2269
  };
2071
2270
  }
2072
- function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata) {
2271
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata, options = {}) {
2272
+ const path = `${kind}.${nodeId}.${property}`;
2273
+ const manual = options.isManualTranslation?.(existingTranslationMetadata, { path, locale }) ?? isManualTranslationMetadata(existingTranslationMetadata);
2274
+ const normalizedMetadata = existingTranslationMetadata === void 0 ? void 0 : options.normalizeMetadata === void 0 ? existingTranslationMetadata : { ...options.normalizeMetadata(existingTranslationMetadata, sourceText) };
2275
+ const status = getTranslationStatusWithManualOverride(sourceText, existingText, normalizedMetadata, manual);
2073
2276
  return {
2074
2277
  kind,
2075
2278
  nodeId,
2076
2279
  property,
2077
2280
  locale,
2078
2281
  sourceText,
2282
+ target: { kind, ...nodeId.length === 0 ? {} : { id: nodeId }, property },
2283
+ path,
2284
+ sourceTextHash: computeSourceTextHash(sourceText),
2285
+ status,
2079
2286
  ...existingText === void 0 ? {} : { existingText },
2080
2287
  ...nodeMetadata === void 0 ? {} : { nodeMetadata, metadata: nodeMetadata },
2081
- ...existingTranslationMetadata === void 0 ? {} : { existingTranslationMetadata }
2288
+ ...normalizedMetadata === void 0 ? {} : { existingTranslationMetadata: normalizedMetadata }
2082
2289
  };
2083
2290
  }
2084
- function translationSlots(schema, locale) {
2291
+ function translationSlots(schema, locale, options = {}) {
2085
2292
  const descriptors = [];
2086
2293
  const addFormSlot = (property, sourceText) => {
2087
2294
  const slot = createSlot(
@@ -2092,10 +2299,15 @@ function translationSlots(schema, locale) {
2092
2299
  sourceText,
2093
2300
  schema.translations?.[locale]?.[property],
2094
2301
  schema.metadata,
2095
- schema.translationMetadata?.[locale]?.[property]
2302
+ schema.translationMetadata?.[locale]?.[property],
2303
+ options
2096
2304
  );
2097
2305
  descriptors.push({
2098
2306
  slot,
2307
+ manual: options.isManualTranslation?.(schema.translationMetadata?.[locale]?.[property], {
2308
+ path: slot.path ?? "",
2309
+ locale
2310
+ }) ?? isManualTranslationMetadata(schema.translationMetadata?.[locale]?.[property]),
2099
2311
  apply: (current, value, metadata) => withTranslationMetadata(
2100
2312
  { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
2101
2313
  locale,
@@ -2117,10 +2329,15 @@ function translationSlots(schema, locale) {
2117
2329
  sourceText,
2118
2330
  field.translations?.[locale]?.[property],
2119
2331
  field.metadata,
2120
- field.translationMetadata?.[locale]?.[property]
2332
+ field.translationMetadata?.[locale]?.[property],
2333
+ options
2121
2334
  );
2122
2335
  descriptors.push({
2123
2336
  slot,
2337
+ manual: options.isManualTranslation?.(field.translationMetadata?.[locale]?.[property], {
2338
+ path: slot.path ?? "",
2339
+ locale
2340
+ }) ?? isManualTranslationMetadata(field.translationMetadata?.[locale]?.[property]),
2124
2341
  apply: (current, value, metadata) => ({
2125
2342
  ...current,
2126
2343
  fields: current.fields.map(
@@ -2149,10 +2366,15 @@ function translationSlots(schema, locale) {
2149
2366
  option.label,
2150
2367
  option.translations?.[locale],
2151
2368
  option.metadata,
2152
- option.translationMetadata?.[locale]?.label
2369
+ option.translationMetadata?.[locale]?.label,
2370
+ options
2153
2371
  );
2154
2372
  descriptors.push({
2155
2373
  slot,
2374
+ manual: options.isManualTranslation?.(option.translationMetadata?.[locale]?.label, {
2375
+ path: slot.path ?? "",
2376
+ locale
2377
+ }) ?? isManualTranslationMetadata(option.translationMetadata?.[locale]?.label),
2156
2378
  apply: (current, value, metadata) => ({
2157
2379
  ...current,
2158
2380
  fields: current.fields.map((candidate, candidateIndex) => {
@@ -2187,10 +2409,15 @@ function translationSlots(schema, locale) {
2187
2409
  sourceText,
2188
2410
  page.translations?.[locale]?.[property],
2189
2411
  page.metadata,
2190
- page.translationMetadata?.[locale]?.[property]
2412
+ page.translationMetadata?.[locale]?.[property],
2413
+ options
2191
2414
  );
2192
2415
  descriptors.push({
2193
2416
  slot,
2417
+ manual: options.isManualTranslation?.(page.translationMetadata?.[locale]?.[property], {
2418
+ path: slot.path ?? "",
2419
+ locale
2420
+ }) ?? isManualTranslationMetadata(page.translationMetadata?.[locale]?.[property]),
2194
2421
  apply: (current, value, metadata) => ({
2195
2422
  ...current,
2196
2423
  ...current.pages === void 0 ? {} : {
@@ -2214,6 +2441,125 @@ function translationSlots(schema, locale) {
2214
2441
  });
2215
2442
  return descriptors;
2216
2443
  }
2444
+ function removeLocaleRecord(record, locale) {
2445
+ if (record === void 0) return void 0;
2446
+ const remaining = Object.entries(record).filter(([candidate]) => candidate !== locale);
2447
+ return remaining.length === 0 ? void 0 : Object.fromEntries(remaining);
2448
+ }
2449
+ function removeLocalizedNodeLocale(node, locale) {
2450
+ const translations = removeLocaleRecord(node.translations, locale);
2451
+ const translationMetadata = removeLocaleRecord(node.translationMetadata, locale);
2452
+ const { translations: _translations, translationMetadata: _translationMetadata, ...base } = node;
2453
+ return {
2454
+ ...base,
2455
+ ...translations === void 0 ? {} : { translations },
2456
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
2457
+ };
2458
+ }
2459
+ function migrateMetadata(metadata, sourceText, defaultLocale, customMigrator) {
2460
+ if (customMigrator !== void 0) return customMigrator(metadata, sourceText);
2461
+ const record = metadata !== null && typeof metadata === "object" ? metadata : void 0;
2462
+ const sourceLocale = typeof record?.sourceLocale === "string" ? record.sourceLocale : defaultLocale ?? "";
2463
+ const translationSource = isManualTranslationMetadata(record) ? "manual" : "automatic";
2464
+ return {
2465
+ sourceLocale,
2466
+ sourceTextHash: computeSourceTextHash(sourceText),
2467
+ translationSource,
2468
+ ...typeof record?.translatedAt === "string" ? { translatedAt: record.translatedAt } : {},
2469
+ ...typeof record?.editedAt === "string" ? { editedAt: record.editedAt } : {}
2470
+ };
2471
+ }
2472
+ function migrateNodeMetadata(node, sourceTexts, defaultLocale, customMigrator) {
2473
+ if (node.translationMetadata === void 0) return node;
2474
+ const translationMetadata = Object.fromEntries(
2475
+ Object.entries(node.translationMetadata).map(([locale, properties]) => [
2476
+ locale,
2477
+ Object.fromEntries(
2478
+ Object.entries(properties).map(([property, metadata]) => [
2479
+ property,
2480
+ migrateMetadata(metadata, sourceTexts[property] ?? "", defaultLocale, customMigrator)
2481
+ ])
2482
+ )
2483
+ ])
2484
+ );
2485
+ return { ...node, translationMetadata };
2486
+ }
2487
+ var migrateSchemaTranslationMetadata = (schema, customMigrator) => {
2488
+ const migratedSchema = migrateNodeMetadata(
2489
+ schema,
2490
+ {
2491
+ title: schema.title,
2492
+ ...schema.description === void 0 ? {} : { description: schema.description },
2493
+ ...schema.completionMessage === void 0 ? {} : { completionMessage: schema.completionMessage }
2494
+ },
2495
+ schema.defaultLocale,
2496
+ customMigrator
2497
+ );
2498
+ const fields = schema.fields.map((field) => {
2499
+ const migratedField = migrateNodeMetadata(
2500
+ field,
2501
+ { title: field.title, ...field.description === void 0 ? {} : { description: field.description } },
2502
+ schema.defaultLocale,
2503
+ customMigrator
2504
+ );
2505
+ if (!("options" in migratedField)) return migratedField;
2506
+ return {
2507
+ ...migratedField,
2508
+ options: migratedField.options.map(
2509
+ (option) => migrateNodeMetadata(option, { label: option.label }, schema.defaultLocale, customMigrator)
2510
+ )
2511
+ };
2512
+ });
2513
+ const pages = schema.pages?.map(
2514
+ (page) => migrateNodeMetadata(
2515
+ page,
2516
+ {
2517
+ ...page.title === void 0 ? {} : { title: page.title },
2518
+ ...page.description === void 0 ? {} : { description: page.description }
2519
+ },
2520
+ schema.defaultLocale,
2521
+ customMigrator
2522
+ )
2523
+ );
2524
+ return {
2525
+ ...migratedSchema,
2526
+ fields,
2527
+ ...pages === void 0 ? {} : { pages }
2528
+ };
2529
+ };
2530
+ var removeLocaleFromSchema = (schema, localeToRemove) => {
2531
+ if (localeToRemove === schema.defaultLocale) {
2532
+ throw new Error(`Cannot remove defaultLocale: ${localeToRemove}`);
2533
+ }
2534
+ const form = removeLocalizedNodeLocale(schema, localeToRemove);
2535
+ const fields = schema.fields.map((field) => {
2536
+ const localizedField = removeLocalizedNodeLocale(field, localeToRemove);
2537
+ if (!("options" in localizedField)) return localizedField;
2538
+ return {
2539
+ ...localizedField,
2540
+ options: localizedField.options.map((option) => {
2541
+ const translations = removeLocaleRecord(option.translations, localeToRemove);
2542
+ const translationMetadata = removeLocaleRecord(option.translationMetadata, localeToRemove);
2543
+ const { translations: _translations, translationMetadata: _translationMetadata, ...base } = option;
2544
+ return {
2545
+ ...base,
2546
+ ...translations === void 0 ? {} : { translations },
2547
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
2548
+ };
2549
+ })
2550
+ };
2551
+ });
2552
+ const pages = schema.pages?.map((page) => removeLocalizedNodeLocale(page, localeToRemove));
2553
+ return {
2554
+ ...form,
2555
+ supportedLocales: (schema.supportedLocales ?? []).filter((locale) => locale !== localeToRemove),
2556
+ fields,
2557
+ ...pages === void 0 ? {} : { pages }
2558
+ };
2559
+ };
2560
+ function collectTranslationSlots(schema, locale) {
2561
+ return translationSlots(schema, locale).map((descriptor) => descriptor.slot);
2562
+ }
2217
2563
  function resolveLocalizedSchema(schema, targetLocale) {
2218
2564
  if (targetLocale === void 0 || targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
2219
2565
  const formTranslation = schema.translations?.[targetLocale];
@@ -2270,17 +2616,32 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2270
2616
  }
2271
2617
  const updatedSlots = [];
2272
2618
  const skippedSlots = [];
2619
+ const staleSlots = [];
2620
+ const skippedReasons = {};
2273
2621
  let result = schema;
2274
2622
  for (const locale of locales) {
2275
- const descriptors = translationSlots(schema, locale);
2623
+ const descriptors = translationSlots(schema, locale, options);
2624
+ if (options.markStaleTranslations ?? true) {
2625
+ for (const descriptor of descriptors) {
2626
+ if (descriptor.slot.status === "stale" || descriptor.slot.status === "manual-stale")
2627
+ staleSlots.push(descriptor.slot);
2628
+ }
2629
+ }
2276
2630
  const selected = [];
2277
2631
  for (const descriptor of descriptors) {
2278
- const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
2632
+ const status = descriptor.slot.status ?? "missing";
2633
+ const manual = descriptor.manual || status === "manual" || status === "manual-stale";
2634
+ const requestedOverwrite = options.shouldOverwrite?.(descriptor.slot);
2635
+ const shouldTranslate = (options.preserveManualTranslations ?? true) && manual ? false : requestedOverwrite ?? (options.overwrite === "all" || (options.overwrite === "stale-and-missing" || options.overwrite === void 0 ? status === "missing" || status === "stale" || !(options.preserveManualTranslations ?? true) && status === "manual-stale" : status === "missing"));
2279
2636
  if (shouldTranslate) selected.push(descriptor);
2280
- else skippedSlots.push(descriptor.slot);
2637
+ else {
2638
+ skippedSlots.push(descriptor.slot);
2639
+ skippedReasons[descriptor.slot.path ?? `${descriptor.slot.kind}.${descriptor.slot.nodeId}.${descriptor.slot.property}`] = manual ? "manual" : "unchanged";
2640
+ }
2281
2641
  }
2282
2642
  if (selected.length === 0) continue;
2283
- const translated = await adapter.translateBatch(
2643
+ const translated = await translateBatch(
2644
+ adapter,
2284
2645
  selected.map((descriptor) => descriptor.slot.sourceText),
2285
2646
  locale,
2286
2647
  schema.defaultLocale
@@ -2291,7 +2652,12 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2291
2652
  selected.forEach((descriptor, index) => {
2292
2653
  const translatedText = translated[index];
2293
2654
  if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
2294
- const metadata = options.createMetadata?.(descriptor.slot, translatedText);
2655
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText) ?? {
2656
+ sourceLocale: schema.defaultLocale ?? "",
2657
+ sourceTextHash: computeSourceTextHash(descriptor.slot.sourceText),
2658
+ translationSource: "automatic",
2659
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString()
2660
+ };
2295
2661
  result = descriptor.apply(result, translatedText, metadata);
2296
2662
  updatedSlots.push(descriptor.slot);
2297
2663
  });
@@ -2305,7 +2671,7 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2305
2671
  ];
2306
2672
  if (supportedLocales.length > 0) result = { ...result, supportedLocales };
2307
2673
  assertValidFormSchema(result);
2308
- return { schema: result, report: { updatedSlots, skippedSlots } };
2674
+ return { schema: result, report: { updatedSlots, skippedSlots, staleSlots, skippedReasons } };
2309
2675
  }
2310
2676
  async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
2311
2677
  const populated = await populateSchemaTranslations(
@@ -2609,6 +2975,8 @@ function assertVersionMutable(status) {
2609
2975
  calculatePageVisibility,
2610
2976
  cloneVersionToDraft,
2611
2977
  collectSchemaLocales,
2978
+ collectTranslationSlots,
2979
+ computeSourceTextHash,
2612
2980
  createCloneTransitionPlan,
2613
2981
  createDeleteDraftTransitionPlan,
2614
2982
  createPublishTransitionPlan,
@@ -2623,16 +2991,21 @@ function assertVersionMutable(status) {
2623
2991
  escapeCsvCell,
2624
2992
  exportResponsesToCsv,
2625
2993
  exportResponsesToCsvStream,
2994
+ getTranslationStatus,
2995
+ isDisplayConditionGroupSatisfied,
2626
2996
  isDisplayConditionSatisfied,
2997
+ isManualTranslationMetadata,
2627
2998
  isQuestionVisible,
2628
2999
  iterateSubmissionPages,
2629
3000
  jsonValuesEqual,
2630
3001
  matchesSubmissionFilter,
2631
3002
  matchesSubmissionPageFilters,
3003
+ migrateSchemaTranslationMetadata,
2632
3004
  normalizeSubmissionPageSize,
2633
3005
  pipeResponsesToCsvStream,
2634
3006
  populateSchemaTranslations,
2635
3007
  publishDraft,
3008
+ removeLocaleFromSchema,
2636
3009
  resolveFormTranslation,
2637
3010
  resolveLocalizedSchema,
2638
3011
  sanitizeSchema,