@qingflow-tech/qingflow-cli 1.1.43-beta.1 → 1.1.43-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +12 -5
  2. package/entry_point.py +1 -1
  3. package/npm/bin/qingflow.mjs +1 -2
  4. package/npm/lib/runtime.mjs +3 -7
  5. package/package.json +1 -2
  6. package/pyproject.toml +1 -3
  7. package/skills/qingflow-app-form/SKILL.md +63 -0
  8. package/skills/qingflow-app-form/agents/openai.yaml +4 -0
  9. package/skills/qingflow-cli/SKILL.md +8 -6
  10. package/skills/qingflow-cli/manifest.yaml +1 -1
  11. package/skills/qingflow-cli/reference/QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md +4 -4
  12. package/skills/qingflow-cli/reference/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  13. package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
  14. package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +4 -4
  15. package/skills/qingflow-cli/reference/builder/10-build-single-app.md +5 -5
  16. package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +14 -14
  17. package/skills/qingflow-cli/reference/builder/30-schema-fields.md +98 -101
  18. package/skills/qingflow-cli/reference/builder/40-layout.md +39 -31
  19. package/skills/qingflow-cli/reference/builder/README.md +9 -9
  20. package/skills/qingflow-cli/reference/builder/code-integrations/README.md +4 -2
  21. package/skills/qingflow-cli/reference/builder/code-integrations/code-block.md +2 -0
  22. package/skills/qingflow-cli/reference/builder/code-integrations/q-linker.md +2 -0
  23. package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +45 -38
  24. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_EXPLORATION_REPORT.md +1 -1
  25. package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
  26. package/skills/qingflow-cli/reference/examples/README.md +3 -3
  27. package/skills/qingflow-common/SKILL.md +47 -0
  28. package/skills/qingflow-common/agents/openai.yaml +4 -0
  29. package/src/qingflow_mcp/__main__.py +2 -3
  30. package/src/qingflow_mcp/app_form_compat.py +6 -1
  31. package/src/qingflow_mcp/builder_facade/app_form.py +547 -18
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +1321 -178
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +268 -7
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +115 -10
  35. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +304 -7
  36. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +23 -4
  37. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +23 -4
  38. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +23 -4
  39. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +23 -4
  40. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +145 -4
  41. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +69 -10
  42. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +69 -10
  43. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +69 -10
  44. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +360 -7
  45. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +71 -4
  46. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +115 -10
  47. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +78 -10
  48. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +23 -4
  49. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +249 -4
  50. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +71 -4
  51. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +71 -4
  52. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +1041 -20
  53. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +69 -10
  54. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +23 -0
  55. package/src/qingflow_mcp/response_trim.py +41 -1
  56. package/src/qingflow_mcp/server.py +24 -3
  57. package/src/qingflow_mcp/server_app_builder.py +1 -1
  58. package/src/qingflow_mcp/tools/record_tools.py +3 -3
  59. package/src/qingflow_mcp/version.py +2 -4
  60. package/docs/local-agent-install.md +0 -370
  61. package/skills/qingflow-mcp-setup/SKILL.md +0 -115
  62. package/skills/qingflow-mcp-setup/agents/openai.yaml +0 -4
  63. package/skills/qingflow-mcp-setup/references/claude-desktop.md +0 -34
  64. package/skills/qingflow-mcp-setup/references/environments.md +0 -62
  65. package/skills/qingflow-mcp-setup/references/generic-stdio.md +0 -32
  66. package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +0 -38
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  from copy import deepcopy
4
5
  from dataclasses import dataclass
5
6
  import json
6
7
  import re
7
8
  from typing import Any, Iterable
9
+ from urllib.parse import unquote_plus
8
10
 
9
11
  from ..app_form_compat import normalize_app_form_declaration, validate_app_form_create_icon
10
12
  from ..errors import QingflowApiError, is_auth_like_error
@@ -19,6 +21,8 @@ from .service import (
19
21
  _compile_code_block_binding_fields,
20
22
  _compile_q_linker_binding_fields,
21
23
  _field_to_question,
24
+ _looks_like_backend_encoded_formula,
25
+ _normalize_formula_defaults_for_save,
22
26
  _normalize_and_validate_code_block_fields_for_write,
23
27
  )
24
28
 
@@ -159,6 +163,17 @@ def _positive_int(value: Any) -> int | None:
159
163
  return result if result > 0 else None
160
164
 
161
165
 
166
+ def _decode_formula_default(value: Any) -> Any:
167
+ if not isinstance(value, str) or not _looks_like_backend_encoded_formula(value):
168
+ return value
169
+ encoded = value[16:-16]
170
+ try:
171
+ decoded = base64.b64decode(encoded, validate=True).decode("utf-8")
172
+ return unquote_plus(decoded)
173
+ except Exception:
174
+ return value
175
+
176
+
162
177
  def _without_none(value: Any) -> Any:
163
178
  if isinstance(value, dict):
164
179
  return {key: _without_none(item) for key, item in value.items() if item is not None}
@@ -167,6 +182,117 @@ def _without_none(value: Any) -> Any:
167
182
  return value
168
183
 
169
184
 
