@form-engine-ts/core 4.4.0 → 4.5.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/README.md CHANGED
@@ -53,6 +53,15 @@ safe maximum-length corrections while purging unregistered locale content. Pass
53
53
  `{ policy: { allowedLocales, maxLocales } }` to `populateSchemaTranslations` to reject inadmissible targets before the
54
54
  translation adapter runs.
55
55
 
56
+ Fields can use a `displayRule` with nested `all`/`any` condition groups and `show` or `hide` actions. Supported
57
+ operators include equality, containment, emptiness, and numeric comparisons; the legacy `displayCondition` and
58
+ `not_empty` forms remain supported. `submissionSettings` can enable pre-submit confirmation and select its
59
+ `dialog`, `inline`, or `replace` presentation.
60
+
61
+ `collectTranslationSlots`, `computeSourceTextHash`, and `getTranslationStatus` expose canonical translation targets
62
+ and missing, translated, stale, or manual states for authoring tools. `populateSchemaTranslations` can populate stale
63
+ and missing entries while preserving manual translations and reports skipped reasons.
64
+
56
65
  The React builder uses canonical keys with legacy aliases when resolving UI translations:
57
66
 
58
67
  | Canonical key | Legacy alias |
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,6 +47,8 @@ __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,
49
53
  isQuestionVisible: () => isQuestionVisible,
50
54
  iterateSubmissionPages: () => iterateSubmissionPages,
@@ -116,6 +120,19 @@ function collectSchemaLocales(schema) {
116
120
  }
117
121
 
118
122
  // src/sanitization.ts
123
+ function displayRuleSourceIds(field) {
124
+ if (field.displayRule === void 0)
125
+ return field.displayCondition?.questionId === void 0 ? [] : [field.displayCondition.questionId];
126
+ const ids = [];
127
+ const visit = (group) => {
128
+ for (const condition of group.conditions) {
129
+ if ("logic" in condition) visit(condition);
130
+ else ids.push(condition.fieldId);
131
+ }
132
+ };
133
+ visit(field.displayRule.condition);
134
+ return ids;
135
+ }
119
136
  function registeredEntries(value, registeredLocales) {
120
137
  if (value === void 0) return void 0;
121
138
  const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
@@ -183,26 +200,28 @@ function cyclicQuestionIds(fields) {
183
200
  if (!firstById.has(field.id)) firstById.set(field.id, field);
184
201
  }
185
202
  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;
203
+ const visiting = /* @__PURE__ */ new Set();
204
+ const visited = /* @__PURE__ */ new Set();
205
+ const path = [];
206
+ const visit = (id) => {
207
+ if (visiting.has(id)) {
208
+ const start = path.indexOf(id);
209
+ for (const member of path.slice(start < 0 ? 0 : start)) cyclic.add(member);
210
+ return;
203
211
  }
204
- for (const id of path) resolved.add(id);
205
- }
212
+ if (visited.has(id)) return;
213
+ visiting.add(id);
214
+ path.push(id);
215
+ const field = firstById.get(id);
216
+ if (field === void 0) return;
217
+ for (const sourceId of displayRuleSourceIds(field)) {
218
+ if (sourceId !== id && firstById.has(sourceId)) visit(sourceId);
219
+ }
220
+ path.pop();
221
+ visiting.delete(id);
222
+ visited.add(id);
223
+ };
224
+ for (const id of firstById.keys()) visit(id);
206
225
  return cyclic;
207
226
  }
208
227
  function validateSchemaStructure(schema) {
@@ -234,20 +253,20 @@ function validateSchemaStructure(schema) {
234
253
  }
235
254
  }
236
255
  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
- });
256
+ for (const sourceId of displayRuleSourceIds(field)) {
257
+ if (sourceId === field.id) {
258
+ issues.push({
259
+ type: "self_condition_reference",
260
+ questionId: field.id,
261
+ message: `Question "${field.id}" cannot depend on itself.`
262
+ });
263
+ } else if (!questionIds.has(sourceId)) {
264
+ issues.push({
265
+ type: "dangling_condition_reference",
266
+ questionId: field.id,
267
+ message: `Question "${field.id}" references missing question "${sourceId}".`
268
+ });
269
+ }
251
270
  }
