@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.js CHANGED
@@ -47,6 +47,19 @@ function collectSchemaLocales(schema) {
47
47
  }
48
48
 
49
49
  // src/sanitization.ts
50
+ function displayRuleSourceIds(field) {
51
+ if (field.displayRule === void 0)
52
+ return field.displayCondition?.questionId === void 0 ? [] : [field.displayCondition.questionId];
53
+ const ids = [];
54
+ const visit = (group) => {
55
+ for (const condition of group.conditions) {
56
+ if ("logic" in condition) visit(condition);
57
+ else ids.push(condition.fieldId);
58
+ }
59
+ };
60
+ visit(field.displayRule.condition);
61
+ return ids;
62
+ }
50
63
  function registeredEntries(value, registeredLocales) {
51
64
  if (value === void 0) return void 0;
52
65
  const entries = Object.entries(value).filter(([locale]) => registeredLocales.has(locale));
@@ -114,26 +127,28 @@ function cyclicQuestionIds(fields) {
114
127
  if (!firstById.has(field.id)) firstById.set(field.id, field);
115
128
  }
116
129
  const cyclic = /* @__PURE__ */ new Set();
117
- const resolved = /* @__PURE__ */ new Set();
118
- for (const startId of firstById.keys()) {
119
- if (resolved.has(startId)) continue;
120
- const path = [];
121
- const pathIndex = /* @__PURE__ */ new Map();
122
- let currentId = startId;
123
- while (currentId !== void 0 && firstById.has(currentId) && !resolved.has(currentId)) {
124
- const existingIndex = pathIndex.get(currentId);
125
- if (existingIndex !== void 0) {
126
- for (const id of path.slice(existingIndex)) cyclic.add(id);
127
- break;
128
- }
129
- pathIndex.set(currentId, path.length);
130
- path.push(currentId);
131
- const field = firstById.get(currentId);
132
- const sourceId = field?.displayCondition?.questionId;
133
- currentId = sourceId === currentId ? void 0 : sourceId;
130
+ const visiting = /* @__PURE__ */ new Set();
131
+ const visited = /* @__PURE__ */ new Set();
132
+ const path = [];
133
+ const visit = (id) => {
134
+ if (visiting.has(id)) {
135
+ const start = path.indexOf(id);
136
+ for (const member of path.slice(start < 0 ? 0 : start)) cyclic.add(member);
137
+ return;
134
138
  }
135
- for (const id of path) resolved.add(id);
136
- }
139
+ if (visited.has(id)) return;
140
+ visiting.add(id);
141
+ path.push(id);
142
+ const field = firstById.get(id);
143
+ if (field === void 0) return;
144
+ for (const sourceId of displayRuleSourceIds(field)) {
145
+ if (sourceId !== id && firstById.has(sourceId)) visit(sourceId);
146
+ }
147
+ path.pop();
148
+ visiting.delete(id);
149
+ visited.add(id);
150
+ };
151
+ for (const id of firstById.keys()) visit(id);
137
152
  return cyclic;
138
153
  }
139
154
  function validateSchemaStructure(schema) {
@@ -165,20 +180,20 @@ function validateSchemaStructure(schema) {
165
180
  }
166
181
  }
167
182
  for (const field of schema.fields) {
168
- const sourceId = field.displayCondition?.questionId;
169
- if (sourceId === void 0) continue;
170
- if (sourceId === field.id) {
171
- issues.push({
172
- type: "self_condition_reference",
173
- questionId: field.id,
174
- message: `Question "${field.id}" cannot depend on itself.`
175
- });
176
- } else if (!questionIds.has(sourceId)) {
177
- issues.push({
178
- type: "dangling_condition_reference",
179
- questionId: field.id,
180
- message: `Question "${field.id}" references missing question "${sourceId}".`
181
- });
183
+ for (const sourceId of displayRuleSourceIds(field)) {
184
+ if (sourceId === field.id) {
185
+ issues.push({
186
+ type: "self_condition_reference",
187
+ questionId: field.id,
188
+ message: `Question "${field.id}" cannot depend on itself.`
189
+ });
190
+ } else if (!questionIds.has(sourceId)) {
191
+ issues.push({
192
+ type: "dangling_condition_reference",
193
+ questionId: field.id,
194
+ message: `Question "${field.id}" references missing question "${sourceId}".`
195
+ });
196
+ }
182
197
  }
183
198
  }
184
199
  const cyclic = cyclicQuestionIds(schema.fields);
@@ -187,6 +202,11 @@ function validateSchemaStructure(schema) {
187
202
  issues.push({
188
203
  type: "cyclic_condition_reference",
189
204
  questionId: field.id,
205
+ cycle: [
206
+ field.id,
207
+ ...displayRuleSourceIds(field).filter((sourceId) => cyclic.has(sourceId)).slice(0, 1),
208
+ field.id
209
+ ],
190
210
  message: `Question "${field.id}" participates in a display-condition cycle.`
191
211
  });
192
212
  }
@@ -201,11 +221,17 @@ function sanitizeSchema(schema, options = {}) {
201
221
  const cyclic = cyclicQuestionIds(schema.fields);
202
222
  const sanitizedFields = schema.fields.map((sourceField) => {
203
223
  const field = sanitizeFieldConstraints(sanitizeFieldLocales(sourceField, registeredLocales), options.policy);
204
- const sourceId = field.displayCondition?.questionId;
205
- if (sourceId === void 0 || existingQuestionIds.has(sourceId) && sourceId !== field.id && !cyclic.has(field.id)) {
206
- return field;
224
+ let sanitized = field;
225
+ const ruleSources = displayRuleSourceIds(field);
226
+ if (field.displayRule !== void 0 && (cyclic.has(field.id) || ruleSources.some((sourceId2) => sourceId2 === field.id || !existingQuestionIds.has(sourceId2)))) {
227
+ const { displayRule: _displayRule, ...withoutRule } = sanitized;
228
+ sanitized = withoutRule;
229
+ }
230
+ const sourceId = sanitized.displayCondition?.questionId;
231
+ if (sourceId !== void 0 && (!existingQuestionIds.has(sourceId) || sourceId === sanitized.id || cyclic.has(sanitized.id))) {
232
+ const { displayCondition: _displayCondition, ...withoutCondition } = sanitized;
233
+ return withoutCondition;
207
234
  }
208
- const { displayCondition: _displayCondition, ...sanitized } = field;
209
235
  return sanitized;
210
236
  });
211
237
  const localizedSchema = sanitizeNodeLocales(schema, registeredLocales);
@@ -248,7 +274,17 @@ function sanitizeSchema(schema, options = {}) {
248
274
 
249
275
  // src/schema.ts
250
276
  var FIELD_TYPES = /* @__PURE__ */ new Set(["text", "textarea", "number", "rating", "select", "multi-select", "checkbox", "radio"]);
251
- var CONDITION_OPERATORS = /* @__PURE__ */ new Set(["equals", "not_equals", "contains", "not_empty"]);
277
+ var CONDITION_OPERATORS = /* @__PURE__ */ new Set([
278
+ "equals",
279
+ "not_equals",
280
+ "contains",
281
+ "not_contains",
282
+ "is_empty",
283
+ "is_not_empty",
284
+ "greater_than",
285
+ "less_than",
286
+ "not_empty"
287
+ ]);
252
288
  function isRecord(value) {
253
289
  return typeof value === "object" && value !== null && !Array.isArray(value);
254
290
  }
@@ -405,7 +441,7 @@ function validateDisplayCondition(value, path, issues) {
405
441
  return false;
406
442
  }
407
443
  const hasValue = Object.hasOwn(value, "value") && value.value !== void 0;
408
- if (value.operator === "not_empty") {
444
+ if (value.operator === "not_empty" || value.operator === "is_empty" || value.operator === "is_not_empty") {
409
445
  if (hasValue) issue(issues, `${path}.value`, "unexpected_condition_value", "not_empty does not accept a value.");
410
446
  } else if (!hasValue) {
411
447
  issue(issues, `${path}.value`, "missing_condition_value", `${value.operator} requires a value.`);
@@ -416,6 +452,75 @@ function validateDisplayCondition(value, path, issues) {
416
452
  }
417
453
  return true;
418
454
  }
455
+ function validateDisplayConditionGroup(value, path, issues) {
456
+ if (!isRecord(value)) {
457
+ issue(issues, path, "invalid_condition_group", "Expected a display condition group.");
458
+ return false;
459
+ }
460
+ if (value.logic !== "all" && value.logic !== "any") {
461
+ issue(issues, `${path}.logic`, "invalid_condition_logic", "Expected all or any.");
462
+ }
463
+ if (!Array.isArray(value.conditions)) {
464
+ issue(issues, `${path}.conditions`, "invalid_condition_group", "Expected a conditions array.");
465
+ return false;
466
+ }
467
+ value.conditions.forEach((condition, index) => {
468
+ const conditionPath = `${path}.conditions[${index}]`;
469
+ if (isRecord(condition) && Object.hasOwn(condition, "logic")) {
470
+ validateDisplayConditionGroup(condition, conditionPath, issues);
471
+ return;
472
+ }
473
+ if (!isRecord(condition)) {
474
+ issue(issues, conditionPath, "invalid_condition", "Expected a field display condition.");
475
+ return;
476
+ }
477
+ if (!isNonEmptyString(condition.fieldId)) {
478
+ issue(issues, `${conditionPath}.fieldId`, "invalid_condition_source", "Expected a field ID.");
479
+ }
480
+ if (typeof condition.operator !== "string" || !CONDITION_OPERATORS.has(condition.operator)) {
481
+ issue(issues, `${conditionPath}.operator`, "invalid_condition_operator", "Unsupported condition operator.");
482
+ return;
483
+ }
484
+ const needsValue = !["not_empty", "is_empty", "is_not_empty"].includes(condition.operator);
485
+ const hasValue = Object.hasOwn(condition, "value") && condition.value !== void 0;
486
+ if (needsValue && !hasValue)
487
+ issue(issues, `${conditionPath}.value`, "missing_condition_value", "A value is required.");
488
+ });
489
+ return true;
490
+ }
491
+ function validateDisplayRule(value, path, issues) {
492
+ if (!isRecord(value)) {
493
+ issue(issues, path, "invalid_display_rule", "Expected a display rule object.");
494
+ return false;
495
+ }
496
+ if (value.action !== "show" && value.action !== "hide") {
497
+ issue(issues, `${path}.action`, "invalid_display_action", "Expected show or hide.");
498
+ }
499
+ validateDisplayConditionGroup(value.condition, `${path}.condition`, issues);
500
+ return true;
501
+ }
502
+ function validateSubmissionSettings(value, path, issues) {
503
+ if (!isRecord(value)) {
504
+ issue(issues, path, "invalid_submission_settings", "Expected submission settings to be an object.");
505
+ return;
506
+ }
507
+ validateExtensibleNode(value, path, issues);
508
+ if (value.showConfirmationBeforeSubmit !== void 0 && typeof value.showConfirmationBeforeSubmit !== "boolean") {
509
+ issue(issues, `${path}.showConfirmationBeforeSubmit`, "invalid_submission_setting", "Expected a boolean.");
510
+ }
511
+ if (value.confirmationRenderMode !== void 0 && !["dialog", "inline", "replace"].includes(String(value.confirmationRenderMode))) {
512
+ issue(
513
+ issues,
514
+ `${path}.confirmationRenderMode`,
515
+ "invalid_submission_setting",
516
+ "Unsupported confirmation render mode."
517
+ );
518
+ }
519
+ for (const key of ["confirmButtonLabel", "cancelButtonLabel"]) {
520
+ if (value[key] !== void 0 && !isNonEmptyString(value[key]))
521
+ issue(issues, `${path}.${key}`, "invalid_submission_setting", "Expected non-empty text.");
522
+ }
523
+ }
419
524
  function validateField(value, path, issues) {
420
525
  if (!isRecord(value)) {
421
526
  issue(issues, path, "invalid_field", "Expected a field object.");
@@ -439,6 +544,7 @@ function validateField(value, path, issues) {
439
544
  if (value.displayCondition !== void 0) {
440
545
  validateDisplayCondition(value.displayCondition, `${path}.displayCondition`, issues);
441
546
  }
547
+ if (value.displayRule !== void 0) validateDisplayRule(value.displayRule, `${path}.displayRule`, issues);
442
548
  if (value.translations !== void 0) validateLocalizedTextMap(value.translations, `${path}.translations`, issues);
443
549
  if (typeof value.type !== "string" || !FIELD_TYPES.has(value.type)) {
444
550
  issue(issues, `${path}.type`, "invalid_field_type", "Unsupported field type.");
@@ -823,6 +929,8 @@ function validateFormSchema(input, options = {}) {
823
929
  }
824
930
  }
825
931
  if (input.translations !== void 0) validateLocalizedTextMap(input.translations, "translations", issues);
932
+ if (input.submissionSettings !== void 0)
933
+ validateSubmissionSettings(input.submissionSettings, "submissionSettings", issues);
826
934
  if (!Array.isArray(input.fields) || input.fields.length === 0) {
827
935
  issue(issues, "fields", "invalid_fields", "Expected at least one field.");
828
936
  } else {
@@ -846,8 +954,19 @@ function validateFormSchema(input, options = {}) {
846
954
  continue;
847
955
  }
848
956
  const fieldIndex = inputFields.findIndex((field) => isRecord(field) && field.id === structuralIssue.questionId);
849
- const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : "condition_cycle";
850
- issue(issues, `fields[${fieldIndex}].displayCondition`, code, structuralIssue.message);
957
+ const currentField = inputFields[fieldIndex];
958
+ const usesDisplayRule = isRecord(currentField) && currentField.displayRule !== void 0;
959
+ const code = structuralIssue.type === "dangling_condition_reference" ? "unknown_condition_source" : structuralIssue.type === "self_condition_reference" ? "self_condition" : usesDisplayRule ? "circular_display_condition" : "condition_cycle";
960
+ const path = usesDisplayRule ? `fields[${fieldIndex}].displayRule` : `fields[${fieldIndex}].displayCondition`;
961
+ if (usesDisplayRule && structuralIssue.type === "cyclic_condition_reference") {
962
+ issues.push({
963
+ path,
964
+ code,
965
+ type: code,
966
+ message: structuralIssue.message,
967
+ ...structuralIssue.cycle === void 0 ? {} : { cycle: structuralIssue.cycle }
968
+ });
969
+ } else issue(issues, path, code, structuralIssue.message);
851
970
  }
852
971
  if (input.pages !== void 0) {
853
972
  if (!Array.isArray(input.pages) || input.pages.length === 0) {
@@ -956,23 +1075,61 @@ function normalizeString(value) {
956
1075
  return value.trim().normalize("NFKC").toLowerCase();
957
1076
  }
958
1077
  function valuesEqual(left, right) {
959
- return typeof left === "string" && typeof right === "string" ? normalizeString(left) === normalizeString(right) : left === right;
1078
+ if (typeof left === "string" && typeof right === "string") return normalizeString(left) === normalizeString(right);
1079
+ if (Array.isArray(left) && Array.isArray(right)) {
1080
+ return left.length === right.length && left.every((item, index) => valuesEqual(item, right[index]));
1081
+ }
1082
+ return left === right;
1083
+ }
1084
+ function evaluateFieldCondition(condition, currentAnswers) {
1085
+ const sourceId = "fieldId" in condition ? condition.fieldId : condition.questionId;
1086
+ const answer = currentAnswers[sourceId];
1087
+ if (isEmpty(answer) && ["equals", "not_equals", "contains"].includes(condition.operator)) return false;
1088
+ if (condition.operator === "is_empty") return isEmpty(answer);
1089
+ if (condition.operator === "is_not_empty" || condition.operator === "not_empty") return !isEmpty(answer);
1090
+ if (condition.operator === "contains" || condition.operator === "not_contains") {
1091
+ 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));
1092
+ return condition.operator === "not_contains" ? !contains : contains;
1093
+ }
1094
+ if (condition.value === void 0) return false;
1095
+ if (condition.operator === "equals") return valuesEqual(answer, condition.value);
1096
+ if (condition.operator === "not_equals") return !valuesEqual(answer, condition.value);
1097
+ if (condition.operator === "greater_than")
1098
+ return typeof answer === "number" && typeof condition.value === "number" && answer > condition.value;
1099
+ if (condition.operator === "less_than")
1100
+ return typeof answer === "number" && typeof condition.value === "number" && answer < condition.value;
1101
+ return false;
1102
+ }
1103
+ function isDisplayConditionGroupSatisfied(group, currentAnswers) {
1104
+ const results = group.conditions.map(
1105
+ (condition) => "logic" in condition ? isDisplayConditionGroupSatisfied(condition, currentAnswers) : evaluateFieldCondition(condition, currentAnswers)
1106
+ );
1107
+ return group.logic === "all" ? results.every(Boolean) : results.some(Boolean);
960
1108
  }
961
1109
  function isQuestionVisible(question, currentAnswers) {
1110
+ if (question.displayRule !== void 0) {
1111
+ const satisfied = isDisplayConditionGroupSatisfied(question.displayRule.condition, currentAnswers);
1112
+ return question.displayRule.action === "hide" ? !satisfied : satisfied;
1113
+ }
962
1114
  return isDisplayConditionSatisfied(question.displayCondition, currentAnswers);
963
1115
  }
964
1116
  function isDisplayConditionSatisfied(condition, currentAnswers) {
965
1117
  if (condition === void 0) return true;
966
- const answer = currentAnswers[condition.questionId];
967
- if (isEmpty(answer)) return false;
968
- if (condition.operator === "not_empty") return true;
969
- if (condition.value === void 0) return false;
970
- if (condition.operator === "equals") return valuesEqual(answer, condition.value);
971
- if (condition.operator === "not_equals") return !valuesEqual(answer, condition.value);
972
- if (typeof answer === "string" && typeof condition.value === "string") {
973
- return normalizeString(answer).includes(normalizeString(condition.value));
974
- }
975
- return Array.isArray(answer) && answer.some((item) => valuesEqual(item, condition.value));
1118
+ if ("logic" in condition) return isDisplayConditionGroupSatisfied(condition, currentAnswers);
1119
+ return evaluateFieldCondition(condition, currentAnswers);
1120
+ }
1121
+ function conditionSourceIds(condition) {
1122
+ if (condition === void 0) return [];
1123
+ if ("questionId" in condition) return [condition.questionId];
1124
+ const ids = [];
1125
+ const visit = (group) => {
1126
+ for (const item of group.conditions) {
1127
+ if ("logic" in item) visit(item);
1128
+ else ids.push(item.fieldId);
1129
+ }
1130
+ };
1131
+ visit(condition.condition);
1132
+ return ids;
976
1133
  }
977
1134
  function calculateBaseFieldVisibility(schema, currentAnswers) {
978
1135
  const fields = new Map(schema.fields.map((field) => [field.id, field]));
@@ -983,10 +1140,12 @@ function calculateBaseFieldVisibility(schema, currentAnswers) {
983
1140
  if (existing !== void 0) return existing;
984
1141
  if (resolving.has(field.id)) return false;
985
1142
  resolving.add(field.id);
986
- const sourceId = field.displayCondition?.questionId;
987
- const source = sourceId === void 0 ? void 0 : fields.get(sourceId);
988
- const sourceVisible = source === void 0 ? sourceId === void 0 : resolve(source);
989
- const visible = sourceVisible && isQuestionVisible(field, currentAnswers);
1143
+ const sources = field.displayRule === void 0 ? conditionSourceIds(field.displayCondition) : conditionSourceIds(field.displayRule);
1144
+ const sourcesVisible = sources.every((sourceId) => {
1145
+ const source = fields.get(sourceId);
1146
+ return source !== void 0 && source.id !== field.id && resolve(source);
1147
+ });
1148
+ const visible = sourcesVisible && isQuestionVisible(field, currentAnswers);
990
1149
  resolving.delete(field.id);
991
1150
  resolved.set(field.id, visible);
992
1151
  return visible;
@@ -1984,6 +2143,39 @@ function createSubmission(schema, values, options) {
1984
2143
  }
1985
2144
 
1986
2145
  // src/translation.ts
2146
+ var isManualTranslationMetadata = (metadata) => {
2147
+ if (metadata === void 0) return false;
2148
+ if ("isManuallyEdited" in metadata && metadata.isManuallyEdited === true || "isManual" in metadata && metadata.isManual === true)
2149
+ return true;
2150
+ return typeof metadata.translationSource === "string" && metadata.translationSource.toLowerCase() === "manual";
2151
+ };
2152
+ var computeSourceTextHash = (text) => {
2153
+ const normalized = text.normalize("NFKC").trim().replace(/\s+/gu, " ");
2154
+ let hash = 2166136261;
2155
+ for (let index = 0; index < normalized.length; index += 1) {
2156
+ hash ^= normalized.charCodeAt(index);
2157
+ hash = Math.imul(hash, 16777619);
2158
+ }
2159
+ return (hash >>> 0).toString(16).padStart(8, "0");
2160
+ };
2161
+ function getTranslationStatus(sourceText, translatedText, metadata) {
2162
+ return getTranslationStatusWithManualOverride(sourceText, translatedText, metadata, void 0);
2163
+ }
2164
+ function getTranslationStatusWithManualOverride(sourceText, translatedText, metadata, manualOverride) {
2165
+ if (translatedText === void 0 || translatedText.trim() === "") return "missing";
2166
+ if (metadata === void 0) return "translated";
2167
+ const currentHash = computeSourceTextHash(sourceText);
2168
+ const sourceHash = typeof metadata.sourceTextHash === "string" ? metadata.sourceTextHash : void 0;
2169
+ const isManual = manualOverride ?? isManualTranslationMetadata(metadata);
2170
+ if (isManual) return sourceHash === void 0 || sourceHash === currentHash ? "manual" : "manual-stale";
2171
+ return sourceHash === void 0 || sourceHash === currentHash ? "translated" : "stale";
2172
+ }
2173
+ async function translateBatch(adapter, texts, locale, sourceLocale) {
2174
+ if ("translateBatch" in adapter) return adapter.translateBatch(texts, locale, sourceLocale);
2175
+ return texts.map(
2176
+ (text) => adapter.translate(text, locale, sourceLocale === void 0 ? void 0 : { sourceLocale }) ?? text
2177
+ );
2178
+ }
1987
2179
  function mergeLocalizedText(translations, locale, property, value) {
1988
2180
  return { ...translations, [locale]: { ...translations?.[locale], [property]: value } };
1989
2181
  }
@@ -2000,19 +2192,27 @@ function withTranslationMetadata(node, locale, property, metadata) {
2000
2192
  }
2001
2193
  };
2002
2194
  }
2003
- function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata) {
2195
+ function createSlot(kind, nodeId, property, locale, sourceText, existingText, nodeMetadata, existingTranslationMetadata, options = {}) {
2196
+ const path = `${kind}.${nodeId}.${property}`;
2197
+ const manual = options.isManualTranslation?.(existingTranslationMetadata, { path, locale }) ?? isManualTranslationMetadata(existingTranslationMetadata);
2198
+ const normalizedMetadata = existingTranslationMetadata === void 0 ? void 0 : options.normalizeMetadata === void 0 ? existingTranslationMetadata : { ...options.normalizeMetadata(existingTranslationMetadata, sourceText) };
2199
+ const status = getTranslationStatusWithManualOverride(sourceText, existingText, normalizedMetadata, manual);
2004
2200
  return {
2005
2201
  kind,
2006
2202
  nodeId,
2007
2203
  property,
2008
2204
  locale,
2009
2205
  sourceText,
2206
+ target: { kind, ...nodeId.length === 0 ? {} : { id: nodeId }, property },
2207
+ path,
2208
+ sourceTextHash: computeSourceTextHash(sourceText),
2209
+ status,
2010
2210
  ...existingText === void 0 ? {} : { existingText },
2011
2211
  ...nodeMetadata === void 0 ? {} : { nodeMetadata, metadata: nodeMetadata },
2012
- ...existingTranslationMetadata === void 0 ? {} : { existingTranslationMetadata }
2212
+ ...normalizedMetadata === void 0 ? {} : { existingTranslationMetadata: normalizedMetadata }
2013
2213
  };
2014
2214
  }
2015
- function translationSlots(schema, locale) {
2215
+ function translationSlots(schema, locale, options = {}) {
2016
2216
  const descriptors = [];
2017
2217
  const addFormSlot = (property, sourceText) => {
2018
2218
  const slot = createSlot(
@@ -2023,10 +2223,15 @@ function translationSlots(schema, locale) {
2023
2223
  sourceText,
2024
2224
  schema.translations?.[locale]?.[property],
2025
2225
  schema.metadata,
2026
- schema.translationMetadata?.[locale]?.[property]
2226
+ schema.translationMetadata?.[locale]?.[property],
2227
+ options
2027
2228
  );
2028
2229
  descriptors.push({
2029
2230
  slot,
2231
+ manual: options.isManualTranslation?.(schema.translationMetadata?.[locale]?.[property], {
2232
+ path: slot.path ?? "",
2233
+ locale
2234
+ }) ?? isManualTranslationMetadata(schema.translationMetadata?.[locale]?.[property]),
2030
2235
  apply: (current, value, metadata) => withTranslationMetadata(
2031
2236
  { ...current, translations: mergeLocalizedText(current.translations, locale, property, value) },
2032
2237
  locale,
@@ -2048,10 +2253,15 @@ function translationSlots(schema, locale) {
2048
2253
  sourceText,
2049
2254
  field.translations?.[locale]?.[property],
2050
2255
  field.metadata,
2051
- field.translationMetadata?.[locale]?.[property]
2256
+ field.translationMetadata?.[locale]?.[property],
2257
+ options
2052
2258
  );
2053
2259
  descriptors.push({
2054
2260
  slot,
2261
+ manual: options.isManualTranslation?.(field.translationMetadata?.[locale]?.[property], {
2262
+ path: slot.path ?? "",
2263
+ locale
2264
+ }) ?? isManualTranslationMetadata(field.translationMetadata?.[locale]?.[property]),
2055
2265
  apply: (current, value, metadata) => ({
2056
2266
  ...current,
2057
2267
  fields: current.fields.map(
@@ -2080,10 +2290,15 @@ function translationSlots(schema, locale) {
2080
2290
  option.label,
2081
2291
  option.translations?.[locale],
2082
2292
  option.metadata,
2083
- option.translationMetadata?.[locale]?.label
2293
+ option.translationMetadata?.[locale]?.label,
2294
+ options
2084
2295
  );
2085
2296
  descriptors.push({
2086
2297
  slot,
2298
+ manual: options.isManualTranslation?.(option.translationMetadata?.[locale]?.label, {
2299
+ path: slot.path ?? "",
2300
+ locale
2301
+ }) ?? isManualTranslationMetadata(option.translationMetadata?.[locale]?.label),
2087
2302
  apply: (current, value, metadata) => ({
2088
2303
  ...current,
2089
2304
  fields: current.fields.map((candidate, candidateIndex) => {
@@ -2118,10 +2333,15 @@ function translationSlots(schema, locale) {
2118
2333
  sourceText,
2119
2334
  page.translations?.[locale]?.[property],
2120
2335
  page.metadata,
2121
- page.translationMetadata?.[locale]?.[property]
2336
+ page.translationMetadata?.[locale]?.[property],
2337
+ options
2122
2338
  );
2123
2339
  descriptors.push({
2124
2340
  slot,
2341
+ manual: options.isManualTranslation?.(page.translationMetadata?.[locale]?.[property], {
2342
+ path: slot.path ?? "",
2343
+ locale
2344
+ }) ?? isManualTranslationMetadata(page.translationMetadata?.[locale]?.[property]),
2125
2345
  apply: (current, value, metadata) => ({
2126
2346
  ...current,
2127
2347
  ...current.pages === void 0 ? {} : {
@@ -2145,6 +2365,125 @@ function translationSlots(schema, locale) {
2145
2365
  });
2146
2366
  return descriptors;
2147
2367
  }
2368
+ function removeLocaleRecord(record, locale) {
2369
+ if (record === void 0) return void 0;
2370
+ const remaining = Object.entries(record).filter(([candidate]) => candidate !== locale);
2371
+ return remaining.length === 0 ? void 0 : Object.fromEntries(remaining);
2372
+ }
2373
+ function removeLocalizedNodeLocale(node, locale) {
2374
+ const translations = removeLocaleRecord(node.translations, locale);
2375
+ const translationMetadata = removeLocaleRecord(node.translationMetadata, locale);
2376
+ const { translations: _translations, translationMetadata: _translationMetadata, ...base } = node;
2377
+ return {
2378
+ ...base,
2379
+ ...translations === void 0 ? {} : { translations },
2380
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
2381
+ };
2382
+ }
2383
+ function migrateMetadata(metadata, sourceText, defaultLocale, customMigrator) {
2384
+ if (customMigrator !== void 0) return customMigrator(metadata, sourceText);
2385
+ const record = metadata !== null && typeof metadata === "object" ? metadata : void 0;
2386
+ const sourceLocale = typeof record?.sourceLocale === "string" ? record.sourceLocale : defaultLocale ?? "";
2387
+ const translationSource = isManualTranslationMetadata(record) ? "manual" : "automatic";
2388
+ return {
2389
+ sourceLocale,
2390
+ sourceTextHash: computeSourceTextHash(sourceText),
2391
+ translationSource,
2392
+ ...typeof record?.translatedAt === "string" ? { translatedAt: record.translatedAt } : {},
2393
+ ...typeof record?.editedAt === "string" ? { editedAt: record.editedAt } : {}
2394
+ };
2395
+ }
2396
+ function migrateNodeMetadata(node, sourceTexts, defaultLocale, customMigrator) {
2397
+ if (node.translationMetadata === void 0) return node;
2398
+ const translationMetadata = Object.fromEntries(
2399
+ Object.entries(node.translationMetadata).map(([locale, properties]) => [
2400
+ locale,
2401
+ Object.fromEntries(
2402
+ Object.entries(properties).map(([property, metadata]) => [
2403
+ property,
2404
+ migrateMetadata(metadata, sourceTexts[property] ?? "", defaultLocale, customMigrator)
2405
+ ])
2406
+ )
2407
+ ])
2408
+ );
2409
+ return { ...node, translationMetadata };
2410
+ }
2411
+ var migrateSchemaTranslationMetadata = (schema, customMigrator) => {
2412
+ const migratedSchema = migrateNodeMetadata(
2413
+ schema,
2414
+ {
2415
+ title: schema.title,
2416
+ ...schema.description === void 0 ? {} : { description: schema.description },
2417
+ ...schema.completionMessage === void 0 ? {} : { completionMessage: schema.completionMessage }
2418
+ },
2419
+ schema.defaultLocale,
2420
+ customMigrator
2421
+ );
2422
+ const fields = schema.fields.map((field) => {
2423
+ const migratedField = migrateNodeMetadata(
2424
+ field,
2425
+ { title: field.title, ...field.description === void 0 ? {} : { description: field.description } },
2426
+ schema.defaultLocale,
2427
+ customMigrator
2428
+ );
2429
+ if (!("options" in migratedField)) return migratedField;
2430
+ return {
2431
+ ...migratedField,
2432
+ options: migratedField.options.map(
2433
+ (option) => migrateNodeMetadata(option, { label: option.label }, schema.defaultLocale, customMigrator)
2434
+ )
2435
+ };
2436
+ });
2437
+ const pages = schema.pages?.map(
2438
+ (page) => migrateNodeMetadata(
2439
+ page,
2440
+ {
2441
+ ...page.title === void 0 ? {} : { title: page.title },
2442
+ ...page.description === void 0 ? {} : { description: page.description }
2443
+ },
2444
+ schema.defaultLocale,
2445
+ customMigrator
2446
+ )
2447
+ );
2448
+ return {
2449
+ ...migratedSchema,
2450
+ fields,
2451
+ ...pages === void 0 ? {} : { pages }
2452
+ };
2453
+ };
2454
+ var removeLocaleFromSchema = (schema, localeToRemove) => {
2455
+ if (localeToRemove === schema.defaultLocale) {
2456
+ throw new Error(`Cannot remove defaultLocale: ${localeToRemove}`);
2457
+ }
2458
+ const form = removeLocalizedNodeLocale(schema, localeToRemove);
2459
+ const fields = schema.fields.map((field) => {
2460
+ const localizedField = removeLocalizedNodeLocale(field, localeToRemove);
2461
+ if (!("options" in localizedField)) return localizedField;
2462
+ return {
2463
+ ...localizedField,
2464
+ options: localizedField.options.map((option) => {
2465
+ const translations = removeLocaleRecord(option.translations, localeToRemove);
2466
+ const translationMetadata = removeLocaleRecord(option.translationMetadata, localeToRemove);
2467
+ const { translations: _translations, translationMetadata: _translationMetadata, ...base } = option;
2468
+ return {
2469
+ ...base,
2470
+ ...translations === void 0 ? {} : { translations },
2471
+ ...translationMetadata === void 0 ? {} : { translationMetadata }
2472
+ };
2473
+ })
2474
+ };
2475
+ });
2476
+ const pages = schema.pages?.map((page) => removeLocalizedNodeLocale(page, localeToRemove));
2477
+ return {
2478
+ ...form,
2479
+ supportedLocales: (schema.supportedLocales ?? []).filter((locale) => locale !== localeToRemove),
2480
+ fields,
2481
+ ...pages === void 0 ? {} : { pages }
2482
+ };
2483
+ };
2484
+ function collectTranslationSlots(schema, locale) {
2485
+ return translationSlots(schema, locale).map((descriptor) => descriptor.slot);
2486
+ }
2148
2487
  function resolveLocalizedSchema(schema, targetLocale) {
2149
2488
  if (targetLocale === void 0 || targetLocale.length === 0 || targetLocale === schema.defaultLocale) return schema;
2150
2489
  const formTranslation = schema.translations?.[targetLocale];
@@ -2201,17 +2540,32 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2201
2540
  }
2202
2541
  const updatedSlots = [];
2203
2542
  const skippedSlots = [];
2543
+ const staleSlots = [];
2544
+ const skippedReasons = {};
2204
2545
  let result = schema;
2205
2546
  for (const locale of locales) {
2206
- const descriptors = translationSlots(schema, locale);
2547
+ const descriptors = translationSlots(schema, locale, options);
2548
+ if (options.markStaleTranslations ?? true) {
2549
+ for (const descriptor of descriptors) {
2550
+ if (descriptor.slot.status === "stale" || descriptor.slot.status === "manual-stale")
2551
+ staleSlots.push(descriptor.slot);
2552
+ }
2553
+ }
2207
2554
  const selected = [];
2208
2555
  for (const descriptor of descriptors) {
2209
- const shouldTranslate = options.shouldOverwrite?.(descriptor.slot) ?? (options.overwrite === "all" || descriptor.slot.existingText === void 0);
2556
+ const status = descriptor.slot.status ?? "missing";
2557
+ const manual = descriptor.manual || status === "manual" || status === "manual-stale";
2558
+ const requestedOverwrite = options.shouldOverwrite?.(descriptor.slot);
2559
+ 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"));
2210
2560
  if (shouldTranslate) selected.push(descriptor);
2211
- else skippedSlots.push(descriptor.slot);
2561
+ else {
2562
+ skippedSlots.push(descriptor.slot);
2563
+ skippedReasons[descriptor.slot.path ?? `${descriptor.slot.kind}.${descriptor.slot.nodeId}.${descriptor.slot.property}`] = manual ? "manual" : "unchanged";
2564
+ }
2212
2565
  }
2213
2566
  if (selected.length === 0) continue;
2214
- const translated = await adapter.translateBatch(
2567
+ const translated = await translateBatch(
2568
+ adapter,
2215
2569
  selected.map((descriptor) => descriptor.slot.sourceText),
2216
2570
  locale,
2217
2571
  schema.defaultLocale
@@ -2222,7 +2576,12 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2222
2576
  selected.forEach((descriptor, index) => {
2223
2577
  const translatedText = translated[index];
2224
2578
  if (translatedText === void 0) throw new Error("Translation adapter returned an unexpected result.");
2225
- const metadata = options.createMetadata?.(descriptor.slot, translatedText);
2579
+ const metadata = options.createMetadata?.(descriptor.slot, translatedText) ?? {
2580
+ sourceLocale: schema.defaultLocale ?? "",
2581
+ sourceTextHash: computeSourceTextHash(descriptor.slot.sourceText),
2582
+ translationSource: "automatic",
2583
+ translatedAt: (/* @__PURE__ */ new Date()).toISOString()
2584
+ };
2226
2585
  result = descriptor.apply(result, translatedText, metadata);
2227
2586
  updatedSlots.push(descriptor.slot);
2228
2587
  });
@@ -2236,7 +2595,7 @@ async function populateSchemaTranslations(schema, targetLocales, adapter, option
2236
2595
  ];
2237
2596
  if (supportedLocales.length > 0) result = { ...result, supportedLocales };
2238
2597
  assertValidFormSchema(result);
2239
- return { schema: result, report: { updatedSlots, skippedSlots } };
2598
+ return { schema: result, report: { updatedSlots, skippedSlots, staleSlots, skippedReasons } };
2240
2599
  }
2241
2600
  async function resolveFormTranslation(schema, adapter, targetLocale, sourceLocale) {
2242
2601
  const populated = await populateSchemaTranslations(
@@ -2539,6 +2898,8 @@ export {
2539
2898
  calculatePageVisibility,
2540
2899
  cloneVersionToDraft,
2541
2900
  collectSchemaLocales,
2901
+ collectTranslationSlots,
2902
+ computeSourceTextHash,
2542
2903
  createCloneTransitionPlan,
2543
2904
  createDeleteDraftTransitionPlan,
2544
2905
  createPublishTransitionPlan,
@@ -2553,16 +2914,21 @@ export {
2553
2914
  escapeCsvCell,
2554
2915
  exportResponsesToCsv,
2555
2916
  exportResponsesToCsvStream,
2917
+ getTranslationStatus,
2918
+ isDisplayConditionGroupSatisfied,
2556
2919
  isDisplayConditionSatisfied,
2920
+ isManualTranslationMetadata,
2557
2921
  isQuestionVisible,
2558
2922
  iterateSubmissionPages,
2559
2923
  jsonValuesEqual,
2560
2924
  matchesSubmissionFilter,
2561
2925
  matchesSubmissionPageFilters,
2926
+ migrateSchemaTranslationMetadata,
2562
2927
  normalizeSubmissionPageSize,
2563
2928
  pipeResponsesToCsvStream,
2564
2929
  populateSchemaTranslations,
2565
2930
  publishDraft,
2931
+ removeLocaleFromSchema,
2566
2932
  resolveFormTranslation,
2567
2933
  resolveLocalizedSchema,
2568
2934
  sanitizeSchema,