185
+ def _canonical_unlimited(value: Any) -> Any:
186
+ """Expose the backend -1 sentinel as the declarative null/omitted form."""
187
+ return None if value == -1 else value
188
+
189
+
190
+ def _backend_unlimited(value: Any) -> Any:
191
+ """Translate an explicit declarative null back to the backend -1 sentinel."""
192
+ return -1 if value is None else value
193
+
194
+
195
+ def _scope_items(scope: Any, key: str) -> list[JSONObject]:
196
+ value = scope.get(key) if isinstance(scope, dict) else None
197
+ return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
198
+
199
+
200
+ def _backend_member_scope(scope: Any) -> JSONObject | None:
201
+ """Translate the public member/depart scope to AuthMembersIncludeExternalBean JSON."""
202
+ if not isinstance(scope, dict):
203
+ return None
204
+ result: JSONObject = {
205
+ "member": [{"uid": item.get("uid"), "nickName": item.get("nickName")} for item in _scope_items(scope, "members")],
206
+ "depart": [{"deptId": item.get("deptId"), "deptName": item.get("deptName")} for item in _scope_items(scope, "departments")],
207
+ "role": [{"roleId": item.get("roleId"), "roleName": item.get("roleName")} for item in _scope_items(scope, "roles")],
208
+ "dynamic": deepcopy(_scope_items(scope, "dynamic")),
209
+ "externalMemberList": deepcopy(_scope_items(scope, "externalMembers")),
210
+ "externalDepartList": deepcopy(_scope_items(scope, "externalDepartments")),
211
+ }
212
+ if "includeSubDeparts" in scope:
213
+ result["includeSubDeparts"] = bool(scope.get("includeSubDeparts"))
214
+ return result
215
+
216
+
217
+ def _canonical_member_scope(scope: Any, scope_type: Any, *, department: bool = False) -> JSONObject:
218
+ normalized_type = _positive_int(scope_type) or 1
219
+ if normalized_type == 1:
220
+ return {"mode": "all"}
221
+ raw = scope if isinstance(scope, dict) else {}
222
+ result: JSONObject = {
223
+ "mode": "custom" if normalized_type == 2 else "relation",
224
+ "includeSubDeparts": bool(raw.get("includeSubDeparts")),
225
+ }
226
+ if department:
227
+ result["departments"] = [
228
+ _without_none({"deptId": _positive_int(item.get("deptId") or item.get("id")), "deptName": item.get("deptName") or item.get("name")})
229
+ for item in _scope_items(raw, "depart")
230
+ if _positive_int(item.get("deptId") or item.get("id")) is not None
231
+ ]
232
+ else:
233
+ result["members"] = [
234
+ _without_none({"uid": _positive_int(item.get("uid")), "nickName": item.get("nickName")})
235
+ for item in _scope_items(raw, "member")
236
+ if _positive_int(item.get("uid")) is not None
237
+ ]
238
+ result["departments"] = [
239
+ _without_none({"deptId": _positive_int(item.get("deptId")), "deptName": item.get("deptName")})
240
+ for item in _scope_items(raw, "depart")
241
+ if _positive_int(item.get("deptId")) is not None
242
+ ]
243
+ result["roles"] = [
244
+ _without_none({"roleId": _positive_int(item.get("roleId")), "roleName": item.get("roleName")})
245
+ for item in _scope_items(raw, "role")
246
+ if _positive_int(item.get("roleId")) is not None
247
+ ]
248
+ result["dynamic"] = [
249
+ _without_none({
250
+ "type": _positive_int(item.get("type")),
251
+ "queId": item.get("queId"),
252
+ "queTitle": item.get("queTitle"),
253
+ })
254
+ for item in _scope_items(raw, "dynamic")
255
+ if _positive_int(item.get("type")) is not None
256
+ ]
257
+ result["externalMembers"] = [
258
+ _without_none({
259
+ "uid": _positive_int(item.get("uid")),
260
+ "nickName": item.get("nickName"),
261
+ "email": item.get("email"),
262
+ })
263
+ for item in _scope_items(raw, "externalMemberList")
264
+ if _positive_int(item.get("uid")) is not None
265
+ ]
266
+ result["externalDepartments"] = [
267
+ _without_none({
268
+ "deptId": _positive_int(item.get("deptId")),
269
+ "deptName": item.get("deptName"),
270
+ })
271
+ for item in _scope_items(raw, "externalDepartList")
272
+ if _positive_int(item.get("deptId")) is not None
273
+ ]
274
+ return _without_none(result)
275
+
276
+
277
+ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
278
+ if not isinstance(relation, dict):
279
+ return None
280
+ displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
281
+ displayed_id = _positive_int(relation.get("displayedQueId") or displayed.get("queId"))
282
+ if displayed_id is None or not displayed.get("appKey"):
283
+ return None
284
+ result: JSONObject = {
285
+ "displayedField": _selector(displayed_id, displayed.get("queTitle")),
286
+ "targetAppKey": displayed.get("appKey"),
287
+ "targetAppTitle": displayed.get("appTitle"),
288
+ "displayedFieldType": displayed.get("queType"),
289
+ "matchRuleType": relation.get("matchRuleType"),
290
+ "matchRules": relation.get("matchRules"),
291
+ "matchRuleFormula": relation.get("matchRuleFormula"),
292
+ }
293
+ return _without_none(result)
294
+
295
+
170
296
  def _selector(que_id: Any, name: Any) -> JSONObject:
171
297
  result: JSONObject = {}
172
298
  normalized_id = _positive_int(que_id)
@@ -221,6 +347,7 @@ def _semantic(value: Any) -> Any:
221
347
  key: _semantic(item)
222
348
  for key, item in sorted(value.items())
223
349
  if key not in identity_keys
350
+ and not key.startswith("_")
224
351
  and not (key == "sort" and isinstance(item, dict) and item.get("enabled") is False)
225
352
  }
226
353
  if isinstance(value, list):
@@ -308,6 +435,7 @@ def _validate_payload_limits(value: Any) -> None:
308
435
  class AppFormNormalizer:
309
436
  def from_backend(self, *, context: AppFormSchemaContext, app_key: str, form: JSONObject) -> JSONObject:
310
437
  relations = [item for item in form.get("questionRelations") or [] if isinstance(item, dict)]
438
+ select_scope_relations = [item for item in form.get("selectScopeRelations") or [] if isinstance(item, dict)]
311
439
  field_names = self._field_index(form)
312
440
  title_id = _positive_int(form.get("dataTitleQueId"))