252
271
  }
253
272
  const cyclic = cyclicQuestionIds(schema.fields);
@@ -256,6 +275,11 @@ function validateSchemaStructure(schema) {
256
275
  issues.push({
257
276
  type: "cyclic_condition_reference",
258
277
  questionId: field.id,
278
+ cycle: [
279
+ field.id,
280
+ ...displayRuleSourceIds(field).filter((sourceId) => cyclic.has(sourceId)).slice(0, 1),
281
+ field.id
282
+ ],
259
283
  message: `Question "${field.id}" participates in a display-condition cycle.`
260
284
  });
261
285
  }
@@ -270,11 +294,17 @@ function sanitizeSchema(schema, options = {}) {
270
294
  const cyclic = cyclicQuestionIds(schema.fields);
271
295
  const sanitizedFields = schema.fields.map((sourceField) => {
272
296
  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;
297
+ let sanitized = field;
298
+ const ruleSources = displayRuleSourceIds(field);
299
+ if (field.displayRule !== void 0 && (cyclic.has(field.id) || ruleSources.some((sourceId2) => sourceId2 === field.id || !existingQuestionIds.has(sourceId2)))) {
300
+ const { displayRule: _displayRule, ...withoutRule } = sanitized;
301
+ sanitized = withoutRule;
302
+ }
303
+ const sourceId = sanitized.displayCondition?.questionId;
304
+ if (sourceId !== void 0 && (!existingQuestionIds.has(sourceId) || sourceId === sanitized.id || cyclic.has(sanitized.id))) {
305
+ const { displayCondition: _displayCondition, ...withoutCondition } = sanitized;
306
+ return withoutCondition;
276
307
  }
277
- const { displayCondition: _displayCondition, ...sanitized } = field;
278
308
  return sanitized;
279
309
  });
280
310
  const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
@@ -317,7 +347,17 @@ function sanitizeSchema(schema, options = {}) {
317
347
 
318
348
  // src/schema.ts
319
349
  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"]);
350
+ var CONDITION_OPERATORS = /* @__PURE__ */ new Set([
351
+ "equals",
352
+ "not_equals",
353
+ "contains",
354
+ "not_contains",
355
+ "is_empty",
356
+ "is_not_empty",
357
+ "greater_than",
358
+ "less_than",
359
+ "not_empty"
360
+ ]);
321
361
  function isRecord(value) {
322
362
  return typeof value === "object" && value !== null && !Array.isArray(value);
323
363
  }
@@ -474,7 +514,7 @@ function validateDisplayCondition(value, path, issues) {
474
514
  return false;
475
515
  }
476
516
  const hasValue = Object.hasOwn(value, "value") && value.value !== void 0;
477
- if (value.operator === "not_empty") {
517
+ if (value.operator === "not_empty" || value.operator === "is_empty" || value.operator === "is_not_empty") {
478
518
  if (hasValue) issue(issues, `${path}.value`, "unexpected_condition_value", "not_empty does not accept a value.");
479
519
  } else if (!hasValue) {
480
520
  issue(issues, `${path}.value`, "missing_condition_value", `${value.operator} requires a value.`);
@@ -485,6 +525,75 @@ function validateDisplayCondition(value, path, issues) {
485
525
  }
486
526
  return true;
487
527
  }
528
+ function validateDisplayConditionGroup(value, path, issues) {
529
+ if (!isRecord(value)) {
530
+ issue(issues, path, "invalid_condition_group", "Expected a display condition group.");
531
+ return false;
532
+ }
533
+ if (value.logic !== "all" && value.logic !== "any") {
534
+ issue(issues, `${path}.logic`, "invalid_condition_logic", "Expected all or any.");
535
+ }
536
+ if (!Array.isArray(value.conditions)) {
537
+ issue(issues, `${path}.conditions`, "invalid_condition_group", "Expected a conditions array.");
538
+ return false;
539
+ }
540
+ value.conditions.forEach((condition, index) => {
541
+ const conditionPath = `${path}.conditions[${index}]`;
542
+ if (isRecord(condition) && Object.hasOwn(condition, "logic")) {
543
+ validateDisplayConditionGroup(condition, conditionPath, issues);
544
+ return;
545
+ }
546
+ if (!isRecord(condition)) {
547
+ issue(issues, conditionPath, "invalid_condition", "Expected a field display condition.");
548
+ return;
549
+ }
550
+ if (!isNonEmptyString(condition.fieldId)) {
551
+ issue(issues, `${conditionPath}.fieldId`, "invalid_condition_source", "Expected a field ID.");
552
+ }
553
+ if (typeof condition.operator !== "string" || !CONDITION_OPERATORS.has(condition.operator)) {
554
+ issue(issues, `${conditionPath}.operator`, "invalid_condition_operator", "Unsupported condition operator.");
555
+ return;
556
+ }
557
+ const needsValue = !["not_empty", "is_empty", "is_not_empty"].includes(condition.operator);
558
+ const hasValue = Object.hasOwn(condition, "value") && condition.value !== void 0;
559
+ if (needsValue && !hasValue)
560
+ issue(issues, `${conditionPath}.value`, "missing_condition_value", "A value is required.");
561
+ });
562
+ return true;
563
+ }
564
+ function validateDisplayRule(value, path, issues) {
565
+ if (!isRecord(value)) {
566
+ issue(issues, path, "invalid_display_rule", "Expected a display rule object.");
567
+ return false;
568
+ }
569
+ if (value.action !== "show" && value.action !== "hide") {
570
+ issue(issues, `${path}.action`, "invalid_display_action", "Expected show or hide.");
571
+ }
572
+ validateDisplayConditionGroup(value.condition, `${path}.condition`, issues);
573
+ return true;
574
+ }
575
+ function validateSubmissionSettings(value, path, issues) {
576
+ if (!isRecord(value)) {
577
+ issue(issues, path, "invalid_submission_settings", "Expected submission settings to be an object.");
578
+ return;
579
+ }
580
+ validateExtensibleNode(value, path, issues);
581
+ if (value.showConfirmationBeforeSubmit !== void 0 && typeof value.showConfirmationBeforeSubmit !== "boolean") {
582
+ issue(issues, `${path}.showConfirmationBeforeSubmit`, "invalid_submission_setting", "Expected a boolean.");
583
+ }
584
+ if (value.confirmationRenderMode !== void 0 && !["dialog", "inline", "replace"].includes(String(value.confirmationRenderMode))) {
585
+ issue(
586
+ issues,
587
+ `${path}.confirmationRenderMode`,
588
+ "invalid_submission_setting",
589
+ "Unsupported confirmation render mode."
590
+ );
591
+ }
592
+ for (const key of ["confirmButtonLabel", "cancelButtonLabel"]) {
593
+ if (value[key] !== void 0 && !isNonEmptyString(value[key]))
594
+ issue(issues, `${path}.${key}`, "invalid_submission_setting", "Expected non-empty text.");
595
+ }
596
+ }
488
597
  function validateField(value, path, issues) {
489
598
  if (!isRecord(value)) {
490
599
  issue(issues, path, "invalid_field", "Expected a field object.");
@@ -508,6 +617,7 @@ function validateField(value, path, issues) {
508
617
  if (value.displayCondition !== void 0) {
509
618
  validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
510
619
  }
620
+ if (value.displayRule !== void 0) validateDisplayRule(value.displayRule, `${path}.displayRule`, issues);
511
621
  if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
512
622
  if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
513
623
  issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
@@ -892,6 +1002,8 @@ function validateFormSchema(input, options = {}) {
892
1002
  }
893
1003
  }
894
1004
  if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
1005
+ if (input.submissionSettings !== void 0)
1006
+ validateSubmissionSettings(input.submissionSettings, "submissionSettings", issues);
895
1007
  if (!Array.isArray(input.fields) || input.fields.length === 0) {
896
1008
  issue(issues, "fields", "invalid_fields", "Expected at least one field.");
897
1009
  } else {
@@ -915,8 +1027,19 @@ function validateFormSchema(input, options = {}) {
915
1027
  continue;
916
1028
  }
917
1029
  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);
1030
+ const currentField = inputFields[fieldIndex];
1031
+ const usesDisplayRule = isRecord(currentField) && currentField.displayRule !== void 0;
1032
+ const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : usesDisplayRule ? "circular_display_condition" : "condition_cycle";
1033
+ const path = usesDisplayRule ? `fields[${fieldIndex}].displayRule` : `fields[${fieldIndex}].displayCondition`;
1034
+ if (usesDisplayRule && structuralIssue.type === "cyclic_condition_reference") {
1035
+ issues.push({
1036
+ path,
1037
+ code,
1038
+ type: code,
1039
+ message: structuralIssue.message,
1040
+ ...structuralIssue.cycle === void 0 ? {} : { cycle: structuralIssue.cycle }
1041
+ });
1042
+ } else issue(issues, path, code, structuralIssue.message);
920
1043
  }
921
1044
  if (input.pages !== void 0) {
922
1045
  if (!Array.isArray(input.pages) || input.pages.length === 0) {
@@ -1025,23 +1148,61 @@ function normalizeString(value) {
1025
1148
  return value.trim().normalize("NFKC").toLowerCase();
1026
1149
  }
1027
1150
  function valuesEqual(left, right) {
1028
- return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
1151
+ if (typeof left === "string" && typeof right === "string") return normalizeString(left) === normalizeString(right);
1152
+ if (Array.isArray(left) && Array.isArray(right)) {
1153
+ return left.length === right.length && left.every((item, index) => valuesEqual(item, right[index]));
1154
+ }
1155
+ return left === right;
1156
+ }
1157
+ function evaluateFieldCondition(condition, currentAnswers) {
1158
+ const sourceId = "fieldId" in condition ? condition.fieldId : condition.questionId;
1159
+ const answer = currentAnswers[sourceId];
1160
+ if (isEmpty(answer) && ["equals", "not_equals", "contains"].includes(condition.operator)) return false;
1161
+ if (condition.operator === "is_empty") return isEmpty(answer);
1162
+ if (condition.operator === "is_not_empty" || condition.operator === "not_empty") return !isEmpty(answer);
1163
+ if (condition.operator === "contains" || condition.operator === "not_contains") {
1164
+ 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));
1165
+ return condition.operator === "not_contains" ? !contains : contains;
1166
+ }
1167
+ if (condition.value === void 0) return false;
1168
+ if (condition.operator === "equals") return valuesEqual(answer, condition.value);
1169
+ if (condition.operator === "not_equals") return !valuesEqual(answer, condition.value);
1170
+ if (condition.operator === "greater_than")
1171
+ return typeof answer === "number" && typeof condition.value === "number" && answer > condition.value;
1172
+ if (condition.operator === "less_than")
1173
+ return typeof answer === "number" && typeof condition.value === "number" && answer < condition.value;
1174
+ return false;
1175
+ }
1176
+ function isDisplayConditionGroupSatisfied(group, currentAnswers) {
1177
+ const results = group.conditions.map(
1178
+ (condition) => "logic" in condition ? isDisplayConditionGroupSatisfied(condition, currentAnswers) : evaluateFieldCondition(condition, currentAnswers)
1179
+ );
1180
+ return group.logic === "all" ? results.every(Boolean) : results.some(Boolean);
1029
1181
  }
1030
1182
  function isQuestionVisible(question, currentAnswers) {
1183
+ if (question.displayRule !== void 0) {
1184
+ const satisfied = isDisplayConditionGroupSatisfied(question.displayRule.condition, currentAnswers);
1185
+ return question.displayRule.action === "hide" ? !satisfied : satisfied;
1186
+ }
1031
1187
  return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
1032
1188
  }
1033
1189
  function isDisplayConditionSatisfied(condition, currentAnswers) {
1034
1190
  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));
1191
+ if ("logic" in condition) return isDisplayConditionGroupSatisfied(condition, currentAnswers);
1192
+ return evaluateFieldCondition(condition, currentAnswers);
1193
+ }
1194
+ function conditionSourceIds(condition) {
1195
+ if (condition === void 0) return [];
1196
+ if ("questionId" in condition) return [condition.questionId];
1197
+ const ids = [];
1198
+ const visit = (group) => {
1199
+ for (const item of group.conditions) {
1200
+ if ("logic" in item) visit(item);
1201
+ else ids.push(item.fieldId);
1202
+ }
1203
+ };
1204
+ visit(condition.condition);
1205
+ return ids;
1045
1206
  }
1046
1207
  function calculateBaseFieldVisibility(schema, currentAnswers) {
1047
1208
  const fields = new Map(schema.fields.map((field) => [field.id, field]));
@@ -1052,10 +1213,12 @@ function calculateBaseFieldVisibility(schema, currentAnswers) {
1052
1213
  if (existing !== void 0) return existing;
1053
1214
  if (resolving.has(field.id)) return false;
1054
1215
  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);
1216
+ const sources = field.displayRule === void 0 ? conditionSourceIds(field.displayCondition) : conditionSourceIds(field.displayRule);
1217
+ const sourcesVisible = sources.every((sourceId) => {
1218
+ const source = fields.get(sourceId);
1219
+ return source !== void 0 && source.id !== field.id && resolve(source);
1220
+ });
1221
+ const visible = sourcesVisible && isQuestionVisible(field, currentAnswers);
1059
1222
  resolving.delete(field.id);
1060
1223
  resolved.set(field.id, visible);
1061
1224
  return visible;
@@ -2053,6 +2216,30 @@ function createSubmission(schema, values, options) {
2053
2216
  }
2054
2217
 
2055
2218
  // src/translation.ts
2219
+ var computeSourceTextHash = (text) => {
2220
+ const normalized = text.normalize("NFKC").trim().replace(/\s+/gu, " ");
2221
+ let hash = 2166136261;
2222
+ for (let index = 0; index < normalized.length; index += 1) {
2223
+ hash ^= normalized.charCodeAt(index);
2224
+ hash = Math.imul(hash, 16777619);
2225
+ }
2226
+ return (hash >>> 0).toString(16).padStart(8, "0");
2227
+ };
2228
+ function getTranslationStatus(sourceText, translatedText, metadata) {
2229
+ if (translatedText === void 0 || translatedText.trim() === "") return "missing";
2230
+ if (metadata === void 0) return "translated";
2231
+ const currentHash = computeSourceTextHash(sourceText);
2232
+ const sourceHash = typeof metadata.sourceTextHash === "string" ? metadata.sourceTextHash : void 0;
2233
+ const isManual = metadata.translationSource === "manual" || "isManual" in metadata && metadata.isManual === true;
2234
+ if (isManual) return sourceHash === void 0 || sourceHash === currentHash ? "manual" : "manual-stale";
2235
+ return sourceHash === void 0 || sourceHash === currentHash ? "translated" : "stale";
2236
+ }
2237
+ async function translateBatch(adapter, texts, locale, sourceLocale) {
2238
+ if ("translateBatch" in adapter) return adapter.translateBatch(texts, locale, sourceLocale);
2239
+ return texts.map(
2240
+ (text) => adapter.translate(text, locale, sourceLocale === void 0 ? void 0 : { sourceLocale }) ?? text
2241
+ );
2242
+ }
2056
2243
  function mergeLocalizedText(translations, locale, property, value) {
2057
2244
  return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
2058
2245
  }
@@ -2070,12 +2257,17 @@ function withTranslationMetadata(node, locale, property, metadata) {
2070
2257
  };
2071
2258
  }
2072
2259
  function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata) {
2260
+ const status = getTranslationStatus(sourceText, existingText, existingTranslationMetadata);
2073
2261
  return {
2074
2262
  kind,
2075
2263
  nodeId,
2076
2264
  property,
2077
2265
  locale,
2078
2266
  sourceText,
2267
+ target: { kind, ...nodeId.length === 0 ? {} : { id: nodeId }, property },
2268
+ path: `${kind}.${nodeId}.${property}`,
2269
+ sourceTextHash: computeSourceTextHash(sourceText),
2270
+ status,
2079
2271
  ...existingText === void 0 ? {} : { existingText },
2080
2272
  ...nodeMetadata === void 0 ? {} : { nodeMetadata, metadata: nodeMetadata },
2081
2273
  ...existingTranslationMetadata === void 0 ? {} : { existingTranslationMetadata }
@@ -2214,6 +2406,9 @@ function translationSlots(schema, locale) {
2214
2406
  });
2215
2407
  return descriptors;
2216
2408
  }
2409
+ function collectTranslationSlots(schema, locale) {
2410
+ return translationSlots(schema, locale).map((descriptor) => descriptor.slot);
2411
+ }
2217
2412
  function resolveLocalizedSchema(schema, targetLocale) {
2218
2413
  if (targetLocale === void 0 || targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
2219
2414
  const formTranslation = schema.translations?.[targetLocale];
@@ -2270,17 +2465,31 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2270
2465
  }
2271
2466
  const updatedSlots = [];
2272
2467
  const skippedSlots = [];
2468
+ const staleSlots = [];
2469
+ const skippedReasons = {};
2273
2470
  let result = schema;
2274
2471
  for (const locale of locales) {
2275
2472
  const descriptors = translationSlots(schema, locale);
2473
+ if (options.markStaleTranslations ?? true) {
2474
+ for (const descriptor of descriptors) {
2475
+ if (descriptor.slot.status === "stale" || descriptor.slot.status === "manual-stale")
2476
+ staleSlots.push(descriptor.slot);
2477
+ }
2478
+ }
2276
2479
  const selected = [];
2277
2480
  for (const descriptor of descriptors) {
2278
- const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
2481
+ const status = descriptor.slot.status ?? "missing";
2482
+ const manual = status === "manual" || status === "manual-stale";
2483
+ const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? ((!(options.preserveManualTranslations ?? true) || !manual) && (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
2484
  if (shouldTranslate) selected.push(descriptor);
2280
- else skippedSlots.push(descriptor.slot);
2485
+ else {
2486
+ skippedSlots.push(descriptor.slot);
2487
+ skippedReasons[descriptor.slot.path ?? `${descriptor.slot.kind}.${descriptor.slot.nodeId}.${descriptor.slot.property}`] = manual ? "manual" : "unchanged";
2488
+ }
2281
2489
  }
2282
2490
  if (selected.length === 0) continue;
2283
- const translated = await adapter.translateBatch(
2491
+ const translated = await translateBatch(
2492
+ adapter,
2284
2493
  selected.map((descriptor) => descriptor.slot.sourceText),
2285
2494
  locale,
2286
2495
  schema.defaultLocale
@@ -2291,7 +2500,12 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2291
2500
  selected.forEach((descriptor, index) => {
2292
2501
  const translatedText = translated[index];
2293
2502
  if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
2294
- const metadata = options.createMetadata?.(descriptor.slot, translatedText);
2503
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText) ?? {
2504
+ sourceLocale: schema.defaultLocale ?? "",
2505
+ sourceTextHash: computeSourceTextHash(descriptor.slot.sourceText),
2506
+ translationSource: "automatic",
2507
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString()
2508
+ };
2295
2509
  result = descriptor.apply(result, translatedText, metadata);
2296
2510
  updatedSlots.push(descriptor.slot);
2297
2511
  });
@@ -2305,7 +2519,7 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2305
2519
  ];
2306
2520
  if (supportedLocales.length > 0) result = { ...result, supportedLocales };
2307
2521
  assertValidFormSchema(result);
2308
- return { schema: result, report: { updatedSlots, skippedSlots } };
2522
+ return { schema: result, report: { updatedSlots, skippedSlots, staleSlots, skippedReasons } };
2309
2523
  }
2310
2524
  async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
2311
2525
  const populated = await populateSchemaTranslations(
@@ -2609,6 +2823,8 @@ function assertVersionMutable(status) {
2609
2823
  calculatePageVisibility,
2610
2824
  cloneVersionToDraft,
2611
2825
  collectSchemaLocales,
2826
+ collectTranslationSlots,
2827
+ computeSourceTextHash,
2612
2828
  createCloneTransitionPlan,
2613
2829
  createDeleteDraftTransitionPlan,
2614
2830
  createPublishTransitionPlan,
@@ -2623,6 +2839,8 @@ function assertVersionMutable(status) {
2623
2839
  escapeCsvCell,
2624
2840
  exportResponsesToCsv,
2625
2841
  exportResponsesToCsvStream,
2842
+ getTranslationStatus,
2843
+ isDisplayConditionGroupSatisfied,
2626
2844
  isDisplayConditionSatisfied,
2627
2845
  isQuestionVisible,
2628
2846
  iterateSubmissionPages,