313
441
  cover_id = _positive_int(form.get("dataCoverQueId"))
@@ -321,7 +449,7 @@ class AppFormNormalizer:
321
449
  if section_id is None:
322
450
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
323
451
  rows = [
324
- {"fields": [self._field(question, relations, title_id, cover_id, field_names) for question in inner if isinstance(question, dict)]}
452
+ {"fields": [self._field(question, relations, select_scope_relations, title_id, cover_id, field_names) for question in inner if isinstance(question, dict)]}
325
453
  for inner in section.get("innerQuestions") or []
326
454
  if isinstance(inner, list) and inner
327
455
  ]
@@ -329,7 +457,7 @@ class AppFormNormalizer:
329
457
  raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
330
458
  body.append({"kind": "section", "sectionId": str(section_id), "title": str(section.get("queTitle") or ""), "rows": rows})
331
459
  else:
332
- body.append({"kind": "row", "fields": [self._field(question, relations, title_id, cover_id, field_names) for question in row if isinstance(question, dict)]})
460
+ body.append({"kind": "row", "fields": [self._field(question, relations, select_scope_relations, title_id, cover_id, field_names) for question in row if isinstance(question, dict)]})
333
461
  resource = {
334
462
  "apiVersion": context.api_version,
335
463
  "appKey": app_key,
@@ -349,6 +477,7 @@ class AppFormNormalizer:
349
477
  self,
350
478
  question: JSONObject,
351
479
  relations: list[JSONObject],
480
+ select_scope_relations: list[JSONObject],
352
481
  title_id: int | None,
353
482
  cover_id: int | None,
354
483
  field_names: dict[int, str],
@@ -381,6 +510,23 @@ class AppFormNormalizer:
381
510
  "required": bool(question.get("required")),
382
511
  "description": question.get("queHint") or None,
383
512
  }
513
+ default_type = _positive_int(question.get("queDefaultType"))
514
+ if default_type is not None and (
515
+ question.get("queDefaultValue") is not None
516
+ or (default_type != 1 and field_type != "relation")
517
+ ):
518
+ field["defaultType"] = default_type
519
+ raw_default_value = question.get("queDefaultValue")
520
+ if isinstance(raw_default_value, (dict, list)) and field_type not in {"address", "attachment", "relation", "subtable"}:
521
+ raise AppFormFailure(
522
+ "UNSUPPORTED_FIELD_CONFIG",
523
+ "structured default value must be declared through the field-specific config",
524
+ "NORMALIZE",
525
+ details={"queId": que_id, "type": field_type},
526
+ )
527
+ if "queDefaultValue" in question and raw_default_value is not None and not isinstance(raw_default_value, (dict, list)):
528
+ default_value = question.get("queDefaultValue")
529
+ field["defaultValue"] = _decode_formula_default(default_value) if default_type == 3 else deepcopy(default_value)
384
530
  if que_id is not None:
385
531
  field["queId"] = que_id
386
532
  width = _positive_int(question.get("queWidth"))
@@ -390,28 +536,123 @@ class AppFormNormalizer:
390
536
  field["dataTitle"] = True
391
537
  if cover_id is not None and que_id == cover_id:
392
538
  field["dataCover"] = True
393
- if field_type in CHOICE_TYPES:
539
+ if field_type in {"text", "long_text", "link", "phone", "email"}:
540
+ config = _without_none({
541
+ "minWordCount": _canonical_unlimited(question.get("minWordCount")),
542
+ "maxWordCount": _canonical_unlimited(question.get("maxWordCount")),
543
+ "noRepeat": question.get("noRepeat"),
544
+ "canScan": question.get("canScan"),
545
+ "scanType": question.get("scanType"),
546
+ })
547
+ if field_type == "phone":
548
+ config.update(_without_none({
549
+ "msgValidateStatus": question.get("msgValidateStatus"),
550
+ "fixedPhone": question.get("fixedPhone"),
551
+ }))
552
+ if config:
553
+ field["config"] = config
554
+ elif field_type in {"number", "amount"}:
555
+ config = _without_none({
556
+ "numberLimitStatus": question.get("numberLimitStatus"),
557
+ "minValue": question.get("minValue"),
558
+ "maxValue": question.get("maxValue"),
559
+ "decimalDigit": question.get("decimalDigit"),
560
+ "numberFormat": question.get("numberFormat"),
561
+ "currencyStatus": question.get("currencyStatus"),
562
+ "currencyType": question.get("currencyType"),
563
+ "canDecimal": question.get("canDecimal"),
564
+ "noRepeat": question.get("noRepeat"),
565
+ "canScan": question.get("canScan"),
566
+ "scanType": question.get("scanType"),
567
+ })
568
+ if config:
569
+ field["config"] = config
570
+ elif field_type in CHOICE_TYPES:
394
571
  options = [str(item.get("optValue")) for item in question.get("options") or [] if isinstance(item, dict) and item.get("optValue") is not None]
395
572
  if not options and field_type == "single_choice":
396
573
  options = ["是", "否"]
397
- field["config"] = {"options": options}
574
+ field["config"] = _without_none({
575
+ "options": options,
576
+ "minOpts": _canonical_unlimited(question.get("minOpts")),
577
+ "maxOpts": _canonical_unlimited(question.get("maxOpts")),
578
+ "optDirection": question.get("optDirection") if question.get("optDirection") not in {None, 0} else None,
579
+ "optSelectMode": question.get("optSelectMode"),
580
+ "beingShowColor": question.get("beingShowColor"),
581
+ "defaultOptions": [str(item.get("optValue")) for item in question.get("options") or [] if isinstance(item, dict) and item.get("beingDefault") is True],
582
+ })
583
+ if not field["config"].get("defaultOptions"):
584
+ field["config"].pop("defaultOptions", None)
585
+ elif field_type == "member":
586
+ field["config"] = {
587
+ "memberScope": _canonical_member_scope(question.get("memberSelectScope"), question.get("memberSelectScopeType")),
588
+ }
589
+ if question.get("memberDftValue") is not None:
590
+ field["config"]["defaultMembers"] = _canonical_member_scope(question.get("memberDftValue"), 2)
591
+ relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
592
+ scope_relation = _canonical_scope_relation(relation)
593
+ if scope_relation is not None:
594
+ field["config"]["memberScopeRelation"] = scope_relation
595
+ for key in ("noRepeat", "canScan", "scanType"):
596
+ if question.get(key) is not None:
597
+ field["config"][key] = question[key]
398
598
  elif field_type == "department":
399
599
  scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
400
600
  departments = scope.get("depart") if isinstance(scope.get("depart"), list) else []
401
- if departments:
601
+ canonical_departments = [
602
+ _without_none({"deptId": _positive_int(item.get("deptId") or item.get("id")), "deptName": item.get("deptName") or item.get("name")})
603
+ for item in departments
604
+ if isinstance(item, dict) and _positive_int(item.get("deptId") or item.get("id")) is not None
605
+ ]
606
+ scope_type = _positive_int(question.get("deptSelectScopeType")) or 1
607
+ if scope_type == 3:
608
+ field["config"] = {"departmentScope": {"mode": "relation", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
609
+ elif scope_type == 2 or (question.get("deptSelectScopeType") is None and canonical_departments):
610
+ if not canonical_departments:
611
+ raise AppFormFailure(
612
+ "UNSUPPORTED_FIELD_CONFIG",
613
+ "department custom scope has no departments",
614
+ "NORMALIZE",
615
+ details={"queId": que_id},
616
+ )
402
617
  field["config"] = {
403
618
  "departmentScope": {
404
619
  "mode": "custom",
405
- "departments": [
406
- _without_none({"deptId": _positive_int(item.get("deptId") or item.get("id")), "deptName": item.get("deptName") or item.get("name")})
407
- for item in departments
408
- if isinstance(item, dict) and _positive_int(item.get("deptId") or item.get("id")) is not None
409
- ],
620
+ "departments": canonical_departments,
410
621
  "includeSubDeparts": bool(scope.get("includeSubDeparts")),
411
622
  }
412
623
  }
413
624
  else:
414
625
  field["config"] = {"departmentScope": {"mode": "all", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
626
+ if question.get("deptDftValue") is not None:
627
+ field["config"]["defaultDepartments"] = _canonical_member_scope(question.get("deptDftValue"), 2, department=True)
628
+ relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
629
+ scope_relation = _canonical_scope_relation(relation)
630
+ if scope_relation is not None:
631
+ field["config"]["departmentScopeRelation"] = scope_relation
632
+ for key in ("noRepeat", "canScan", "scanType", "beingShowParentPath"):
633
+ if question.get(key) is not None:
634
+ field["config"][key] = question[key]
635
+ elif field_type == "attachment":
636
+ field["config"] = _without_none({
637
+ "fileSize": question.get("fileSize"),
638
+ "fileType": question.get("fileType"),
639
+ "fileBeingCaptureOnly": question.get("fileBeingCaptureOnly"),
640
+ "imageCompress": question.get("imageCompress"),
641
+ "pluginStatus": question.get("pluginStatus"),
642
+ "maxFileLimitStatus": question.get("maxFileLimitStatus"),
643
+ "maxFileLimit": question.get("maxFileLimit"),
644
+ "canScan": question.get("canScan"),
645
+ "scanType": question.get("scanType"),
646
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
647
+ })
648
+ elif field_type == "address":
649
+ field["config"] = _without_none({
650
+ "addressPrecision": question.get("addressPrecision"),
651
+ "pickPosition": question.get("pickPosition"),
652
+ "canScan": question.get("canScan"),
653
+ "scanType": question.get("scanType"),
654
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
655
+ })
415
656
  elif field_type == "relation":
416
657
  reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
417
658
  refer_questions = [item for item in reference.get("referQuestions") or [] if isinstance(item, dict)]
@@ -437,6 +678,9 @@ class AppFormNormalizer:
437
678
  )
438
679
  for item in visible
439
680
  ],
681
+ "beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
682
+ "defaultValueMatchRules": reference.get("defaultValueMatchRules"),
683
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
440
684
  }
441
685
  )
442
686
  elif field_type == "q_linker":
@@ -447,7 +691,7 @@ class AppFormNormalizer:
447
691
  field["config"] = self._data_relation_config(question, relations)
448
692
  elif field_type == "subtable":
449
693
  field["subfields"] = [
450
- self._field(item, relations, None, None, field_names)
694
+ self._field(item, relations, select_scope_relations, None, None, field_names)
451
695
  for item in question.get("subQuestions") or []
452
696
  if isinstance(item, dict)
453
697
  ]
@@ -455,6 +699,13 @@ class AppFormNormalizer:
455
699
  subfield.pop("width", None)
456
700
  subfield.pop("dataTitle", None)
457
701
  subfield.pop("dataCover", None)
702
+ field["config"] = _without_none({
703
+ "tableRowsLimit": question.get("tableRowsLimit"),
704
+ "tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
705
+ "tableNoRepeatQuestion": [_selector(item, field_names.get(_positive_int(item))) for item in question.get("tableNoRepeatQuestion") or [] if _positive_int(item) is not None],
706
+ "tableRowHeight": question.get("tableRowHeight"),
707
+ "defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
708
+ })
458
709
  return field
459
710
 
460
711
  @staticmethod
@@ -958,9 +1209,7 @@ class AppFormCompiler:
958
1209
  fields = list(_iter_fields(body))
959
1210
  templates = self._question_templates(current_form or {})
960
1211
  internal_fields = [self._internal_field(field, templates=templates) for field in fields]
961
- for index, internal in enumerate(internal_fields):
962
- if _positive_int(internal.get("que_id")) is None:
963
- internal["que_temp_id"] = -10000 - (index * 100)
1212
+ self._assign_layout_temp_ids(body, internal_fields)
964
1213
  try:
965
1214
  internal_fields, _ = _normalize_and_validate_code_block_fields_for_write(fields=internal_fields)
966
1215
  internal_fields, relations = _compile_code_block_binding_fields(fields=internal_fields, current_schema=current_form or {})
@@ -992,6 +1241,84 @@ class AppFormCompiler:
992
1241
  self._data_relation_custom_button(config.get("customButton") or {}, template)
993
1242
  self._data_relation_relations(profile, app_key, fields, internal_fields)
994
1243
 
1244
+ @staticmethod
1245
+ def _assign_layout_temp_ids(body: list[Any], internal_fields: list[JSONObject]) -> None:
1246
+ """Assign one temporary-ID sequence to fields and new section wrappers."""
1247
+ temp_id = -10000
1248
+ internal_index = 0
1249
+
1250
+ def assign_field(field: Any) -> None:
1251
+ nonlocal temp_id, internal_index
1252
+ if not isinstance(field, dict):
1253
+ return
1254
+ if internal_index >= len(internal_fields):
1255
+ raise AppFormFailure(
1256
+ "INVALID_SPEC",
1257
+ "form layout and internal field list are inconsistent",
1258
+ "COMPILE",
1259
+ )
1260
+ internal = internal_fields[internal_index]
1261
+ internal_index += 1
1262
+ if _positive_int(field.get("queId")) is None:
1263
+ internal["que_temp_id"] = temp_id
1264
+ temp_id -= 100
1265
+
1266
+ for block in body:
1267
+ if not isinstance(block, dict):
1268
+ continue
1269
+ if block.get("kind") == "row":
1270
+ for field in block.get("fields") or []:
1271
+ assign_field(field)
1272
+ continue
1273
+ for row in block.get("rows") or []:
1274
+ if not isinstance(row, dict):
1275
+ continue
1276
+ for field in row.get("fields") or []:
1277
+ assign_field(field)
1278
+ if _positive_int(block.get("sectionId")) is None:
1279
+ temp_id -= 100
1280
+
1281
+ if internal_index != len(internal_fields):
1282
+ raise AppFormFailure(
1283
+ "INVALID_SPEC",
1284
+ "form layout and internal field list are inconsistent",
1285
+ "COMPILE",
1286
+ )
1287
+
1288
+ @staticmethod
1289
+ def _validate_unique_temp_ids(form_ques: list[list[JSONObject]]) -> None:
1290
+ seen: dict[int, str] = {}
1291
+
1292
+ def visit(question: Any) -> None:
1293
+ if not isinstance(question, dict):
1294
+ return
1295
+ temp_id = question.get("queTempId")
1296
+ if temp_id is not None:
1297
+ try:
1298
+ normalized = int(temp_id)
1299
+ except (TypeError, ValueError):
1300
+ normalized = None
1301
+ if normalized is not None:
1302
+ title = str(question.get("queTitle") or "")
1303
+ previous = seen.get(normalized)
1304
+ if previous is not None:
1305
+ raise AppFormFailure(
1306
+ "DUPLICATE_TEMP_ID",
1307
+ "form payload contains duplicate queTempId",
1308
+ "COMPILE",
1309
+ details={"queTempId": normalized, "questions": [previous, title]},
1310
+ )
1311
+ seen[normalized] = title
1312
+ for subquestion in question.get("subQuestions") or []:
1313
+ visit(subquestion)
1314
+ for inner_row in question.get("innerQuestions") or []:
1315
+ for inner_question in inner_row if isinstance(inner_row, list) else []:
1316
+ visit(inner_question)
1317
+
1318
+ for row in form_ques:
1319
+ for question in row if isinstance(row, list) else []:
1320
+ visit(question)
1321
+
995
1322
  def _target_body(self, current_body: list[Any], declared_body: list[Any]) -> list[JSONObject]:
996
1323
  current_fields = list(_iter_fields(current_body))
997
1324
  by_id = {_positive_int(field.get("queId")): field for field in current_fields if _positive_int(field.get("queId"))}
@@ -1012,6 +1339,12 @@ class AppFormCompiler:
1012
1339
  target["config"] = deepcopy(declared["config"])
1013
1340
  target.setdefault("required", False)
1014
1341
  target.setdefault("description", None)
1342
+ if target.get("defaultValue") is not None:
1343
+ target.setdefault("defaultType", 1)
1344
+ if "defaultValue" in declared and declared.get("defaultValue") is None:
1345
+ target.pop("defaultType", None)
1346
+ target.pop("defaultValue", None)
1347
+ target["_clear_default_value"] = True
1015
1348
  if current is not None:
1016
1349
  target["queId"] = current.get("queId")
1017
1350
  else:
@@ -1157,6 +1490,7 @@ class AppFormCompiler:
1157
1490
  target_fields = list(_iter_fields(target["spec"]["body"]))
1158
1491
  templates = self._question_templates(current_form)
1159
1492
  internal_fields = [self._internal_field(field, templates=templates) for field in target_fields]
1493
+ self._assign_layout_temp_ids(target["spec"]["body"], internal_fields)
1160
1494
  try:
1161
1495
  internal_fields, _ = _normalize_and_validate_code_block_fields_for_write(fields=internal_fields)
1162
1496
  internal_fields, relations = _compile_code_block_binding_fields(fields=internal_fields, current_schema=current_form)
@@ -1176,6 +1510,12 @@ class AppFormCompiler:
1176
1510
  for relation in relations
1177
1511
  if _positive_int(relation.get("relationType")) != 1
1178
1512
  ]
1513
+ payload["selectScopeRelations"] = [
1514
+ self._select_scope_relation(profile, field, internal)
1515
+ for field, internal in _iter_field_pairs(target_fields, internal_fields)
1516
+ if isinstance(field.get("config"), dict)
1517
+ and (field.get("config", {}).get("memberScopeRelation") or field.get("config", {}).get("departmentScopeRelation"))
1518
+ ]
1179
1519
  rows: list[list[JSONObject]] = []
1180
1520
  temp_id = -10000
1181
1521
  for block in target["spec"]["body"]:
@@ -1212,11 +1552,13 @@ class AppFormCompiler:
1212
1552
  temp_id -= 100
1213
1553
  rows.append([section])
1214
1554
  payload["formQues"] = rows
1555
+ self._validate_unique_temp_ids(rows)
1215
1556
  data_title = next((field for field in target_fields if field.get("dataTitle")), None)
1216
1557
  data_cover = next((field for field in target_fields if field.get("dataCover")), None)
1217
1558
  payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
1218
1559
  payload["dataCoverQueId"] = self._field_runtime_id(data_cover, internal_fields) if data_cover else None
1219
1560
  payload["questionRelations"].extend(data_relations)
1561
+ _normalize_formula_defaults_for_save(payload.get("formQues"))
1220
1562
  return payload
1221
1563
 
1222
1564
  @staticmethod
@@ -1377,6 +1719,15 @@ class AppFormCompiler:
1377
1719
  "subfields": [self._internal_field(item, templates=templates) for item in field.get("subfields") or []],
1378
1720
  "_app_form_type": canonical_type,
1379
1721
  }
1722
+ if "defaultValues" in config:
1723
+ result["default_values"] = deepcopy(config.get("defaultValues"))
1724
+ if "defaultType" in field:
1725
+ result["default_type"] = _positive_int(field.get("defaultType")) or 1
1726
+ if "defaultValue" in field:
1727
+ result["default_value"] = deepcopy(field.get("defaultValue"))
1728
+ if field.get("_clear_default_value") is True:
1729
+ result["default_type"] = 1
1730
+ result["default_value"] = None
1380
1731
  template = templates.get(result["que_id"] or -1)
1381
1732
  if template is not None:
1382
1733
  result["_question_template"] = deepcopy(template)
@@ -1390,6 +1741,19 @@ class AppFormCompiler:
1390
1741
  ],
1391
1742
  "include_sub_departs": bool(scope.get("includeSubDeparts")),
1392
1743
  }
1744
+ default_departments = config.get("defaultDepartments")
1745
+ if isinstance(default_departments, dict):
1746
+ result["department_default_scope"] = {
1747
+ "departments": [
1748
+ {"dept_id": item.get("deptId"), "dept_name": item.get("deptName")}
1749
+ for item in default_departments.get("departments") or []
1750
+ if isinstance(item, dict)
1751
+ ],
1752
+ "include_sub_departs": bool(default_departments.get("includeSubDeparts")),
1753
+ }
1754
+ elif canonical_type == "member":
1755
+ result["member_scope"] = deepcopy(config.get("memberScope"))
1756
+ result["member_default_scope"] = deepcopy(config.get("defaultMembers"))
1393
1757
  elif canonical_type == "relation":
1394
1758
  result.update(
1395
1759
  {
@@ -1397,6 +1761,8 @@ class AppFormCompiler:
1397
1761
  "relation_mode": config.get("relationMode") or "single",
1398
1762
  "display_field": _snake_selector(config.get("displayField")),
1399
1763
  "visible_fields": [_snake_selector(item) for item in config.get("visibleFields") or []],
1764
+ "reference_default_formula_auto_fill": config.get("beingDefaultFormulaAutoFillEnabled"),
1765
+ "reference_default_value_match_rules": deepcopy(config.get("defaultValueMatchRules")),
1400
1766
  "_relation_config_explicit": True,
1401
1767
  }
1402
1768
  )
@@ -1443,6 +1809,87 @@ class AppFormCompiler:
1443
1809
  "queHint": field.get("description") or "",
1444
1810
  }
1445
1811
  )
1812
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
1813
+ if field_type in {"text", "long_text", "link", "phone", "email"}:
1814
+ for key in ("minWordCount", "maxWordCount", "noRepeat", "canScan", "scanType"):
1815
+ if key in config:
1816
+ question[key] = _backend_unlimited(config[key]) if key in {"minWordCount", "maxWordCount"} else config[key]
1817
+ if field_type == "phone":
1818
+ for key in ("msgValidateStatus", "fixedPhone"):
1819
+ if key in config:
1820
+ question[key] = config[key]
1821
+ elif field_type in {"number", "amount"}:
1822
+ for key in ("numberLimitStatus", "minValue", "maxValue", "decimalDigit", "numberFormat", "currencyStatus", "currencyType", "canDecimal", "noRepeat", "canScan", "scanType"):
1823
+ if key in config:
1824
+ question[key] = config[key]
1825
+ if field_type == "amount" and "canDecimal" not in question:
1826
+ question["canDecimal"] = True
1827
+ elif field_type in CHOICE_TYPES:
1828
+ for key in ("minOpts", "maxOpts", "optDirection", "optSelectMode", "beingShowColor"):
1829
+ if key in config:
1830
+ question[key] = _backend_unlimited(config[key]) if key in {"minOpts", "maxOpts"} else config[key]
1831
+ elif field_type == "member":
1832
+ scope = config.get("memberScope")
1833
+ default_scope = config.get("defaultMembers")
1834
+ if isinstance(scope, dict):
1835
+ question["memberSelectScopeType"] = {"all": 1, "custom": 2, "relation": 3}.get(scope.get("mode"), 1)
1836
+ question["memberSelectScope"] = _backend_member_scope(scope)
1837
+ if isinstance(default_scope, dict):
1838
+ question["memberDftValue"] = _backend_member_scope(default_scope)
1839
+ for key in ("noRepeat", "canScan", "scanType"):
1840
+ if key in config:
1841
+ question[key] = config[key]
1842
+ elif field_type == "department":
1843
+ scope = config.get("departmentScope") if isinstance(config.get("departmentScope"), dict) else None
1844
+ if scope is not None:
1845
+ question["deptSelectScopeType"] = {"all": 1, "custom": 2, "relation": 3}.get(scope.get("mode"), 1)
1846
+ question["deptSelectScope"] = {
1847
+ "depart": [
1848
+ _without_none({"deptId": item.get("deptId"), "deptName": item.get("deptName")})
1849
+ for item in scope.get("departments") or []
1850
+ if isinstance(item, dict)
1851
+ ],
1852
+ "includeSubDeparts": bool(scope.get("includeSubDeparts")),
1853
+ }
1854
+ default_scope = config.get("defaultDepartments")
1855
+ if isinstance(default_scope, dict):
1856
+ question["deptDftValue"] = {
1857
+ "depart": [
1858
+ _without_none({"deptId": item.get("deptId"), "deptName": item.get("deptName")})
1859
+ for item in default_scope.get("departments") or []
1860
+ if isinstance(item, dict)
1861
+ ],
1862
+ "includeSubDeparts": bool(default_scope.get("includeSubDeparts")),
1863
+ }
1864
+ for key in ("noRepeat", "canScan", "scanType", "beingShowParentPath"):
1865
+ if key in config:
1866
+ question[key] = config[key]
1867
+ elif field_type == "attachment":
1868
+ for key in ("fileSize", "fileType", "fileBeingCaptureOnly", "imageCompress", "pluginStatus", "maxFileLimitStatus", "maxFileLimit", "canScan", "scanType"):
1869
+ if key in config:
1870
+ question[key] = deepcopy(config[key])
1871
+ elif field_type == "address":
1872
+ for key in ("addressPrecision", "pickPosition", "canScan", "scanType"):
1873
+ if key in config:
1874
+ question[key] = config[key]
1875
+ elif field_type == "subtable":
1876
+ for key in ("tableRowsLimit", "tableNoRepeatStatus", "tableRowHeight"):
1877
+ if key in config:
1878
+ question[key] = deepcopy(config[key])
1879
+ if isinstance(config.get("tableNoRepeatQuestion"), list):
1880
+ question["tableNoRepeatQuestion"] = [
1881
+ self._resolve_subfield_runtime_id(item, internal.get("subfields") or [], "tableNoRepeatQuestion")
1882
+ for item in config["tableNoRepeatQuestion"]
1883
+ ]
1884
+ if "defaultValues" in config:
1885
+ question["queDefaultValues"] = deepcopy(config.get("defaultValues"))
1886
+ if "defaultType" in field:
1887
+ question["queDefaultType"] = _positive_int(field.get("defaultType")) or 1
1888
+ if "defaultValue" in field:
1889
+ question["queDefaultValue"] = deepcopy(field.get("defaultValue"))
1890
+ if field.get("_clear_default_value") is True:
1891
+ question["queDefaultType"] = 1
1892
+ question["queDefaultValue"] = None
1446
1893
  if field_type == "date":
1447
1894
  question["dateType"] = 0
1448
1895
  elif field_type == "datetime":
@@ -1460,7 +1907,10 @@ class AppFormCompiler:
1460
1907
  option.update({key: deepcopy(value) for key, value in old.items() if key not in {"optValue"}})
1461
1908
  question["options"] = options
1462
1909
  question["unnamedOptions"] = [item.get("optValue") for item in options]
1463
- question["optDirection"] = 0
1910
+ default_options = (field.get("config") or {}).get("defaultOptions")
1911
+ if isinstance(default_options, list):
1912
+ for option in options:
1913
+ option["beingDefault"] = str(option.get("optValue")) in {str(item) for item in default_options}
1464
1914
  elif field_type == "subtable":
1465
1915
  question["subQuestions"] = [
1466
1916
  self._question(profile, app_key, subfield, sub_internal, temp_id - ((index + 1) * 10), relations)
@@ -1522,10 +1972,56 @@ class AppFormCompiler:
1522
1972
  for index, item in enumerate(visible)
1523
1973
  ],
1524
1974
  "referAuthQues": [{"queId": item["queId"], "queAuth": 1} for item in visible],
1975
+ "beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
1976
+ "defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
1525
1977
  }
1526
1978
  )
1979
+ if "defaultValues" in config:
1980
+ result["queDefaultValues"] = deepcopy(config.get("defaultValues"))
1527
1981
  return result
1528
1982
 
1983
+ def _select_scope_relation(self, profile: str, field: JSONObject, internal: JSONObject) -> JSONObject:
1984
+ config = field.get("config") if isinstance(field.get("config"), dict) else {}
1985
+ relation = config.get("memberScopeRelation") or config.get("departmentScopeRelation")
1986
+ if not isinstance(relation, dict):
1987
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation must be an object", "COMPILE")
1988
+ target_app_key = str(relation.get("targetAppKey") or "")
1989
+ if not target_app_key:
1990
+ raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "select scope relation requires targetAppKey", "COMPILE")
1991
+ target_form = self._read_form(profile, target_app_key, being_draft=False)
1992
+ candidates = _backend_question_candidates(target_form)
1993
+ displayed = _resolve_backend_selector(relation.get("displayedField") or {}, candidates, "selectScopeRelation.displayedField")
1994
+ payload: JSONObject = {
1995
+ "queId": _positive_int(internal.get("que_id")) or int(internal.get("que_temp_id") or 0),
1996
+ "displayedQueId": _positive_int(displayed.get("queId")),
1997
+ "displayedQueInfo": {
1998
+ "appKey": target_app_key,
1999
+ "appTitle": relation.get("targetAppTitle") or target_form.get("formTitle") or "",
2000
+ "queId": _positive_int(displayed.get("queId")),
2001
+ "queTitle": displayed.get("queTitle") or "",
2002
+ "queType": _positive_int(displayed.get("queType")) or 2,
2003
+ },
2004
+ }
2005
+ for key in ("matchRuleType", "matchRules", "matchRuleFormula"):
2006
+ if key in relation:
2007
+ payload[key] = deepcopy(relation.get(key))
2008
+ return _without_none(payload)
2009
+
2010
+ @staticmethod
2011
+ def _resolve_subfield_runtime_id(selector: JSONObject, internal_fields: list[JSONObject], location: str) -> int:
2012
+ if not isinstance(selector, dict):
2013
+ raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must be an object", "COMPILE")
2014
+ que_id = _positive_int(selector.get("queId"))
2015
+ name = str(selector.get("name") or "")
2016
+ matches = [
2017
+ item for item in internal_fields
2018
+ if (que_id is None or _positive_int(item.get("que_id")) == que_id)
2019
+ and (not name or str(item.get("name") or "") == name)
2020
+ ]
2021
+ if len(matches) != 1:
2022
+ raise AppFormFailure("INVALID_FIELD_SELECTOR", f"{location} must resolve to exactly one subfield", "COMPILE", details={"selector": selector, "matches": len(matches)})
2023
+ return _positive_int(matches[0].get("que_id")) or int(matches[0].get("que_temp_id") or 0)
2024
+
1529
2025
  @staticmethod
1530
2026
  def _data_relation_custom_button(config: JSONObject, template: JSONObject | None) -> JSONObject:
1531
2027
  current = (template or {}).get("customButtonConfig")
@@ -1823,7 +2319,7 @@ class AppFormCompiler:
1823
2319
 
1824
2320
  @staticmethod
1825
2321
  def _save_base(current: JSONObject) -> JSONObject:
1826
- read_only = {"appKey", "formId", "baseQues", "applyAuthType", "canCurrentApply", "wsType", "accountLevel", "collectionStatus", "editVersionNo", "formQues", "questionRelations"}
2322
+ read_only = {"appKey", "formId", "baseQues", "applyAuthType", "canCurrentApply", "wsType", "accountLevel", "collectionStatus", "editVersionNo", "formQues", "questionRelations", "selectScopeRelations"}
1827
2323
  return {key: deepcopy(value) for key, value in current.items() if key not in read_only}
1828
2324
 
1829
2325
  def _apply_form_settings(self, payload: JSONObject, *, current_form: JSONObject, target_spec: JSONObject, internal_fields: list[JSONObject]) -> None:
@@ -2664,17 +3160,34 @@ class AppFormReconciler:
2664
3160
 
2665
3161
  @staticmethod
2666
3162
  def _failure(error_code: str, message: str, phase: str, *, status: str = "failed", details: JSONObject | None = None, diagnostics: JSONObject | None = None, app_key: str | None = None, warnings: list[JSONObject] | None = None) -> JSONObject:
3163
+ output_diagnostics = deepcopy(diagnostics or {})
3164
+ for key in ("diff", "remainingDiff"):
3165
+ if key in output_diagnostics:
3166
+ output_diagnostics[key] = AppFormReconciler._compact_diff(output_diagnostics[key])
2667
3167
  result = {
2668
3168
  "status": status,
2669
3169
  "error_code": error_code,
2670
3170
  "recoverable": status != "failed" or error_code in {"PUBLISH_FAILED", "PACKAGE_ATTACH_RESULT_UNKNOWN"},
2671
3171
  "message": message,
2672
- **(diagnostics or {}),
3172
+ **output_diagnostics,
2673
3173
  "phase": phase,
2674
3174
  **({"appKey": app_key} if app_key else {}),
2675
- **({"details": details} if details else {}),
2676
3175
  **({"warnings": warnings} if warnings else {}),
2677
3176
  }
3177
+ if details:
3178
+ # Apply diagnostics are already exposed at the top level. Keep only
3179
+ # additional error context in details to avoid returning the same
3180
+ # large diagnostic object twice.
3181
+ compact_details = {
3182
+ key: value
3183
+ for key, value in details.items()
3184
+ if key not in result
3185
+ }
3186
+ for key in ("diff", "remainingDiff"):
3187
+ if key in compact_details:
3188
+ compact_details[key] = AppFormReconciler._compact_diff(compact_details[key])
3189
+ if compact_details:
3190
+ result["details"] = compact_details
2678
3191
  recovery = {
2679
3192
  "EDIT_VERSION_CONFLICT": "read the latest draft, rebuild the complete declaration, validate, and retry once",
2680
3193
  "EDIT_FINISHED_FAILED": "if published equals draft, release the current account's edit lock without publishing again",
@@ -2686,6 +3199,22 @@ class AppFormReconciler:
2686
3199
  result["recoveryHint"] = recovery
2687
3200
  return result
2688
3201
 
3202
+ @staticmethod
3203
+ def _compact_diff(diff: Any) -> Any:
3204
+ if not isinstance(diff, dict):
3205
+ return diff
3206
+ compact = deepcopy(diff)
3207
+ for key in ("added", "updated", "removed"):
3208
+ fields = compact.get(key)
3209
+ if not isinstance(fields, list):
3210
+ continue
3211
+ compact[key] = [
3212
+ str(field.get("name") or field.get("queId") or "unknown") if isinstance(field, dict) else str(field)
3213
+ for field in fields
3214
+ if isinstance(field, (dict, str, int))
3215
+ ]
3216
+ return compact
3217
+
2689
3218
  @classmethod
2690
3219
  def _from_error(cls, error: AppFormContractError | AppFormFailure) -> JSONObject:
2691
3220
  if isinstance(error, AppFormContractError):