@josephyan/qingflow-cli 0.2.0-beta.67 → 0.2.0-beta.69

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.
@@ -84,6 +84,8 @@ QUESTION_TYPE_TO_FIELD_TYPE: dict[int, str] = {
84
84
  11: FieldType.single_select.value,
85
85
  12: FieldType.multi_select.value,
86
86
  13: FieldType.attachment.value,
87
+ 20: FieldType.q_linker.value,
88
+ 26: FieldType.code_block.value,
87
89
  18: FieldType.subtable.value,
88
90
  21: FieldType.address.value,
89
91
  22: FieldType.department.value,
@@ -104,6 +106,8 @@ FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
104
106
  FieldType.single_select.value: 11,
105
107
  FieldType.multi_select.value: 12,
106
108
  FieldType.attachment.value: 13,
109
+ FieldType.q_linker.value: 20,
110
+ FieldType.code_block.value: 26,
107
111
  FieldType.subtable.value: 18,
108
112
  FieldType.address.value: 21,
109
113
  FieldType.department.value: 22,
@@ -118,6 +122,9 @@ JUDGE_LESS_OR_EQUAL = 7
118
122
  JUDGE_EQUAL_ANY = 9
119
123
  JUDGE_FUZZY_MATCH = 19
120
124
  JUDGE_INCLUDE_ANY = 20
125
+ DEFAULT_TYPE_RELATION = 2
126
+ RELATION_TYPE_Q_LINKER = 2
127
+ RELATION_TYPE_CODE_BLOCK = 3
121
128
 
122
129
  INCLUDE_ANY_FLOW_FIELD_TYPES = {
123
130
  FieldType.multi_select.value,
@@ -144,6 +151,13 @@ class PermissionCheckOutcome:
144
151
  verification: JSONObject = field(default_factory=dict)
145
152
 
146
153
 
154
+ @dataclass(slots=True)
155
+ class RelationHydrationResult:
156
+ fields: list[dict[str, Any]]
157
+ permission_outcome: PermissionCheckOutcome | None = None
158
+ degraded_expectations: list[dict[str, Any]] = field(default_factory=list)
159
+
160
+
147
161
  class AiBuilderFacade:
148
162
  def __init__(
149
163
  self,
@@ -2088,7 +2102,7 @@ class AiBuilderFacade:
2088
2102
  layout = parsed["layout"]
2089
2103
  response = AppLayoutReadResponse(
2090
2104
  app_key=app_key,
2091
- sections=layout.get("sections", []),
2105
+ sections=_decorate_layout_sections_as_paragraphs(layout.get("sections", [])),
2092
2106
  unplaced_fields=_find_unplaced_fields(parsed["fields"], layout),
2093
2107
  layout_mode_detected=_detect_layout_mode(layout),
2094
2108
  )
@@ -2390,7 +2404,7 @@ class AiBuilderFacade:
2390
2404
  current_layout = self.app_read_layout_summary(profile=profile, app_key=request.app_key)
2391
2405
  if current_layout.get("status") == "failed":
2392
2406
  return current_layout
2393
- requested_sections = [section.model_dump(mode="json") for section in request.sections]
2407
+ requested_sections = [section.model_dump(mode="json", exclude_none=True) for section in request.sections]
2394
2408
  if request.preset is not None:
2395
2409
  requested_sections = _build_layout_preset_sections(preset=request.preset, field_names=current_names)
2396
2410
  else:
@@ -2960,8 +2974,15 @@ class AiBuilderFacade:
2960
2974
  removed.append(field["name"])
2961
2975
  selector_map = _build_selector_map(current_fields)
2962
2976
 
2977
+ relation_permission_outcome: PermissionCheckOutcome | None = None
2978
+ relation_degraded_expectations: list[dict[str, Any]] = []
2979
+ relation_target_metadata_verified = True
2963
2980
  try:
2964
- current_fields = _hydrate_relation_field_configs(self, profile=profile, fields=current_fields)
2981
+ relation_hydration = _hydrate_relation_field_configs(self, profile=profile, fields=current_fields)
2982
+ current_fields = relation_hydration.fields
2983
+ relation_permission_outcome = relation_hydration.permission_outcome
2984
+ relation_degraded_expectations = relation_hydration.degraded_expectations
2985
+ relation_target_metadata_verified = not bool(relation_degraded_expectations)
2965
2986
  except (QingflowApiError, RuntimeError) as error:
2966
2987
  api_error = _coerce_api_error(error)
2967
2988
  return _failed_from_api_error(
@@ -2980,6 +3001,36 @@ class AiBuilderFacade:
2980
3001
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
2981
3002
  )
2982
3003
 
3004
+ try:
3005
+ current_fields, compiled_question_relations = _compile_code_block_binding_fields(
3006
+ fields=current_fields,
3007
+ current_schema=schema_result,
3008
+ )
3009
+ except ValueError as error:
3010
+ return _failed(
3011
+ "CODE_BLOCK_BINDING_INVALID",
3012
+ str(error),
3013
+ normalized_args=normalized_args,
3014
+ details={"app_key": target.app_key},
3015
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3016
+ )
3017
+
3018
+ q_linker_schema_context = deepcopy(schema_result)
3019
+ q_linker_schema_context["questionRelations"] = deepcopy(compiled_question_relations)
3020
+ try:
3021
+ current_fields, compiled_question_relations = _compile_q_linker_binding_fields(
3022
+ fields=current_fields,
3023
+ current_schema=q_linker_schema_context,
3024
+ )
3025
+ except ValueError as error:
3026
+ return _failed(
3027
+ "Q_LINKER_BINDING_INVALID",
3028
+ str(error),
3029
+ normalized_args=normalized_args,
3030
+ details={"app_key": target.app_key},
3031
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3032
+ )
3033
+
2983
3034
  relation_field_count = _count_relation_fields(current_fields)
2984
3035
  relation_limit_verified = relation_field_count <= 1
2985
3036
  relation_warnings = (
@@ -3008,6 +3059,7 @@ class AiBuilderFacade:
3008
3059
  "fields_verified": True,
3009
3060
  "relation_field_limit_verified": relation_limit_verified,
3010
3061
  "app_visuals_verified": True,
3062
+ "relation_target_metadata_verified": relation_target_metadata_verified,
3011
3063
  },
3012
3064
  "app_key": target.app_key,
3013
3065
  "app_icon": str(visual_result.get("app_icon") or "").strip() or None,
@@ -3018,6 +3070,7 @@ class AiBuilderFacade:
3018
3070
  "package_attached": package_attached,
3019
3071
  }
3020
3072
  response["details"]["relation_field_count"] = relation_field_count
3073
+ response = _apply_permission_outcomes(response, relation_permission_outcome)
3021
3074
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
3022
3075
 
3023
3076
  payload = _build_form_payload_from_fields(
@@ -3025,6 +3078,7 @@ class AiBuilderFacade:
3025
3078
  current_schema=schema_result,
3026
3079
  fields=current_fields,
3027
3080
  layout=layout,
3081
+ question_relations=compiled_question_relations,
3028
3082
  )
3029
3083
  payload["editVersionNo"] = self._resolve_form_edit_version(
3030
3084
  profile=profile,
@@ -3066,6 +3120,81 @@ class AiBuilderFacade:
3066
3120
  },
3067
3121
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3068
3122
  )
3123
+ if _code_block_relations_need_source_rebind(compiled_question_relations) or _q_linker_relations_need_source_rebind(compiled_question_relations):
3124
+ try:
3125
+ rebound_schema = self.apps.app_get_form_schema(
3126
+ profile=profile,
3127
+ app_key=target.app_key,
3128
+ form_type=1,
3129
+ being_draft=True,
3130
+ being_apply=None,
3131
+ audit_node_id=None,
3132
+ include_raw=True,
3133
+ ).get("result") or {}
3134
+ rebound_parsed = _parse_schema(rebound_schema)
3135
+ rebound_fields = cast(list[dict[str, Any]], rebound_parsed.get("fields") or [])
3136
+ rebound_layout = cast(dict[str, Any], rebound_parsed.get("layout") or {"root_rows": [], "sections": []})
3137
+ _overlay_code_block_binding_fields(target_fields=rebound_fields, source_fields=current_fields)
3138
+ _overlay_q_linker_binding_fields(target_fields=rebound_fields, source_fields=current_fields)
3139
+ rebound_fields, compiled_question_relations = _compile_code_block_binding_fields(
3140
+ fields=rebound_fields,
3141
+ current_schema=rebound_schema,
3142
+ )
3143
+ rebound_q_linker_schema = deepcopy(rebound_schema)
3144
+ rebound_q_linker_schema["questionRelations"] = deepcopy(compiled_question_relations)
3145
+ rebound_fields, compiled_question_relations = _compile_q_linker_binding_fields(
3146
+ fields=rebound_fields,
3147
+ current_schema=rebound_q_linker_schema,
3148
+ )
3149
+ except ValueError as error:
3150
+ return _failed(
3151
+ "Q_LINKER_BINDING_INVALID" if "q_linker" in str(error) else "CODE_BLOCK_BINDING_INVALID",
3152
+ str(error),
3153
+ normalized_args=normalized_args,
3154
+ details={"app_key": target.app_key},
3155
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3156
+ )
3157
+ except (QingflowApiError, RuntimeError) as error:
3158
+ api_error = _coerce_api_error(error)
3159
+ return _failed_from_api_error(
3160
+ "SCHEMA_APPLY_FAILED",
3161
+ api_error,
3162
+ normalized_args=normalized_args,
3163
+ allowed_values={"field_types": [item.value for item in PublicFieldType]},
3164
+ details={
3165
+ "app_key": target.app_key,
3166
+ "field_diff": {"added": added, "updated": updated, "removed": removed},
3167
+ },
3168
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3169
+ )
3170
+ rebound_payload = _build_form_payload_from_fields(
3171
+ title=rebound_schema.get("formTitle") or target.app_name,
3172
+ current_schema=rebound_schema,
3173
+ fields=rebound_fields,
3174
+ layout=rebound_layout,
3175
+ question_relations=compiled_question_relations,
3176
+ )
3177
+ rebound_payload["editVersionNo"] = self._resolve_form_edit_version(
3178
+ profile=profile,
3179
+ app_key=target.app_key,
3180
+ current_schema=rebound_schema,
3181
+ )
3182
+ try:
3183
+ self.apps.app_update_form_schema(profile=profile, app_key=target.app_key, payload=rebound_payload)
3184
+ except (QingflowApiError, RuntimeError) as error:
3185
+ api_error = _coerce_api_error(error)
3186
+ return _failed_from_api_error(
3187
+ "SCHEMA_APPLY_FAILED",
3188
+ api_error,
3189
+ normalized_args=normalized_args,
3190
+ allowed_values={"field_types": [item.value for item in PublicFieldType]},
3191
+ details={
3192
+ "app_key": target.app_key,
3193
+ "field_diff": {"added": added, "updated": updated, "removed": removed},
3194
+ },
3195
+ suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3196
+ )
3197
+ current_fields = rebound_fields
3069
3198
  response = {
3070
3199
  "status": "success",
3071
3200
  "error_code": None,
@@ -3084,6 +3213,7 @@ class AiBuilderFacade:
3084
3213
  "package_attached": None,
3085
3214
  "app_visuals_verified": True,
3086
3215
  "relation_field_limit_verified": relation_limit_verified,
3216
+ "relation_target_metadata_verified": relation_target_metadata_verified,
3087
3217
  },
3088
3218
  "app_key": target.app_key,
3089
3219
  "app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
@@ -3100,6 +3230,7 @@ class AiBuilderFacade:
3100
3230
  response["details"]["relation_field_count"] = relation_field_count
3101
3231
  if schema_readback_delayed:
3102
3232
  response["verification"]["schema_readback_delayed"] = True
3233
+ response = _apply_permission_outcomes(response, relation_permission_outcome)
3103
3234
  response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
3104
3235
  verification_ok = False
3105
3236
  tag_ids_after: list[int] = []
@@ -3109,6 +3240,19 @@ class AiBuilderFacade:
3109
3240
  verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
3110
3241
  verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
3111
3242
  verification_ok = all(name in verified_field_names for name in added + updated) and all(name not in verified_field_names for name in removed)
3243
+ if relation_degraded_expectations:
3244
+ relation_verified_fields = cast(list[dict[str, Any]], verified["schema"]["fields"])
3245
+ try:
3246
+ relation_readback = self.app_read_fields(profile=profile, app_key=target.app_key)
3247
+ relation_verified_fields = cast(list[dict[str, Any]], relation_readback.get("fields") or relation_verified_fields)
3248
+ except (QingflowApiError, RuntimeError):
3249
+ relation_verified_fields = cast(list[dict[str, Any]], verified["schema"]["fields"])
3250
+ relation_readback_verified = _verify_relation_readback_by_name(
3251
+ verified_fields=relation_verified_fields,
3252
+ degraded_expectations=relation_degraded_expectations,
3253
+ )
3254
+ response["verification"]["relation_target_readback_by_name_verified"] = relation_readback_verified
3255
+ verification_ok = verification_ok and relation_readback_verified
3112
3256
  except (QingflowApiError, RuntimeError) as error:
3113
3257
  verification_error = _coerce_api_error(error)
3114
3258
  verification_ok = False
@@ -3170,7 +3314,7 @@ class AiBuilderFacade:
3170
3314
  sections: list[LayoutSectionPatch],
3171
3315
  publish: bool = True,
3172
3316
  ) -> JSONObject:
3173
- requested_sections = [section.model_dump(mode="json") for section in sections]
3317
+ requested_sections = [section.model_dump(mode="json", exclude_none=True) for section in sections]
3174
3318
  normalized_args = {
3175
3319
  "app_key": app_key,
3176
3320
  "mode": mode.value,
@@ -6415,6 +6559,21 @@ def _coerce_nonnegative_int(value: Any) -> int | None:
6415
6559
  return None
6416
6560
 
6417
6561
 
6562
+ def _coerce_any_int(value: Any) -> int | None:
6563
+ if isinstance(value, bool) or value is None:
6564
+ return None
6565
+ if isinstance(value, int):
6566
+ return value
6567
+ if isinstance(value, float):
6568
+ return int(value)
6569
+ if isinstance(value, str) and value.strip():
6570
+ try:
6571
+ return int(value)
6572
+ except ValueError:
6573
+ return None
6574
+ return None
6575
+
6576
+
6418
6577
  def _coerce_int_list(values: Any) -> list[int]:
6419
6578
  if not isinstance(values, list):
6420
6579
  return []
@@ -7274,14 +7433,159 @@ def _normalize_relation_mode(value: Any) -> str:
7274
7433
  return _relation_mode_from_optional_data_num(value)
7275
7434
 
7276
7435
 
7436
+ def _is_relation_target_metadata_read_restricted_api_error(error: QingflowApiError) -> bool:
7437
+ return error.backend_code in {40002, 40027, 40161}
7438
+
7439
+
7440
+ def _relation_target_field_matches(left: dict[str, Any], right: dict[str, Any]) -> bool:
7441
+ left_field_id = str(left.get("field_id") or "").strip()
7442
+ right_field_id = str(right.get("field_id") or "").strip()
7443
+ left_que_id = _coerce_positive_int(left.get("que_id"))
7444
+ right_que_id = _coerce_positive_int(right.get("que_id"))
7445
+ left_name = str(left.get("name") or "").strip()
7446
+ right_name = str(right.get("name") or "").strip()
7447
+ return (
7448
+ (left_field_id and right_field_id and left_field_id == right_field_id)
7449
+ or (left_que_id is not None and right_que_id is not None and left_que_id == right_que_id)
7450
+ or (left_name and right_name and left_name == right_name)
7451
+ )
7452
+
7453
+
7454
+ def _normalize_relation_target_stub(
7455
+ selector_payload: dict[str, Any] | None,
7456
+ *,
7457
+ fallback_name: str | None = None,
7458
+ ) -> dict[str, Any]:
7459
+ payload = selector_payload if isinstance(selector_payload, dict) else {}
7460
+ name = str(payload.get("name") or fallback_name or "").strip() or None
7461
+ if name is None:
7462
+ raise ValueError("relation target selector requires a field name when target app metadata cannot be read")
7463
+ return {
7464
+ "field_id": str(payload.get("field_id") or "").strip() or None,
7465
+ "que_id": 0,
7466
+ "name": name,
7467
+ "type": str(payload.get("type") or "").strip() or None,
7468
+ }
7469
+
7470
+
7471
+ def _apply_relation_target_selection(
7472
+ *,
7473
+ field: dict[str, Any],
7474
+ config: dict[str, Any],
7475
+ display_field: dict[str, Any],
7476
+ visible_fields: list[dict[str, Any]],
7477
+ ) -> None:
7478
+ normalized_visible = [deepcopy(item) for item in visible_fields if isinstance(item, dict)]
7479
+ if not normalized_visible:
7480
+ normalized_visible = [deepcopy(display_field)]
7481
+ elif not any(_relation_target_field_matches(item, display_field) for item in normalized_visible):
7482
+ normalized_visible = [deepcopy(display_field), *normalized_visible]
7483
+ config["target_field_label"] = display_field.get("name")
7484
+ config["target_field_type"] = display_field.get("type")
7485
+ config["target_field_que_id"] = _coerce_positive_int(display_field.get("que_id")) or 0
7486
+ config["refer_field_ids"] = [item.get("field_id") or item.get("name") for item in normalized_visible]
7487
+ config["refer_field_que_ids"] = [_coerce_positive_int(item.get("que_id")) or 0 for item in normalized_visible]
7488
+ config["refer_field_labels"] = [item.get("name") for item in normalized_visible]
7489
+ config["refer_field_types"] = [item.get("type") for item in normalized_visible]
7490
+ config["auth_field_ids"] = [item.get("field_id") or item.get("name") for item in normalized_visible]
7491
+ config["auth_field_que_ids"] = [_coerce_positive_int(item.get("que_id")) or 0 for item in normalized_visible]
7492
+ config["field_name_show"] = bool(field.get("field_name_show", True))
7493
+ field["target_field_id"] = display_field.get("field_id") or display_field.get("name")
7494
+ field["target_field_que_id"] = _coerce_positive_int(display_field.get("que_id")) or 0
7495
+ field["config"] = config
7496
+ field["display_field"] = {
7497
+ "field_id": display_field.get("field_id"),
7498
+ "que_id": _coerce_positive_int(display_field.get("que_id")) or 0,
7499
+ "name": display_field.get("name"),
7500
+ }
7501
+ field["visible_fields"] = [
7502
+ {
7503
+ "field_id": item.get("field_id"),
7504
+ "que_id": _coerce_positive_int(item.get("que_id")) or 0,
7505
+ "name": item.get("name"),
7506
+ }
7507
+ for item in normalized_visible
7508
+ ]
7509
+
7510
+
7511
+ def _verify_relation_readback_by_name(
7512
+ *,
7513
+ verified_fields: list[dict[str, Any]],
7514
+ degraded_expectations: list[dict[str, Any]],
7515
+ ) -> bool:
7516
+ if not degraded_expectations:
7517
+ return True
7518
+ verified_by_name = {
7519
+ str(field.get("name") or "").strip(): field
7520
+ for field in verified_fields
7521
+ if isinstance(field, dict) and str(field.get("name") or "").strip()
7522
+ }
7523
+ for expectation in degraded_expectations:
7524
+ field_name = str(expectation.get("field_name") or "").strip()
7525
+ actual = verified_by_name.get(field_name)
7526
+ if not isinstance(actual, dict):
7527
+ return False
7528
+ if str(actual.get("type") or "") != FieldType.relation.value:
7529
+ return False
7530
+ if str(actual.get("target_app_key") or "") != str(expectation.get("target_app_key") or ""):
7531
+ return False
7532
+ if _normalize_relation_mode(actual.get("relation_mode")) != _normalize_relation_mode(expectation.get("relation_mode")):
7533
+ return False
7534
+ expected_display_name = str((expectation.get("display_field") or {}).get("name") or "").strip()
7535
+ actual_display_name = str((actual.get("display_field") or {}).get("name") or "").strip()
7536
+ if expected_display_name != actual_display_name:
7537
+ return False
7538
+ expected_visible_names = [
7539
+ str(item.get("name") or "").strip()
7540
+ for item in cast(list[Any], expectation.get("visible_fields") or [])
7541
+ if isinstance(item, dict) and str(item.get("name") or "").strip()
7542
+ ]
7543
+ actual_visible_names = [
7544
+ str(item.get("name") or "").strip()
7545
+ for item in cast(list[Any], actual.get("visible_fields") or [])
7546
+ if isinstance(item, dict) and str(item.get("name") or "").strip()
7547
+ ]
7548
+ if expected_visible_names != actual_visible_names:
7549
+ return False
7550
+ return True
7551
+
7552
+
7553
+ def _relation_target_metadata_skip_outcome(*, degraded_entries: list[dict[str, Any]]) -> PermissionCheckOutcome | None:
7554
+ if not degraded_entries:
7555
+ return None
7556
+ first = degraded_entries[0]
7557
+ transport_error = first.get("transport_error") if isinstance(first.get("transport_error"), dict) else None
7558
+ outcome = _permission_skip_outcome(
7559
+ scope="relation_target_metadata",
7560
+ target={
7561
+ "app_key": first.get("target_app_key"),
7562
+ "field_name": first.get("field_name"),
7563
+ },
7564
+ required_permission="read_schema",
7565
+ transport_error=deepcopy(transport_error) if transport_error is not None else None,
7566
+ )
7567
+ outcome.details["relation_target_metadata_unverified"] = True
7568
+ outcome.verification["relation_target_metadata_verified"] = False
7569
+ outcome.warnings.append(
7570
+ _warning(
7571
+ "RELATION_TARGET_METADATA_UNVERIFIED",
7572
+ "relation target metadata could not be verified before schema apply; writing using explicit field names",
7573
+ field_name=first.get("field_name"),
7574
+ target_app_key=first.get("target_app_key"),
7575
+ )
7576
+ )
7577
+ return outcome
7578
+
7579
+
7277
7580
  def _hydrate_relation_field_configs(
7278
7581
  facade: "AiBuilderFacade",
7279
7582
  *,
7280
7583
  profile: str,
7281
7584
  fields: list[dict[str, Any]],
7282
- ) -> list[dict[str, Any]]:
7585
+ ) -> RelationHydrationResult:
7283
7586
  resolved_fields = deepcopy(fields)
7284
7587
  target_field_cache: dict[str, list[dict[str, Any]]] = {}
7588
+ degraded_entries: list[dict[str, Any]] = []
7285
7589
  for field in resolved_fields:
7286
7590
  if not isinstance(field, dict) or field.get("type") != FieldType.relation.value:
7287
7591
  continue
@@ -7299,9 +7603,39 @@ def _hydrate_relation_field_configs(
7299
7603
  continue
7300
7604
  target_fields = target_field_cache.get(target_app_key)
7301
7605
  if target_fields is None:
7302
- target_schema, _ = facade._read_schema_with_fallback(profile=profile, app_key=target_app_key)
7303
- target_fields = _parse_schema(target_schema)["fields"]
7304
- target_field_cache[target_app_key] = target_fields
7606
+ try:
7607
+ target_schema, _ = facade._read_schema_with_fallback(profile=profile, app_key=target_app_key)
7608
+ target_fields = _parse_schema(target_schema)["fields"]
7609
+ target_field_cache[target_app_key] = target_fields
7610
+ except (QingflowApiError, RuntimeError) as error:
7611
+ api_error = _coerce_api_error(error)
7612
+ if not _is_relation_target_metadata_read_restricted_api_error(api_error):
7613
+ raise
7614
+ display_field = _normalize_relation_target_stub(
7615
+ display_selector,
7616
+ fallback_name=str((display_selector or {}).get("name") or "").strip() or None,
7617
+ )
7618
+ visible_fields = [
7619
+ _normalize_relation_target_stub(item, fallback_name=str(item.get("name") or "").strip() or None)
7620
+ for item in visible_selector_payloads
7621
+ ]
7622
+ _apply_relation_target_selection(
7623
+ field=field,
7624
+ config=config,
7625
+ display_field=display_field,
7626
+ visible_fields=visible_fields,
7627
+ )
7628
+ degraded_entries.append(
7629
+ {
7630
+ "field_name": field.get("name"),
7631
+ "target_app_key": target_app_key,
7632
+ "display_field": deepcopy(field.get("display_field") or {}),
7633
+ "visible_fields": deepcopy(field.get("visible_fields") or []),
7634
+ "relation_mode": field.get("relation_mode"),
7635
+ "transport_error": _transport_error_payload(api_error),
7636
+ }
7637
+ )
7638
+ continue
7305
7639
  if not target_fields:
7306
7640
  raise ValueError(f"target relation app '{target_app_key}' has no readable fields")
7307
7641
  display_field = _resolve_relation_target_field(
@@ -7313,41 +7647,17 @@ def _hydrate_relation_field_configs(
7313
7647
  _resolve_relation_target_field(target_fields=target_fields, selector_payload=item)
7314
7648
  for item in visible_selector_payloads
7315
7649
  ]
7316
- if not visible_fields:
7317
- visible_fields = [display_field]
7318
- elif not any(
7319
- str(item.get("field_id") or "") == str(display_field.get("field_id") or "")
7320
- or _coerce_positive_int(item.get("que_id")) == _coerce_positive_int(display_field.get("que_id"))
7321
- for item in visible_fields
7322
- ):
7323
- visible_fields = [display_field, *visible_fields]
7324
- config["target_field_label"] = display_field.get("name")
7325
- config["target_field_type"] = display_field.get("type")
7326
- config["target_field_que_id"] = display_field.get("que_id")
7327
- config["refer_field_ids"] = [item.get("field_id") for item in visible_fields]
7328
- config["refer_field_que_ids"] = [item.get("que_id") or 0 for item in visible_fields]
7329
- config["refer_field_labels"] = [item.get("name") for item in visible_fields]
7330
- config["refer_field_types"] = [item.get("type") for item in visible_fields]
7331
- config["auth_field_ids"] = [item.get("field_id") for item in visible_fields]
7332
- config["auth_field_que_ids"] = [item.get("que_id") or 0 for item in visible_fields]
7333
- config["field_name_show"] = bool(field.get("field_name_show", True))
7334
- field["target_field_id"] = display_field.get("field_id")
7335
- field["target_field_que_id"] = display_field.get("que_id")
7336
- field["config"] = config
7337
- field["display_field"] = {
7338
- "field_id": display_field.get("field_id"),
7339
- "que_id": display_field.get("que_id"),
7340
- "name": display_field.get("name"),
7341
- }
7342
- field["visible_fields"] = [
7343
- {
7344
- "field_id": item.get("field_id"),
7345
- "que_id": item.get("que_id"),
7346
- "name": item.get("name"),
7347
- }
7348
- for item in visible_fields
7349
- ]
7350
- return resolved_fields
7650
+ _apply_relation_target_selection(
7651
+ field=field,
7652
+ config=config,
7653
+ display_field=display_field,
7654
+ visible_fields=visible_fields,
7655
+ )
7656
+ return RelationHydrationResult(
7657
+ fields=resolved_fields,
7658
+ permission_outcome=_relation_target_metadata_skip_outcome(degraded_entries=degraded_entries),
7659
+ degraded_expectations=degraded_entries,
7660
+ )
7351
7661
 
7352
7662
 
7353
7663
  def _slugify(text: str, *, default: str) -> str:
@@ -7380,9 +7690,12 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
7380
7690
  "options": [],
7381
7691
  "option_details": [],
7382
7692
  "target_app_key": None,
7693
+ "config": {},
7383
7694
  "subfields": [],
7384
7695
  "que_id": que_id,
7385
7696
  "que_type": que_type,
7697
+ "default_type": _coerce_positive_int(question.get("queDefaultType")) or 1,
7698
+ "default_value": question.get("queDefaultValue"),
7386
7699
  }
7387
7700
  if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
7388
7701
  options = question.get("options")
@@ -7429,6 +7742,61 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
7429
7742
  }
7430
7743
  field["visible_fields"] = visible_fields
7431
7744
  field["field_name_show"] = bool(reference.get("fieldNameShow", True))
7745
+ if field_type == FieldType.code_block:
7746
+ code_block_config = question.get("codeBlockConfig") if isinstance(question.get("codeBlockConfig"), dict) else {}
7747
+ field["code_block_config"] = {
7748
+ "config_mode": _coerce_positive_int(code_block_config.get("configMode")) or 1,
7749
+ "code_content": str(code_block_config.get("codeContent") or ""),
7750
+ "being_hide_on_form": bool(code_block_config.get("beingHideOnForm", False)),
7751
+ "result_alias_path": [
7752
+ item
7753
+ for item in (
7754
+ _normalize_code_block_alias_path_item(alias)
7755
+ for alias in cast(list[Any], code_block_config.get("resultAliasPath") or [])
7756
+ )
7757
+ if item is not None
7758
+ ],
7759
+ }
7760
+ field["config"] = deepcopy(field["code_block_config"])
7761
+ if question.get("autoTrigger") is not None:
7762
+ field["auto_trigger"] = bool(question.get("autoTrigger"))
7763
+ if question.get("customBtnTextStatus") is not None:
7764
+ field["custom_button_text_enabled"] = bool(question.get("customBtnTextStatus"))
7765
+ if question.get("customBtnText") is not None:
7766
+ field["custom_button_text"] = str(question.get("customBtnText") or "")
7767
+ if field_type == FieldType.q_linker:
7768
+ remote_lookup_config = _normalize_remote_lookup_config(question.get("remoteLookupConfig") or {}) or {
7769
+ "config_mode": 1,
7770
+ "url": "",
7771
+ "method": "GET",
7772
+ "headers": [],
7773
+ "body_type": 1,
7774
+ "url_encoded_value": [],
7775
+ "json_value": None,
7776
+ "xml_value": None,
7777
+ "result_type": 1,
7778
+ "result_format_path": [],
7779
+ "query_params": [],
7780
+ "auto_trigger": None,
7781
+ "custom_button_text_enabled": None,
7782
+ "custom_button_text": None,
7783
+ "being_insert_value_directly": None,
7784
+ "being_hide_on_form": None,
7785
+ }
7786
+ if question.get("autoTrigger") is not None:
7787
+ remote_lookup_config["auto_trigger"] = bool(question.get("autoTrigger"))
7788
+ if question.get("customBtnTextStatus") is not None:
7789
+ remote_lookup_config["custom_button_text_enabled"] = bool(question.get("customBtnTextStatus"))
7790
+ if question.get("customBtnText") is not None:
7791
+ remote_lookup_config["custom_button_text"] = str(question.get("customBtnText") or "")
7792
+ field["remote_lookup_config"] = remote_lookup_config
7793
+ field["config"] = deepcopy(remote_lookup_config)
7794
+ if remote_lookup_config.get("auto_trigger") is not None:
7795
+ field["auto_trigger"] = bool(remote_lookup_config.get("auto_trigger"))
7796
+ if remote_lookup_config.get("custom_button_text_enabled") is not None:
7797
+ field["custom_button_text_enabled"] = bool(remote_lookup_config.get("custom_button_text_enabled"))
7798
+ if remote_lookup_config.get("custom_button_text") is not None:
7799
+ field["custom_button_text"] = str(remote_lookup_config.get("custom_button_text") or "")
7432
7800
  if field_type == FieldType.subtable:
7433
7801
  subfields = []
7434
7802
  for sub_question in question.get("subQuestions", []) or []:
@@ -7462,9 +7830,12 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
7462
7830
  if labels:
7463
7831
  section_rows.append(labels)
7464
7832
  if section_rows:
7833
+ parsed_section_id = _coerce_positive_int(section_question.get("queId"))
7834
+ if parsed_section_id is None:
7835
+ parsed_section_id = _coerce_positive_int(section_question.get("sectionId"))
7465
7836
  sections.append(
7466
7837
  {
7467
- "section_id": str(section_question.get("sectionId") or _slugify(section_question.get("queTitle") or "section", default="section")),
7838
+ "section_id": str(parsed_section_id or _slugify(section_question.get("queTitle") or "section", default="section")),
7468
7839
  "title": section_question.get("queTitle") or "未命名分组",
7469
7840
  "rows": section_rows,
7470
7841
  }
@@ -7480,7 +7851,10 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
7480
7851
  if labels:
7481
7852
  root_rows.append(labels)
7482
7853
  fields = list(fields_by_name.values())
7483
- return {"fields": fields, "layout": {"root_rows": root_rows, "sections": sections}}
7854
+ parsed = {"fields": fields, "layout": {"root_rows": root_rows, "sections": sections}}
7855
+ _attach_q_linker_binding_readback(parsed=parsed, raw_schema=schema)
7856
+ _attach_code_block_binding_readback(parsed=parsed, raw_schema=schema)
7857
+ return parsed
7484
7858
 
7485
7859
 
7486
7860
  def _resolve_layout_sections_to_names(
@@ -7516,8 +7890,11 @@ def _resolve_layout_sections_to_names(
7516
7890
  normalized_row.append(resolved_name)
7517
7891
  if normalized_row:
7518
7892
  normalized_rows.append(normalized_row)
7519
- normalized_section = deepcopy(section)
7520
- normalized_section["rows"] = normalized_rows
7893
+ normalized_section = {
7894
+ "section_id": section.get("section_id"),
7895
+ "title": section.get("title"),
7896
+ "rows": normalized_rows,
7897
+ }
7521
7898
  normalized_sections.append(normalized_section)
7522
7899
  return normalized_sections, missing_selectors
7523
7900
 
@@ -7556,96 +7933,554 @@ def _resolve_layout_field_name(
7556
7933
  return None
7557
7934
 
7558
7935
 
7559
- def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
7560
- payload = {
7561
- "field_id": field.get("field_id"),
7562
- "que_id": field.get("que_id"),
7563
- "name": field.get("name"),
7564
- "type": field.get("type"),
7565
- "required": bool(field.get("required")),
7566
- "section_id": _find_field_section_id(layout, str(field.get("name") or "")),
7567
- }
7568
- if field.get("type") == FieldType.relation.value:
7569
- payload["target_app_key"] = field.get("target_app_key")
7570
- payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
7571
- payload["display_field"] = deepcopy(field.get("display_field"))
7572
- payload["visible_fields"] = deepcopy(field.get("visible_fields") or [])
7573
- return payload
7936
+ _CODE_BLOCK_INPUT_LINE_RE = re.compile(
7937
+ r"^\s*const\s+(?P<var>[A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*qf_field\.\{(?P<field>.+?)\$\$[A-Z0-9-]+\$\$\};?\s*$"
7938
+ )
7574
7939
 
7575
7940
 
7576
- def _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
7577
- mapping: dict[str, int] = {}
7578
- for index, field in enumerate(fields):
7579
- field_id = str(field.get("field_id") or "")
7580
- field_name = str(field.get("name") or "")
7941
+ def _attach_code_block_binding_readback(*, parsed: dict[str, Any], raw_schema: dict[str, Any]) -> None:
7942
+ fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
7943
+ if not fields:
7944
+ return
7945
+ by_que_id = {
7946
+ _coerce_positive_int(field.get("que_id")): field
7947
+ for field in fields
7948
+ if isinstance(field, dict) and _coerce_positive_int(field.get("que_id")) is not None
7949
+ }
7950
+ relations = raw_schema.get("questionRelations") if isinstance(raw_schema.get("questionRelations"), list) else []
7951
+ relation_by_source: dict[int, list[dict[str, Any]]] = {}
7952
+ for relation in relations:
7953
+ if not isinstance(relation, dict) or _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_CODE_BLOCK:
7954
+ continue
7955
+ alias_config = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
7956
+ source_que_id = _coerce_positive_int(alias_config.get("queId"))
7957
+ target_que_id = _coerce_positive_int(relation.get("queId"))
7958
+ alias_name = str(alias_config.get("qlinkerAlias") or relation.get("qlinkerAlias") or "").strip()
7959
+ if source_que_id is None or target_que_id is None or not alias_name:
7960
+ continue
7961
+ relation_by_source.setdefault(source_que_id, []).append(
7962
+ {
7963
+ "alias": alias_name,
7964
+ "alias_id": _coerce_positive_int(alias_config.get("aliasId")),
7965
+ "target_que_id": target_que_id,
7966
+ }
7967
+ )
7968
+ for field in fields:
7969
+ if not isinstance(field, dict) or field.get("type") != FieldType.code_block.value:
7970
+ continue
7971
+ code_block_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {}
7972
+ alias_paths = cast(list[dict[str, Any]], code_block_config.get("result_alias_path") or [])
7581
7973
  que_id = _coerce_positive_int(field.get("que_id"))
7582
- if field_id:
7583
- mapping[f"field_id:{field_id}"] = index
7584
- if field_name:
7585
- mapping[f"name:{field_name}"] = index
7586
- if que_id is not None:
7587
- mapping[f"que_id:{que_id}"] = index
7588
- return mapping
7974
+ relation_items = relation_by_source.get(que_id or -1, [])
7975
+ relation_by_alias = {str(item.get("alias") or ""): item for item in relation_items if str(item.get("alias") or "")}
7976
+ inputs, code = _parse_code_block_inputs_and_body(str(code_block_config.get("code_content") or ""))
7977
+ outputs: list[dict[str, Any]] = []
7978
+ metadata_unverified = False
7979
+ for alias_item in alias_paths:
7980
+ alias_name = str(alias_item.get("alias_name") or "").strip()
7981
+ if not alias_name:
7982
+ continue
7983
+ output_payload: dict[str, Any] = {
7984
+ "alias": alias_name,
7985
+ "path": str(alias_item.get("alias_path") or ""),
7986
+ "alias_id": _coerce_positive_int(alias_item.get("alias_id", alias_item.get("aliasId"))),
7987
+ }
7988
+ relation_item = relation_by_alias.get(alias_name)
7989
+ if relation_item is not None:
7990
+ target_field = by_que_id.get(_coerce_positive_int(relation_item.get("target_que_id")))
7991
+ if isinstance(target_field, dict):
7992
+ output_payload["target_field"] = {
7993
+ "field_id": target_field.get("field_id"),
7994
+ "que_id": target_field.get("que_id"),
7995
+ "name": target_field.get("name"),
7996
+ }
7997
+ else:
7998
+ metadata_unverified = True
7999
+ outputs.append(output_payload)
8000
+ for alias_name in relation_by_alias:
8001
+ if any(str(item.get("alias") or "") == alias_name for item in outputs):
8002
+ continue
8003
+ metadata_unverified = True
8004
+ field["code_block_binding"] = {
8005
+ "inputs": inputs,
8006
+ "code": code,
8007
+ "auto_trigger": field.get("auto_trigger"),
8008
+ "custom_button_text_enabled": field.get("custom_button_text_enabled"),
8009
+ "custom_button_text": field.get("custom_button_text"),
8010
+ "outputs": outputs,
8011
+ }
8012
+ if metadata_unverified:
8013
+ field["metadata_unverified"] = True
7589
8014
 
7590
8015
 
7591
- def _resolve_selector(selector_map: dict[str, int], selector: FieldSelector) -> int | None:
7592
- if selector.field_id:
7593
- value = selector_map.get(f"field_id:{selector.field_id}")
7594
- if value is not None:
7595
- return value
7596
- if selector.que_id:
7597
- value = selector_map.get(f"que_id:{selector.que_id}")
7598
- if value is not None:
7599
- return value
7600
- if selector.name:
7601
- value = selector_map.get(f"name:{selector.name}")
7602
- if value is not None:
7603
- return value
7604
- return None
8016
+ def _normalize_q_linker_key_value_item(value: Any) -> dict[str, Any] | None:
8017
+ if not isinstance(value, dict):
8018
+ return None
8019
+ key = str(value.get("key") or "").strip()
8020
+ if not key:
8021
+ return None
8022
+ item_type = _coerce_positive_int(value.get("type")) or 1
8023
+ normalized: dict[str, Any] = {
8024
+ "key": key,
8025
+ "value": None if value.get("value") is None else str(value.get("value") or ""),
8026
+ "type": item_type,
8027
+ }
8028
+ return normalized
7605
8029
 
7606
8030
 
7607
- def _resolve_remove_selector(fields: list[dict[str, Any]], patch: FieldRemovePatch) -> int | None:
7608
- selector = FieldSelector(field_id=patch.field_id, que_id=patch.que_id, name=patch.name)
7609
- return _resolve_selector(_build_selector_map(fields), selector)
8031
+ def _normalize_q_linker_alias_path_item(value: Any) -> dict[str, Any] | None:
8032
+ if not isinstance(value, dict):
8033
+ return None
8034
+ alias_name = str(value.get("alias_name", value.get("aliasName", "")) or "").strip()
8035
+ alias_path = str(value.get("alias_path", value.get("aliasPath", "")) or "").strip()
8036
+ if not alias_name or not alias_path:
8037
+ return None
8038
+ return {
8039
+ "alias_name": alias_name,
8040
+ "alias_path": alias_path,
8041
+ "alias_id": _coerce_positive_int(value.get("alias_id", value.get("aliasId"))),
8042
+ }
7610
8043
 
7611
8044
 
7612
- def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
7613
- field_id = _slugify(patch.name, default=f"field_{uuid4().hex[:8]}")
8045
+ def _normalize_remote_lookup_config(value: Any) -> dict[str, Any] | None:
8046
+ if value is None or not isinstance(value, dict):
8047
+ return None
8048
+ headers = [
8049
+ item for item in (_normalize_q_linker_key_value_item(raw) for raw in cast(list[Any], value.get("headers") or [])) if item is not None
8050
+ ] if isinstance(value.get("headers"), list) else []
8051
+ query_params = [
8052
+ item for item in (_normalize_q_linker_key_value_item(raw) for raw in cast(list[Any], value.get("query_params", value.get("queryParams")) or [])) if item is not None
8053
+ ] if isinstance(value.get("query_params", value.get("queryParams")), list) else []
8054
+ url_encoded_value = [
8055
+ item for item in (_normalize_q_linker_key_value_item(raw) for raw in cast(list[Any], value.get("url_encoded_value", value.get("urlEncodedValue")) or [])) if item is not None
8056
+ ] if isinstance(value.get("url_encoded_value", value.get("urlEncodedValue")), list) else []
8057
+ result_format_path = [
8058
+ item for item in (_normalize_q_linker_alias_path_item(raw) for raw in cast(list[Any], value.get("result_format_path", value.get("resultFormatPath")) or [])) if item is not None
8059
+ ] if isinstance(value.get("result_format_path", value.get("resultFormatPath")), list) else []
7614
8060
  return {
7615
- "field_id": field_id,
7616
- "name": patch.name,
7617
- "type": patch.type.value,
7618
- "required": patch.required,
7619
- "description": patch.description,
7620
- "options": list(patch.options),
7621
- "target_app_key": patch.target_app_key,
7622
- "display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
7623
- "visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
7624
- "relation_mode": patch.relation_mode.value if patch.relation_mode is not None else None,
7625
- "subfields": [_field_patch_to_internal(subfield) for subfield in patch.subfields],
7626
- "que_id": None,
8061
+ "config_mode": _coerce_positive_int(value.get("config_mode", value.get("configMode"))) or 1,
8062
+ "url": str(value.get("url") or ""),
8063
+ "method": str(value.get("method") or "GET").upper() or "GET",
8064
+ "headers": headers,
8065
+ "body_type": _coerce_positive_int(value.get("body_type", value.get("bodyType"))) or 1,
8066
+ "url_encoded_value": url_encoded_value,
8067
+ "json_value": None if value.get("json_value", value.get("jsonValue")) is None else str(value.get("json_value", value.get("jsonValue")) or ""),
8068
+ "xml_value": None if value.get("xml_value", value.get("xmlValue")) is None else str(value.get("xml_value", value.get("xmlValue")) or ""),
8069
+ "result_type": _coerce_positive_int(value.get("result_type", value.get("resultType"))) or 1,
8070
+ "result_format_path": result_format_path,
8071
+ "query_params": query_params,
8072
+ "auto_trigger": None if value.get("auto_trigger", value.get("autoTrigger")) is None else bool(value.get("auto_trigger", value.get("autoTrigger"))),
8073
+ "custom_button_text_enabled": None if value.get("custom_button_text_enabled", value.get("customButtonTextEnabled", value.get("custom_btn_text_status", value.get("customBtnTextStatus")))) is None else bool(value.get("custom_button_text_enabled", value.get("customButtonTextEnabled", value.get("custom_btn_text_status", value.get("customBtnTextStatus"))))),
8074
+ "custom_button_text": None if value.get("custom_button_text", value.get("customButtonText", value.get("custom_btn_text", value.get("customBtnText")))) is None else str(value.get("custom_button_text", value.get("customButtonText", value.get("custom_btn_text", value.get("customBtnText")))) or ""),
8075
+ "being_insert_value_directly": None if value.get("being_insert_value_directly", value.get("beingInsertValueDirectly")) is None else bool(value.get("being_insert_value_directly", value.get("beingInsertValueDirectly"))),
8076
+ "being_hide_on_form": None if value.get("being_hide_on_form", value.get("beingHideOnForm")) is None else bool(value.get("being_hide_on_form", value.get("beingHideOnForm"))),
7627
8077
  }
7628
8078
 
7629
8079
 
7630
- def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
7631
- return (
7632
- str(field.get("name") or "") == patch.name
7633
- and str(field.get("type") or "") == patch.type.value
7634
- and bool(field.get("required")) == patch.required
7635
- and (field.get("description") or None) == patch.description
7636
- and list(field.get("options") or []) == list(patch.options)
7637
- and (field.get("target_app_key") or None) == patch.target_app_key
7638
- and _field_selector_payload_equal(field.get("display_field"), patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None)
7639
- and _field_selector_list_equal(field.get("visible_fields"), [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields])
7640
- and _normalize_relation_mode(field.get("relation_mode")) == _normalize_relation_mode(patch.relation_mode.value if patch.relation_mode is not None else None)
7641
- and len(field.get("subfields") or []) == len(patch.subfields)
7642
- )
8080
+ def _serialize_remote_lookup_key_value_item(value: Any) -> dict[str, Any]:
8081
+ normalized = _normalize_q_linker_key_value_item(value) or {"key": "", "value": "", "type": 1}
8082
+ return {
8083
+ "key": normalized["key"],
8084
+ "value": normalized["value"],
8085
+ "type": normalized["type"],
8086
+ }
7643
8087
 
7644
8088
 
7645
- def _field_selector_payload_equal(left: Any, right: Any) -> bool:
7646
- if left is None and right is None:
7647
- return True
7648
- if not isinstance(left, dict) or not isinstance(right, dict):
8089
+ def _serialize_q_linker_alias_path_item(value: Any) -> dict[str, Any]:
8090
+ normalized = _normalize_q_linker_alias_path_item(value) or {"alias_name": "", "alias_path": "", "alias_id": None, "alias_type": 1}
8091
+ payload = {
8092
+ "aliasName": normalized["alias_name"],
8093
+ "aliasPath": normalized["alias_path"],
8094
+ "aliasType": normalized.get("alias_type") or 1,
8095
+ }
8096
+ if normalized.get("alias_id") is not None:
8097
+ payload["aliasId"] = normalized["alias_id"]
8098
+ return payload
8099
+
8100
+
8101
+ def _normalize_q_linker_binding_input_item(value: Any) -> dict[str, Any] | None:
8102
+ if not isinstance(value, dict):
8103
+ return None
8104
+ raw_field = value.get("field")
8105
+ if isinstance(raw_field, str):
8106
+ raw_field = {"name": raw_field}
8107
+ if not isinstance(raw_field, dict):
8108
+ return None
8109
+ key = str(value.get("key") or "").strip()
8110
+ source = str(value.get("source") or "").strip().lower()
8111
+ if not key or not source:
8112
+ return None
8113
+ payload = {
8114
+ "field": {
8115
+ "field_id": str(raw_field.get("field_id") or "").strip() or None,
8116
+ "que_id": _coerce_positive_int(raw_field.get("que_id")),
8117
+ "name": str(raw_field.get("name") or "").strip() or None,
8118
+ },
8119
+ "key": key,
8120
+ "source": source,
8121
+ }
8122
+ if not any(payload["field"].values()):
8123
+ return None
8124
+ return payload
8125
+
8126
+
8127
+ def _normalize_q_linker_binding_output_item(value: Any) -> dict[str, Any] | None:
8128
+ if not isinstance(value, dict):
8129
+ return None
8130
+ alias = str(value.get("alias", value.get("alias_name", value.get("aliasName", ""))) or "").strip()
8131
+ path = str(value.get("path", value.get("alias_path", value.get("aliasPath", ""))) or "").strip()
8132
+ raw_target = value.get("target_field", value.get("targetField"))
8133
+ if isinstance(raw_target, str):
8134
+ raw_target = {"name": raw_target}
8135
+ if not alias or not path:
8136
+ return None
8137
+ target_field = {
8138
+ "field_id": None,
8139
+ "que_id": None,
8140
+ "name": None,
8141
+ }
8142
+ if isinstance(raw_target, dict):
8143
+ target_field = {
8144
+ "field_id": str(raw_target.get("field_id") or "").strip() or None,
8145
+ "que_id": _coerce_positive_int(raw_target.get("que_id")),
8146
+ "name": str(raw_target.get("name") or "").strip() or None,
8147
+ }
8148
+ if not any(target_field.values()):
8149
+ target_field = None
8150
+ else:
8151
+ target_field = None
8152
+ return {
8153
+ "alias": alias,
8154
+ "path": path,
8155
+ "alias_id": _coerce_positive_int(value.get("alias_id", value.get("aliasId"))),
8156
+ "target_field": target_field,
8157
+ }
8158
+
8159
+
8160
+ def _normalize_q_linker_request(value: Any) -> dict[str, Any] | None:
8161
+ if not isinstance(value, dict):
8162
+ return None
8163
+ return {
8164
+ "url": str(value.get("url") or ""),
8165
+ "method": str(value.get("method") or "GET").upper() or "GET",
8166
+ "headers": [
8167
+ item for item in (_normalize_q_linker_key_value_item(raw) for raw in cast(list[Any], value.get("headers") or [])) if item is not None
8168
+ ] if isinstance(value.get("headers"), list) else [],
8169
+ "query_params": [
8170
+ item for item in (_normalize_q_linker_key_value_item(raw) for raw in cast(list[Any], value.get("query_params", value.get("queryParams")) or [])) if item is not None
8171
+ ] if isinstance(value.get("query_params", value.get("queryParams")), list) else [],
8172
+ "body_type": _coerce_positive_int(value.get("body_type", value.get("bodyType"))) or 1,
8173
+ "url_encoded_value": [
8174
+ item for item in (_normalize_q_linker_key_value_item(raw) for raw in cast(list[Any], value.get("url_encoded_value", value.get("urlEncodedValue")) or [])) if item is not None
8175
+ ] if isinstance(value.get("url_encoded_value", value.get("urlEncodedValue")), list) else [],
8176
+ "json_value": None if value.get("json_value", value.get("jsonValue")) is None else str(value.get("json_value", value.get("jsonValue")) or ""),
8177
+ "xml_value": None if value.get("xml_value", value.get("xmlValue")) is None else str(value.get("xml_value", value.get("xmlValue")) or ""),
8178
+ "result_type": _coerce_positive_int(value.get("result_type", value.get("resultType"))) or 1,
8179
+ "auto_trigger": None if value.get("auto_trigger", value.get("autoTrigger")) is None else bool(value.get("auto_trigger", value.get("autoTrigger"))),
8180
+ "custom_button_text_enabled": None if value.get("custom_button_text_enabled", value.get("customButtonTextEnabled", value.get("custom_btn_text_status", value.get("customBtnTextStatus")))) is None else bool(value.get("custom_button_text_enabled", value.get("customButtonTextEnabled", value.get("custom_btn_text_status", value.get("customBtnTextStatus"))))),
8181
+ "custom_button_text": None if value.get("custom_button_text", value.get("customButtonText", value.get("custom_btn_text", value.get("customBtnText")))) is None else str(value.get("custom_button_text", value.get("customButtonText", value.get("custom_btn_text", value.get("customBtnText")))) or ""),
8182
+ "being_insert_value_directly": None if value.get("being_insert_value_directly", value.get("beingInsertValueDirectly")) is None else bool(value.get("being_insert_value_directly", value.get("beingInsertValueDirectly"))),
8183
+ "being_hide_on_form": None if value.get("being_hide_on_form", value.get("beingHideOnForm")) is None else bool(value.get("being_hide_on_form", value.get("beingHideOnForm"))),
8184
+ }
8185
+
8186
+
8187
+ def _normalize_q_linker_binding(value: Any) -> dict[str, Any] | None:
8188
+ if not isinstance(value, dict):
8189
+ return None
8190
+ request = _normalize_q_linker_request(value.get("request"))
8191
+ if request is None:
8192
+ return None
8193
+ inputs = [
8194
+ item for item in (_normalize_q_linker_binding_input_item(raw) for raw in cast(list[Any], value.get("inputs") or [])) if item is not None
8195
+ ] if isinstance(value.get("inputs"), list) else []
8196
+ outputs = [
8197
+ item for item in (_normalize_q_linker_binding_output_item(raw) for raw in cast(list[Any], value.get("outputs") or [])) if item is not None
8198
+ ] if isinstance(value.get("outputs"), list) else []
8199
+ return {
8200
+ "inputs": inputs,
8201
+ "request": request,
8202
+ "outputs": outputs,
8203
+ }
8204
+
8205
+
8206
+ def _remote_lookup_config_equal(left: Any, right: Any) -> bool:
8207
+ return _normalize_remote_lookup_config(left) == _normalize_remote_lookup_config(right)
8208
+
8209
+
8210
+ def _q_linker_binding_equal(left: Any, right: Any) -> bool:
8211
+ return _normalize_q_linker_binding(left) == _normalize_q_linker_binding(right)
8212
+
8213
+
8214
+ def _compose_qlinker_value_reference(*, field_name: str, que_id: int) -> str:
8215
+ return f"{{{field_name}$${_encrypt_question_id(que_id)}$$}}"
8216
+
8217
+
8218
+ def _attach_q_linker_binding_readback(*, parsed: dict[str, Any], raw_schema: dict[str, Any]) -> None:
8219
+ fields = parsed.get("fields") if isinstance(parsed.get("fields"), list) else []
8220
+ if not fields:
8221
+ return
8222
+ by_que_id = {
8223
+ _coerce_positive_int(field.get("que_id")): field
8224
+ for field in fields
8225
+ if isinstance(field, dict) and _coerce_positive_int(field.get("que_id")) is not None
8226
+ }
8227
+ relations = raw_schema.get("questionRelations") if isinstance(raw_schema.get("questionRelations"), list) else []
8228
+ relation_by_source: dict[int, list[dict[str, Any]]] = {}
8229
+ for relation in relations:
8230
+ if not isinstance(relation, dict) or _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_Q_LINKER:
8231
+ continue
8232
+ alias_config = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
8233
+ source_que_id = _coerce_positive_int(alias_config.get("queId")) or _coerce_positive_int(relation.get("qlinkerQueId"))
8234
+ target_que_id = _coerce_positive_int(relation.get("queId"))
8235
+ alias_name = str(alias_config.get("qlinkerAlias") or relation.get("qlinkerAlias") or "").strip()
8236
+ if source_que_id is None or target_que_id is None or not alias_name:
8237
+ continue
8238
+ relation_by_source.setdefault(source_que_id, []).append(
8239
+ {
8240
+ "alias": alias_name,
8241
+ "target_que_id": target_que_id,
8242
+ }
8243
+ )
8244
+ for field in fields:
8245
+ if not isinstance(field, dict) or field.get("type") != FieldType.q_linker.value:
8246
+ continue
8247
+ remote_lookup_config = _normalize_remote_lookup_config(field.get("remote_lookup_config") or field.get("config") or {}) or {}
8248
+ que_id = _coerce_positive_int(field.get("que_id"))
8249
+ relation_items = relation_by_source.get(que_id or -1, [])
8250
+ relation_by_alias = {str(item.get("alias") or ""): item for item in relation_items if str(item.get("alias") or "")}
8251
+ inputs: list[dict[str, Any]] = []
8252
+ static_headers: list[dict[str, Any]] = []
8253
+ static_query_params: list[dict[str, Any]] = []
8254
+ static_url_encoded: list[dict[str, Any]] = []
8255
+ metadata_unverified = False
8256
+ for source_name, config_items, static_bucket in (
8257
+ ("header", cast(list[dict[str, Any]], remote_lookup_config.get("headers") or []), static_headers),
8258
+ ("query_param", cast(list[dict[str, Any]], remote_lookup_config.get("query_params") or []), static_query_params),
8259
+ ("url_encoded", cast(list[dict[str, Any]], remote_lookup_config.get("url_encoded_value") or []), static_url_encoded),
8260
+ ):
8261
+ for item in config_items:
8262
+ if _coerce_positive_int(item.get("type")) == 2:
8263
+ source_field = by_que_id.get(_coerce_positive_int(item.get("value")))
8264
+ if isinstance(source_field, dict):
8265
+ inputs.append(
8266
+ {
8267
+ "field": {
8268
+ "field_id": source_field.get("field_id"),
8269
+ "que_id": source_field.get("que_id"),
8270
+ "name": source_field.get("name"),
8271
+ },
8272
+ "key": item.get("key"),
8273
+ "source": source_name,
8274
+ }
8275
+ )
8276
+ else:
8277
+ metadata_unverified = True
8278
+ else:
8279
+ static_bucket.append({"key": item.get("key"), "value": item.get("value")})
8280
+ outputs: list[dict[str, Any]] = []
8281
+ for alias_item in cast(list[dict[str, Any]], remote_lookup_config.get("result_format_path") or []):
8282
+ alias_name = str(alias_item.get("alias_name") or "").strip()
8283
+ if not alias_name:
8284
+ continue
8285
+ output_payload: dict[str, Any] = {
8286
+ "alias": alias_name,
8287
+ "path": str(alias_item.get("alias_path") or ""),
8288
+ "alias_id": _coerce_positive_int(alias_item.get("alias_id")),
8289
+ }
8290
+ relation_item = relation_by_alias.get(alias_name)
8291
+ if relation_item is not None:
8292
+ target_field = by_que_id.get(_coerce_positive_int(relation_item.get("target_que_id")))
8293
+ if isinstance(target_field, dict):
8294
+ output_payload["target_field"] = {
8295
+ "field_id": target_field.get("field_id"),
8296
+ "que_id": target_field.get("que_id"),
8297
+ "name": target_field.get("name"),
8298
+ }
8299
+ else:
8300
+ metadata_unverified = True
8301
+ else:
8302
+ metadata_unverified = True
8303
+ outputs.append(output_payload)
8304
+ field["q_linker_binding"] = {
8305
+ "inputs": inputs,
8306
+ "request": {
8307
+ "url": remote_lookup_config.get("url"),
8308
+ "method": remote_lookup_config.get("method"),
8309
+ "headers": static_headers,
8310
+ "query_params": static_query_params,
8311
+ "body_type": remote_lookup_config.get("body_type"),
8312
+ "url_encoded_value": static_url_encoded,
8313
+ "json_value": remote_lookup_config.get("json_value"),
8314
+ "xml_value": remote_lookup_config.get("xml_value"),
8315
+ "result_type": remote_lookup_config.get("result_type"),
8316
+ "auto_trigger": remote_lookup_config.get("auto_trigger"),
8317
+ "custom_button_text_enabled": remote_lookup_config.get("custom_button_text_enabled"),
8318
+ "custom_button_text": remote_lookup_config.get("custom_button_text"),
8319
+ "being_insert_value_directly": remote_lookup_config.get("being_insert_value_directly"),
8320
+ "being_hide_on_form": remote_lookup_config.get("being_hide_on_form"),
8321
+ },
8322
+ "outputs": outputs,
8323
+ }
8324
+ if metadata_unverified:
8325
+ field["metadata_unverified"] = True
8326
+
8327
+
8328
+ def _parse_code_block_inputs_and_body(code_content: str) -> tuple[list[dict[str, Any]], str]:
8329
+ if not code_content:
8330
+ return [], ""
8331
+ lines = code_content.splitlines()
8332
+ inputs: list[dict[str, Any]] = []
8333
+ index = 0
8334
+ while index < len(lines):
8335
+ line = lines[index]
8336
+ if not line.strip():
8337
+ index += 1
8338
+ continue
8339
+ matched = _CODE_BLOCK_INPUT_LINE_RE.match(line)
8340
+ if matched is None:
8341
+ break
8342
+ inputs.append({"field": {"name": matched.group("field")}, "var": matched.group("var")})
8343
+ index += 1
8344
+ body = "\n".join(lines[index:]).lstrip("\n")
8345
+ return inputs, body
8346
+
8347
+
8348
+ def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
8349
+ payload = {
8350
+ "field_id": field.get("field_id"),
8351
+ "que_id": field.get("que_id"),
8352
+ "name": field.get("name"),
8353
+ "type": field.get("type"),
8354
+ "required": bool(field.get("required")),
8355
+ "section_id": _find_field_section_id(layout, str(field.get("name") or "")),
8356
+ }
8357
+ if field.get("type") == FieldType.relation.value:
8358
+ payload["target_app_key"] = field.get("target_app_key")
8359
+ payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
8360
+ payload["display_field"] = deepcopy(field.get("display_field"))
8361
+ payload["visible_fields"] = deepcopy(field.get("visible_fields") or [])
8362
+ if field.get("type") == FieldType.code_block.value:
8363
+ payload["code_block_config"] = deepcopy(field.get("code_block_config") or {})
8364
+ if field.get("auto_trigger") is not None:
8365
+ payload["auto_trigger"] = bool(field.get("auto_trigger"))
8366
+ if field.get("custom_button_text_enabled") is not None:
8367
+ payload["custom_button_text_enabled"] = bool(field.get("custom_button_text_enabled"))
8368
+ if field.get("custom_button_text") is not None:
8369
+ payload["custom_button_text"] = field.get("custom_button_text")
8370
+ if field.get("code_block_binding") is not None:
8371
+ payload["code_block_binding"] = deepcopy(field.get("code_block_binding"))
8372
+ if field.get("metadata_unverified") is not None:
8373
+ payload["metadata_unverified"] = bool(field.get("metadata_unverified"))
8374
+ if field.get("type") == FieldType.q_linker.value:
8375
+ payload["remote_lookup_config"] = deepcopy(field.get("remote_lookup_config") or {})
8376
+ if field.get("q_linker_binding") is not None:
8377
+ payload["q_linker_binding"] = deepcopy(field.get("q_linker_binding"))
8378
+ if field.get("auto_trigger") is not None:
8379
+ payload["auto_trigger"] = bool(field.get("auto_trigger"))
8380
+ if field.get("custom_button_text_enabled") is not None:
8381
+ payload["custom_button_text_enabled"] = bool(field.get("custom_button_text_enabled"))
8382
+ if field.get("custom_button_text") is not None:
8383
+ payload["custom_button_text"] = field.get("custom_button_text")
8384
+ if field.get("metadata_unverified") is not None:
8385
+ payload["metadata_unverified"] = bool(field.get("metadata_unverified"))
8386
+ return payload
8387
+
8388
+
8389
+ def _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
8390
+ mapping: dict[str, int] = {}
8391
+ for index, field in enumerate(fields):
8392
+ field_id = str(field.get("field_id") or "")
8393
+ field_name = str(field.get("name") or "")
8394
+ que_id = _coerce_positive_int(field.get("que_id"))
8395
+ if field_id:
8396
+ mapping[f"field_id:{field_id}"] = index
8397
+ if field_name:
8398
+ mapping[f"name:{field_name}"] = index
8399
+ if que_id is not None:
8400
+ mapping[f"que_id:{que_id}"] = index
8401
+ return mapping
8402
+
8403
+
8404
+ def _resolve_selector(selector_map: dict[str, int], selector: FieldSelector) -> int | None:
8405
+ if selector.field_id:
8406
+ value = selector_map.get(f"field_id:{selector.field_id}")
8407
+ if value is not None:
8408
+ return value
8409
+ if selector.que_id:
8410
+ value = selector_map.get(f"que_id:{selector.que_id}")
8411
+ if value is not None:
8412
+ return value
8413
+ if selector.name:
8414
+ value = selector_map.get(f"name:{selector.name}")
8415
+ if value is not None:
8416
+ return value
8417
+ return None
8418
+
8419
+
8420
+ def _resolve_remove_selector(fields: list[dict[str, Any]], patch: FieldRemovePatch) -> int | None:
8421
+ selector = FieldSelector(field_id=patch.field_id, que_id=patch.que_id, name=patch.name)
8422
+ return _resolve_selector(_build_selector_map(fields), selector)
8423
+
8424
+
8425
+ def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
8426
+ field_id = _slugify(patch.name, default=f"field_{uuid4().hex[:8]}")
8427
+ remote_lookup_config = patch.remote_lookup_config.model_dump(mode="json", exclude_none=True) if patch.remote_lookup_config is not None else None
8428
+ q_linker_binding = patch.q_linker_binding.model_dump(mode="json", exclude_none=True) if patch.q_linker_binding is not None else None
8429
+ code_block_config = patch.code_block_config.model_dump(mode="json", exclude_none=True) if patch.code_block_config is not None else None
8430
+ code_block_binding = patch.code_block_binding.model_dump(mode="json", exclude_none=True) if patch.code_block_binding is not None else None
8431
+ return {
8432
+ "field_id": field_id,
8433
+ "name": patch.name,
8434
+ "type": patch.type.value,
8435
+ "required": patch.required,
8436
+ "description": patch.description,
8437
+ "options": list(patch.options),
8438
+ "target_app_key": patch.target_app_key,
8439
+ "display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
8440
+ "visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
8441
+ "relation_mode": patch.relation_mode.value if patch.relation_mode is not None else None,
8442
+ "remote_lookup_config": remote_lookup_config,
8443
+ "_explicit_remote_lookup_config": remote_lookup_config is not None,
8444
+ "q_linker_binding": q_linker_binding,
8445
+ "code_block_config": code_block_config,
8446
+ "code_block_binding": code_block_binding,
8447
+ "config": deepcopy(remote_lookup_config) if remote_lookup_config is not None else (deepcopy(code_block_config) if code_block_config is not None else {}),
8448
+ "auto_trigger": patch.auto_trigger,
8449
+ "custom_button_text_enabled": patch.custom_button_text_enabled,
8450
+ "custom_button_text": patch.custom_button_text,
8451
+ "subfields": [_field_patch_to_internal(subfield) for subfield in patch.subfields],
8452
+ "que_id": None,
8453
+ "default_type": 1,
8454
+ "default_value": None,
8455
+ }
8456
+
8457
+
8458
+ def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
8459
+ return (
8460
+ str(field.get("name") or "") == patch.name
8461
+ and str(field.get("type") or "") == patch.type.value
8462
+ and bool(field.get("required")) == patch.required
8463
+ and (field.get("description") or None) == patch.description
8464
+ and list(field.get("options") or []) == list(patch.options)
8465
+ and (field.get("target_app_key") or None) == patch.target_app_key
8466
+ and _field_selector_payload_equal(field.get("display_field"), patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None)
8467
+ and _field_selector_list_equal(field.get("visible_fields"), [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields])
8468
+ and _normalize_relation_mode(field.get("relation_mode")) == _normalize_relation_mode(patch.relation_mode.value if patch.relation_mode is not None else None)
8469
+ and _remote_lookup_config_equal(field.get("remote_lookup_config"), patch.remote_lookup_config.model_dump(mode="json", exclude_none=True) if patch.remote_lookup_config is not None else None)
8470
+ and _q_linker_binding_equal(field.get("q_linker_binding"), patch.q_linker_binding.model_dump(mode="json", exclude_none=True) if patch.q_linker_binding is not None else None)
8471
+ and _code_block_config_equal(field.get("code_block_config"), patch.code_block_config.model_dump(mode="json", exclude_none=True) if patch.code_block_config is not None else None)
8472
+ and _code_block_binding_equal(field.get("code_block_binding"), patch.code_block_binding.model_dump(mode="json", exclude_none=True) if patch.code_block_binding is not None else None)
8473
+ and _optional_bool_equal(field.get("auto_trigger"), patch.auto_trigger)
8474
+ and _optional_bool_equal(field.get("custom_button_text_enabled"), patch.custom_button_text_enabled)
8475
+ and _optional_string_equal(field.get("custom_button_text"), patch.custom_button_text)
8476
+ and len(field.get("subfields") or []) == len(patch.subfields)
8477
+ )
8478
+
8479
+
8480
+ def _field_selector_payload_equal(left: Any, right: Any) -> bool:
8481
+ if left is None and right is None:
8482
+ return True
8483
+ if not isinstance(left, dict) or not isinstance(right, dict):
7649
8484
  return False
7650
8485
  return (
7651
8486
  str(left.get("field_id") or "") == str(right.get("field_id") or "")
@@ -7662,6 +8497,146 @@ def _field_selector_list_equal(left: Any, right: Any) -> bool:
7662
8497
  return all(_field_selector_payload_equal(item_left, item_right) for item_left, item_right in zip(left_items, right_items))
7663
8498
 
7664
8499
 
8500
+ def _optional_bool_equal(left: Any, right: Any) -> bool:
8501
+ if left is None and right is None:
8502
+ return True
8503
+ return bool(left) == bool(right)
8504
+
8505
+
8506
+ def _optional_string_equal(left: Any, right: Any) -> bool:
8507
+ if left is None and right is None:
8508
+ return True
8509
+ return str(left or "") == str(right or "")
8510
+
8511
+
8512
+ def _normalize_code_block_alias_path_item(value: Any) -> dict[str, Any] | None:
8513
+ if not isinstance(value, dict):
8514
+ return None
8515
+ payload = {
8516
+ "alias_name": str(value.get("alias_name", value.get("aliasName", "")) or "").strip(),
8517
+ "alias_path": str(value.get("alias_path", value.get("aliasPath", "")) or "").strip(),
8518
+ "alias_type": _coerce_positive_int(value.get("alias_type", value.get("aliasType"))) or 1,
8519
+ "alias_id": _coerce_positive_int(value.get("alias_id", value.get("aliasId"))),
8520
+ "sub_alias": [],
8521
+ }
8522
+ sub_alias_raw = value.get("sub_alias", value.get("subAlias"))
8523
+ if isinstance(sub_alias_raw, list):
8524
+ payload["sub_alias"] = [
8525
+ item for item in (_normalize_code_block_alias_path_item(sub_item) for sub_item in sub_alias_raw) if item is not None
8526
+ ]
8527
+ return payload
8528
+
8529
+
8530
+ def _normalize_code_block_config(value: Any) -> dict[str, Any] | None:
8531
+ if value is None:
8532
+ return None
8533
+ if not isinstance(value, dict):
8534
+ return None
8535
+ result_alias_path_raw = value.get("result_alias_path", value.get("resultAliasPath", value.get("alias_config", value.get("aliasConfig"))))
8536
+ result_alias_path = []
8537
+ if isinstance(result_alias_path_raw, list):
8538
+ result_alias_path = [
8539
+ item for item in (_normalize_code_block_alias_path_item(alias) for alias in result_alias_path_raw) if item is not None
8540
+ ]
8541
+ return {
8542
+ "config_mode": _coerce_positive_int(value.get("config_mode", value.get("configMode"))) or 1,
8543
+ "code_content": str(value.get("code_content", value.get("codeContent", "")) or ""),
8544
+ "being_hide_on_form": bool(value.get("being_hide_on_form", value.get("beingHideOnForm", False))),
8545
+ "result_alias_path": result_alias_path,
8546
+ }
8547
+
8548
+
8549
+ def _code_block_config_equal(left: Any, right: Any) -> bool:
8550
+ return _normalize_code_block_config(left) == _normalize_code_block_config(right)
8551
+
8552
+
8553
+ def _normalize_code_block_binding_input_item(value: Any) -> dict[str, Any] | None:
8554
+ if isinstance(value, str):
8555
+ return {"field": {"name": value}, "var": None}
8556
+ if not isinstance(value, dict):
8557
+ return None
8558
+ raw_field = value.get("field")
8559
+ if isinstance(raw_field, str):
8560
+ raw_field = {"name": raw_field}
8561
+ elif not isinstance(raw_field, dict):
8562
+ for key in ("field_name", "fieldName", "name", "title", "label"):
8563
+ raw_value = value.get(key)
8564
+ if isinstance(raw_value, str) and raw_value.strip():
8565
+ raw_field = {"name": raw_value.strip()}
8566
+ break
8567
+ if not isinstance(raw_field, dict):
8568
+ return None
8569
+ payload = {
8570
+ "field": {
8571
+ "field_id": str(raw_field.get("field_id") or "").strip() or None,
8572
+ "que_id": _coerce_positive_int(raw_field.get("que_id")),
8573
+ "name": str(raw_field.get("name") or "").strip() or None,
8574
+ },
8575
+ "var": str(value.get("var") or "").strip() or None,
8576
+ }
8577
+ if not any(payload["field"].values()):
8578
+ return None
8579
+ return payload
8580
+
8581
+
8582
+ def _normalize_code_block_binding_output_item(value: Any) -> dict[str, Any] | None:
8583
+ if not isinstance(value, dict):
8584
+ return None
8585
+ alias = str(value.get("alias", value.get("alias_name", value.get("aliasName", ""))) or "").strip()
8586
+ path = str(value.get("path", value.get("alias_path", value.get("aliasPath", ""))) or "").strip()
8587
+ raw_target = value.get("target_field", value.get("targetField"))
8588
+ if isinstance(raw_target, str):
8589
+ raw_target = {"name": raw_target}
8590
+ target_field = None
8591
+ if isinstance(raw_target, dict):
8592
+ target_field = {
8593
+ "field_id": str(raw_target.get("field_id") or "").strip() or None,
8594
+ "que_id": _coerce_positive_int(raw_target.get("que_id")),
8595
+ "name": str(raw_target.get("name") or "").strip() or None,
8596
+ }
8597
+ if not any(target_field.values()):
8598
+ target_field = None
8599
+ if not alias or not path:
8600
+ return None
8601
+ return {
8602
+ "alias": alias,
8603
+ "path": path,
8604
+ "alias_id": _coerce_positive_int(value.get("alias_id", value.get("aliasId"))),
8605
+ "target_field": target_field,
8606
+ }
8607
+
8608
+
8609
+ def _normalize_code_block_binding(value: Any) -> dict[str, Any] | None:
8610
+ if value is None or not isinstance(value, dict):
8611
+ return None
8612
+ inputs = []
8613
+ outputs = []
8614
+ if isinstance(value.get("inputs"), list):
8615
+ inputs = [
8616
+ item
8617
+ for item in (_normalize_code_block_binding_input_item(raw_item) for raw_item in cast(list[Any], value.get("inputs") or []))
8618
+ if item is not None
8619
+ ]
8620
+ if isinstance(value.get("outputs"), list):
8621
+ outputs = [
8622
+ item
8623
+ for item in (_normalize_code_block_binding_output_item(raw_item) for raw_item in cast(list[Any], value.get("outputs") or []))
8624
+ if item is not None
8625
+ ]
8626
+ return {
8627
+ "inputs": inputs,
8628
+ "code": str(value.get("code", value.get("code_content", value.get("codeContent", ""))) or ""),
8629
+ "auto_trigger": None if value.get("auto_trigger", value.get("autoTrigger")) is None else bool(value.get("auto_trigger", value.get("autoTrigger"))),
8630
+ "custom_button_text_enabled": None if value.get("custom_button_text_enabled", value.get("customButtonTextEnabled", value.get("custom_btn_text_status", value.get("customBtnTextStatus")))) is None else bool(value.get("custom_button_text_enabled", value.get("customButtonTextEnabled", value.get("custom_btn_text_status", value.get("customBtnTextStatus"))))),
8631
+ "custom_button_text": None if value.get("custom_button_text", value.get("customButtonText", value.get("custom_btn_text", value.get("customBtnText")))) is None else str(value.get("custom_button_text", value.get("customButtonText", value.get("custom_btn_text", value.get("customBtnText")))) or ""),
8632
+ "outputs": outputs,
8633
+ }
8634
+
8635
+
8636
+ def _code_block_binding_equal(left: Any, right: Any) -> bool:
8637
+ return _normalize_code_block_binding(left) == _normalize_code_block_binding(right)
8638
+
8639
+
7665
8640
  def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
7666
8641
  payload = mutation.model_dump(mode="json", exclude_none=True)
7667
8642
  if "name" in payload:
@@ -7682,10 +8657,785 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
7682
8657
  field["visible_fields"] = list(payload["visible_fields"])
7683
8658
  if "relation_mode" in payload:
7684
8659
  field["relation_mode"] = payload["relation_mode"]
8660
+ if "remote_lookup_config" in payload:
8661
+ field["remote_lookup_config"] = payload["remote_lookup_config"]
8662
+ field["config"] = deepcopy(payload["remote_lookup_config"])
8663
+ field["_explicit_remote_lookup_config"] = True
8664
+ if "q_linker_binding" in payload:
8665
+ field["q_linker_binding"] = payload["q_linker_binding"]
8666
+ if "remote_lookup_config" not in payload:
8667
+ field["_explicit_remote_lookup_config"] = False
8668
+ if "code_block_config" in payload:
8669
+ field["code_block_config"] = payload["code_block_config"]
8670
+ field["config"] = deepcopy(payload["code_block_config"])
8671
+ if "code_block_binding" in payload:
8672
+ field["code_block_binding"] = payload["code_block_binding"]
8673
+ if "auto_trigger" in payload:
8674
+ field["auto_trigger"] = payload["auto_trigger"]
8675
+ if "custom_button_text_enabled" in payload:
8676
+ field["custom_button_text_enabled"] = payload["custom_button_text_enabled"]
8677
+ if "custom_button_text" in payload:
8678
+ field["custom_button_text"] = payload["custom_button_text"]
7685
8679
  if "subfields" in payload:
7686
8680
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
7687
8681
 
7688
8682
 
8683
+ def _resolve_field_selector_with_uniqueness(
8684
+ *,
8685
+ fields: list[dict[str, Any]],
8686
+ selector_payload: dict[str, Any],
8687
+ location: str,
8688
+ ) -> dict[str, Any]:
8689
+ selector = FieldSelector.model_validate(selector_payload)
8690
+ if selector.field_id:
8691
+ matched = [field for field in fields if str(field.get("field_id") or "") == str(selector.field_id)]
8692
+ elif selector.que_id:
8693
+ matched = [field for field in fields if _coerce_positive_int(field.get("que_id")) == _coerce_positive_int(selector.que_id)]
8694
+ elif selector.name:
8695
+ name = str(selector.name or "").strip()
8696
+ matched = [field for field in fields if str(field.get("name") or "").strip() == name]
8697
+ if len(matched) > 1:
8698
+ raise ValueError(f"{location} matched multiple fields with the same name; use field_id or que_id")
8699
+ else:
8700
+ matched = []
8701
+ if len(matched) != 1:
8702
+ raise ValueError(f"{location} could not be resolved")
8703
+ return matched[0]
8704
+
8705
+
8706
+ def _default_code_block_input_var(*, field_name: str, que_id: int | None, index: int) -> str:
8707
+ seed = _slugify(field_name, default="")
8708
+ if seed:
8709
+ parts = [part for part in re.split(r"[-_]+", seed) if part]
8710
+ candidate = parts[0] + "".join(part.capitalize() for part in parts[1:])
8711
+ if candidate and re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", candidate):
8712
+ return candidate
8713
+ return f"field_{que_id or index + 1}"
8714
+
8715
+
8716
+ def _encrypt_question_id(que_id: int) -> str:
8717
+ have_prefix = que_id < 0
8718
+ abs_que_id = abs(que_id)
8719
+ result_length = 4
8720
+ ran = abs_que_id * 17
8721
+ inp = str(abs_que_id)
8722
+ seeds: list[int] = []
8723
+ total = 0
8724
+ key = 1000013
8725
+ for _ in range(max(result_length - len(inp), 0)):
8726
+ tmp = (ran % 20) + 71
8727
+ ran = ((ran + 13) * 17) % key
8728
+ total += tmp
8729
+ seeds.append(tmp)
8730
+ result = [((total + index + int(char, 16)) % 15) for index, char in enumerate(inp)]
8731
+ for seed in seeds:
8732
+ position = (ran + seed) % len(result)
8733
+ ran = ((ran + 13) * 17) % key
8734
+ result.insert(position, seed)
8735
+ rendered = "".join(chr(item) if item > 15 else format(item, "X") for item in result)
8736
+ return f"-{rendered}" if have_prefix else rendered
8737
+
8738
+
8739
+ def _compose_code_block_input_reference(*, field_name: str, que_id: int) -> str:
8740
+ return f"qf_field.{{{field_name}$${_encrypt_question_id(que_id)}$$}}"
8741
+
8742
+
8743
+ def _strip_code_block_generated_input_prelude(code_content: str) -> str:
8744
+ _inputs, body = _parse_code_block_inputs_and_body(code_content)
8745
+ return body
8746
+
8747
+
8748
+ def _normalize_code_block_output_assignment(code_content: str) -> str:
8749
+ return re.sub(r"(?m)^(\s*)(?:const|let)\s+qf_output\s*=", r"\1qf_output =", code_content)
8750
+
8751
+
8752
+ def _ensure_field_temp_ids(fields: list[dict[str, Any]]) -> None:
8753
+ temp_id = -10000
8754
+ for field in fields:
8755
+ if not isinstance(field, dict):
8756
+ continue
8757
+ if _coerce_positive_int(field.get("que_id")) is None and _coerce_any_int(field.get("que_temp_id")) is None:
8758
+ field["que_temp_id"] = temp_id
8759
+ temp_id -= 100
8760
+
8761
+
8762
+ def _compile_code_block_binding_fields(
8763
+ *,
8764
+ fields: list[dict[str, Any]],
8765
+ current_schema: dict[str, Any],
8766
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
8767
+ next_fields = deepcopy(fields)
8768
+ _ensure_field_temp_ids(next_fields)
8769
+ by_name = {
8770
+ str(field.get("name") or ""): field
8771
+ for field in next_fields
8772
+ if isinstance(field, dict) and str(field.get("name") or "")
8773
+ }
8774
+ existing_relations = current_schema.get("questionRelations") if isinstance(current_schema.get("questionRelations"), list) else []
8775
+ relation_specs: list[dict[str, Any]] = []
8776
+ affected_source_refs: set[int] = set()
8777
+ affected_target_refs: set[int] = set()
8778
+ for field in next_fields:
8779
+ if not isinstance(field, dict) or field.get("type") != FieldType.code_block.value:
8780
+ continue
8781
+ binding = _normalize_code_block_binding(field.get("code_block_binding"))
8782
+ if binding is None:
8783
+ continue
8784
+ current_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
8785
+ "config_mode": 1,
8786
+ "code_content": "",
8787
+ "being_hide_on_form": False,
8788
+ "result_alias_path": [],
8789
+ }
8790
+ low_level_code = _strip_code_block_generated_input_prelude(str(current_config.get("code_content") or ""))
8791
+ if str(current_config.get("code_content") or "").strip() and low_level_code != str(binding.get("code") or ""):
8792
+ raise ValueError(f"code_block field '{field.get('name')}' has conflicting code_block_config.code_content and code_block_binding.code")
8793
+ compiled_inputs: list[dict[str, Any]] = []
8794
+ input_lines: list[str] = []
8795
+ for index, input_item in enumerate(cast(list[dict[str, Any]], binding.get("inputs") or [])):
8796
+ source_field = _resolve_field_selector_with_uniqueness(
8797
+ fields=next_fields,
8798
+ selector_payload=cast(dict[str, Any], input_item.get("field") or {}),
8799
+ location=f"code_block_binding.inputs[{index}].field",
8800
+ )
8801
+ source_que_id = _coerce_positive_int(source_field.get("que_id"))
8802
+ if source_que_id is None:
8803
+ source_que_id = _coerce_any_int(source_field.get("que_temp_id"))
8804
+ if source_que_id is None:
8805
+ raise ValueError(f"code_block input field '{source_field.get('name')}' does not have a stable que_id")
8806
+ variable_name = str(input_item.get("var") or "").strip() or _default_code_block_input_var(
8807
+ field_name=str(source_field.get("name") or ""),
8808
+ que_id=source_que_id,
8809
+ index=index,
8810
+ )
8811
+ if not re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", variable_name):
8812
+ raise ValueError(f"code_block_binding.inputs[{index}].var must be a valid JavaScript identifier")
8813
+ compiled_inputs.append({"field": {"name": source_field.get("name"), "que_id": source_que_id}, "var": variable_name})
8814
+ input_lines.append(f"const {variable_name} = {_compose_code_block_input_reference(field_name=str(source_field.get('name') or ''), que_id=source_que_id)};")
8815
+ compiled_outputs: list[dict[str, Any]] = []
8816
+ existing_alias_by_name = {
8817
+ str(item.get("alias_name") or ""): item
8818
+ for item in cast(list[dict[str, Any]], current_config.get("result_alias_path") or [])
8819
+ if str(item.get("alias_name") or "")
8820
+ }
8821
+ body = _normalize_code_block_output_assignment(str(binding.get("code") or ""))
8822
+ compiled_code = "\n".join(input_lines + ([body] if body else [])).strip()
8823
+ for output_index, output_item in enumerate(cast(list[dict[str, Any]], binding.get("outputs") or [])):
8824
+ alias_name = str(output_item.get("alias") or "").strip()
8825
+ alias_path = str(output_item.get("path") or "").strip()
8826
+ existing_alias = existing_alias_by_name.get(alias_name) or {}
8827
+ alias_id = _coerce_positive_int(existing_alias.get("alias_id"))
8828
+ compiled_outputs.append(
8829
+ {
8830
+ "alias_name": alias_name,
8831
+ "alias_path": alias_path,
8832
+ "alias_type": 1,
8833
+ "alias_id": alias_id,
8834
+ "sub_alias": [],
8835
+ }
8836
+ )
8837
+ target_payload = output_item.get("target_field")
8838
+ if not isinstance(target_payload, dict):
8839
+ raise ValueError(f"code_block_binding.outputs[{output_index}].target_field is required")
8840
+ target_field = _resolve_field_selector_with_uniqueness(
8841
+ fields=next_fields,
8842
+ selector_payload=target_payload,
8843
+ location=f"code_block_binding.outputs[{output_index}].target_field",
8844
+ )
8845
+ if str(target_field.get("type") or "") in {FieldType.code_block.value, FieldType.subtable.value, FieldType.relation.value}:
8846
+ raise ValueError(f"code_block output target field '{target_field.get('name')}' uses an unsupported field type")
8847
+ target_que_ref = _coerce_positive_int(target_field.get("que_id"))
8848
+ if target_que_ref is None:
8849
+ target_que_ref = _coerce_any_int(target_field.get("que_temp_id"))
8850
+ compiled_outputs[-1]["target_field"] = {
8851
+ "field_id": target_field.get("field_id"),
8852
+ "que_id": target_que_ref,
8853
+ "name": target_field.get("name"),
8854
+ }
8855
+ current_config["code_content"] = compiled_code
8856
+ current_config["result_alias_path"] = compiled_outputs
8857
+ field["code_block_config"] = current_config
8858
+ field["config"] = deepcopy(current_config)
8859
+ field["auto_trigger"] = binding.get("auto_trigger")
8860
+ field["custom_button_text_enabled"] = binding.get("custom_button_text_enabled")
8861
+ field["custom_button_text"] = binding.get("custom_button_text")
8862
+ field["code_block_binding"] = {
8863
+ "inputs": compiled_inputs,
8864
+ "code": body,
8865
+ "auto_trigger": binding.get("auto_trigger"),
8866
+ "custom_button_text_enabled": binding.get("custom_button_text_enabled"),
8867
+ "custom_button_text": binding.get("custom_button_text"),
8868
+ "outputs": compiled_outputs,
8869
+ }
8870
+ source_ref = _coerce_positive_int(field.get("que_id"))
8871
+ if source_ref is None:
8872
+ source_ref = _coerce_any_int(field.get("que_temp_id"))
8873
+ if source_ref is not None:
8874
+ affected_source_refs.add(source_ref)
8875
+ for output_item in compiled_outputs:
8876
+ target_payload = output_item.get("target_field")
8877
+ if not isinstance(target_payload, dict):
8878
+ continue
8879
+ target_field = by_name.get(str(target_payload.get("name") or ""))
8880
+ if not isinstance(target_field, dict):
8881
+ continue
8882
+ target_ref = _coerce_positive_int(target_field.get("que_id"))
8883
+ if target_ref is None:
8884
+ target_ref = _coerce_any_int(target_field.get("que_temp_id"))
8885
+ if target_ref is None or source_ref is None:
8886
+ continue
8887
+ target_field["default_type"] = DEFAULT_TYPE_RELATION
8888
+ affected_target_refs.add(target_ref)
8889
+ relation_specs.append(
8890
+ {
8891
+ "source_field_name": field.get("name"),
8892
+ "source_field_ref": source_ref,
8893
+ "target_field_name": target_field.get("name"),
8894
+ "target_field_ref": target_ref,
8895
+ "alias": output_item.get("alias_name"),
8896
+ "alias_id": _coerce_positive_int(output_item.get("alias_id")),
8897
+ }
8898
+ )
8899
+ carried_relations: list[dict[str, Any]] = []
8900
+ for relation in existing_relations:
8901
+ if not isinstance(relation, dict):
8902
+ continue
8903
+ if _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_CODE_BLOCK:
8904
+ carried_relations.append(deepcopy(relation))
8905
+ continue
8906
+ alias_config = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
8907
+ source_ref = _coerce_positive_int(alias_config.get("queId"))
8908
+ target_ref = _coerce_positive_int(relation.get("queId"))
8909
+ if (source_ref is not None and source_ref in affected_source_refs) or (target_ref is not None and target_ref in affected_target_refs):
8910
+ continue
8911
+ carried_relations.append(deepcopy(relation))
8912
+ carried_relations.extend(_materialize_code_block_question_relations(fields=next_fields, relation_specs=relation_specs))
8913
+ existing_target_refs = _collect_code_block_relation_target_refs(cast(list[dict[str, Any]], existing_relations))
8914
+ final_target_refs = _collect_code_block_relation_target_refs(carried_relations)
8915
+ for field in next_fields:
8916
+ if not isinstance(field, dict):
8917
+ continue
8918
+ field_ref = _coerce_positive_int(field.get("que_id"))
8919
+ if field_ref is None:
8920
+ field_ref = _coerce_any_int(field.get("que_temp_id"))
8921
+ if field_ref is None or field_ref not in existing_target_refs:
8922
+ continue
8923
+ if field_ref in final_target_refs:
8924
+ field["default_type"] = DEFAULT_TYPE_RELATION
8925
+ continue
8926
+ if _coerce_any_int(field.get("default_type")) == DEFAULT_TYPE_RELATION:
8927
+ field["default_type"] = 1
8928
+ return next_fields, carried_relations
8929
+
8930
+
8931
+ def _materialize_code_block_question_relations(
8932
+ fields: list[dict[str, Any]],
8933
+ relation_specs: list[dict[str, Any]],
8934
+ ) -> list[dict[str, Any]]:
8935
+ by_name = {str(field.get("name") or ""): field for field in fields if isinstance(field, dict) and str(field.get("name") or "")}
8936
+ materialized: list[dict[str, Any]] = []
8937
+ for relation in relation_specs:
8938
+ if not isinstance(relation, dict):
8939
+ continue
8940
+ if _coerce_positive_int(relation.get("relationType")) == RELATION_TYPE_CODE_BLOCK:
8941
+ materialized.append(deepcopy(relation))
8942
+ continue
8943
+ source_field = by_name.get(str(relation.get("source_field_name") or ""))
8944
+ target_field = by_name.get(str(relation.get("target_field_name") or ""))
8945
+ if not isinstance(source_field, dict) or not isinstance(target_field, dict):
8946
+ continue
8947
+ source_ref = _coerce_positive_int(source_field.get("que_id"))
8948
+ if source_ref is None:
8949
+ source_ref = _coerce_any_int(source_field.get("que_temp_id"))
8950
+ if source_ref is None:
8951
+ source_ref = _coerce_any_int(relation.get("source_field_ref"))
8952
+ target_ref = _coerce_positive_int(target_field.get("que_id"))
8953
+ if target_ref is None:
8954
+ target_ref = _coerce_any_int(target_field.get("que_temp_id"))
8955
+ if target_ref is None:
8956
+ target_ref = _coerce_any_int(relation.get("target_field_ref"))
8957
+ if source_ref is None or target_ref is None:
8958
+ continue
8959
+ materialized.append(
8960
+ {
8961
+ "queId": target_ref,
8962
+ "relationType": RELATION_TYPE_CODE_BLOCK,
8963
+ "displayedQueId": None,
8964
+ "displayedQueInfo": None,
8965
+ "qlinkerAlias": relation.get("alias"),
8966
+ "aliasConfig": {
8967
+ "queId": source_ref,
8968
+ "queTitle": source_field.get("name"),
8969
+ "qlinkerAlias": relation.get("alias"),
8970
+ "aliasId": _coerce_positive_int(relation.get("alias_id")),
8971
+ },
8972
+ "matchRuleType": 1,
8973
+ "matchRules": [],
8974
+ "matchRuleFormula": None,
8975
+ "tableMatchRules": [],
8976
+ "sortConfig": None,
8977
+ }
8978
+ )
8979
+ return materialized
8980
+
8981
+
8982
+ def _collect_code_block_relation_target_refs(relations: list[dict[str, Any]]) -> set[int]:
8983
+ target_refs: set[int] = set()
8984
+ for relation in relations:
8985
+ if not isinstance(relation, dict):
8986
+ continue
8987
+ if _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_CODE_BLOCK:
8988
+ continue
8989
+ target_ref = _coerce_any_int(relation.get("queId"))
8990
+ if target_ref is not None:
8991
+ target_refs.add(target_ref)
8992
+ return target_refs
8993
+
8994
+
8995
+ def _code_block_relations_need_source_rebind(question_relations: list[dict[str, Any]]) -> bool:
8996
+ for relation in question_relations:
8997
+ if not isinstance(relation, dict):
8998
+ continue
8999
+ if _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_CODE_BLOCK:
9000
+ continue
9001
+ alias_config = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
9002
+ source_ref = _coerce_any_int(alias_config.get("queId"))
9003
+ alias_id = _coerce_positive_int(alias_config.get("aliasId"))
9004
+ if (source_ref is not None and source_ref <= 0) or alias_id is None:
9005
+ return True
9006
+ return False
9007
+
9008
+
9009
+ def _overlay_code_block_binding_fields(*, target_fields: list[dict[str, Any]], source_fields: list[dict[str, Any]]) -> None:
9010
+ source_by_name = {
9011
+ str(field.get("name") or ""): field
9012
+ for field in source_fields
9013
+ if isinstance(field, dict) and str(field.get("name") or "")
9014
+ }
9015
+ for field in target_fields:
9016
+ if not isinstance(field, dict):
9017
+ continue
9018
+ source = source_by_name.get(str(field.get("name") or ""))
9019
+ if not isinstance(source, dict):
9020
+ continue
9021
+ if source.get("code_block_binding") is not None:
9022
+ binding = deepcopy(source.get("code_block_binding"))
9023
+ if isinstance(binding, dict):
9024
+ for input_item in binding.get("inputs") or []:
9025
+ if not isinstance(input_item, dict):
9026
+ continue
9027
+ selector = input_item.get("field")
9028
+ if isinstance(selector, dict):
9029
+ selector["field_id"] = None
9030
+ if _coerce_positive_int(selector.get("que_id")) is None:
9031
+ selector["que_id"] = None
9032
+ for output_item in binding.get("outputs") or []:
9033
+ if not isinstance(output_item, dict):
9034
+ continue
9035
+ selector = output_item.get("target_field")
9036
+ if isinstance(selector, dict):
9037
+ selector["field_id"] = None
9038
+ if _coerce_positive_int(selector.get("que_id")) is None:
9039
+ selector["que_id"] = None
9040
+ field["code_block_binding"] = binding
9041
+
9042
+
9043
+ def _q_linker_target_type_supported(field_type: str) -> bool:
9044
+ return field_type in {
9045
+ FieldType.text.value,
9046
+ FieldType.long_text.value,
9047
+ FieldType.number.value,
9048
+ FieldType.amount.value,
9049
+ FieldType.date.value,
9050
+ FieldType.datetime.value,
9051
+ FieldType.single_select.value,
9052
+ FieldType.multi_select.value,
9053
+ FieldType.boolean.value,
9054
+ }
9055
+
9056
+
9057
+ def _compile_q_linker_binding_fields(
9058
+ *,
9059
+ fields: list[dict[str, Any]],
9060
+ current_schema: dict[str, Any],
9061
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
9062
+ next_fields = deepcopy(fields)
9063
+ _ensure_field_temp_ids(next_fields)
9064
+ by_name = {
9065
+ str(field.get("name") or ""): field
9066
+ for field in next_fields
9067
+ if isinstance(field, dict) and str(field.get("name") or "")
9068
+ }
9069
+ existing_relations = current_schema.get("questionRelations") if isinstance(current_schema.get("questionRelations"), list) else []
9070
+ relation_specs: list[dict[str, Any]] = []
9071
+ affected_source_refs: set[int] = set()
9072
+ affected_target_refs: set[int] = set()
9073
+ for field in next_fields:
9074
+ if not isinstance(field, dict) or field.get("type") != FieldType.q_linker.value:
9075
+ continue
9076
+ binding = _normalize_q_linker_binding(field.get("q_linker_binding"))
9077
+ if binding is None:
9078
+ continue
9079
+ current_config_source = field.get("remote_lookup_config")
9080
+ current_config_raw = _normalize_remote_lookup_config(current_config_source) if current_config_source is not None else None
9081
+ explicit_remote_lookup_config = bool(field.get("_explicit_remote_lookup_config"))
9082
+ current_config = current_config_raw or {
9083
+ "config_mode": 1,
9084
+ "url": "",
9085
+ "method": "GET",
9086
+ "headers": [],
9087
+ "body_type": 1,
9088
+ "url_encoded_value": [],
9089
+ "json_value": None,
9090
+ "xml_value": None,
9091
+ "result_type": 1,
9092
+ "result_format_path": [],
9093
+ "query_params": [],
9094
+ "auto_trigger": None,
9095
+ "custom_button_text_enabled": None,
9096
+ "custom_button_text": None,
9097
+ "being_insert_value_directly": None,
9098
+ "being_hide_on_form": None,
9099
+ }
9100
+ request = _normalize_q_linker_request(binding.get("request")) or {
9101
+ "url": "",
9102
+ "method": "GET",
9103
+ "headers": [],
9104
+ "query_params": [],
9105
+ "body_type": 1,
9106
+ "url_encoded_value": [],
9107
+ "json_value": None,
9108
+ "xml_value": None,
9109
+ "result_type": 1,
9110
+ "auto_trigger": None,
9111
+ "custom_button_text_enabled": None,
9112
+ "custom_button_text": None,
9113
+ "being_insert_value_directly": None,
9114
+ "being_hide_on_form": None,
9115
+ }
9116
+ if int(request.get("config_mode") or 1) != 1:
9117
+ raise ValueError(f"q_linker field '{field.get('name')}' currently only supports custom config_mode=1")
9118
+ request_view = {
9119
+ "config_mode": 1,
9120
+ "url": request.get("url"),
9121
+ "method": request.get("method"),
9122
+ "headers": request.get("headers") or [],
9123
+ "body_type": request.get("body_type"),
9124
+ "url_encoded_value": request.get("url_encoded_value") or [],
9125
+ "json_value": request.get("json_value"),
9126
+ "xml_value": request.get("xml_value"),
9127
+ "result_type": request.get("result_type"),
9128
+ "result_format_path": [],
9129
+ "query_params": request.get("query_params") or [],
9130
+ "auto_trigger": request.get("auto_trigger"),
9131
+ "custom_button_text_enabled": request.get("custom_button_text_enabled"),
9132
+ "custom_button_text": request.get("custom_button_text"),
9133
+ "being_insert_value_directly": request.get("being_insert_value_directly"),
9134
+ "being_hide_on_form": request.get("being_hide_on_form"),
9135
+ }
9136
+ current_view = {
9137
+ "config_mode": current_config.get("config_mode"),
9138
+ "url": current_config.get("url"),
9139
+ "method": current_config.get("method"),
9140
+ "headers": current_config.get("headers") or [],
9141
+ "body_type": current_config.get("body_type"),
9142
+ "url_encoded_value": current_config.get("url_encoded_value") or [],
9143
+ "json_value": current_config.get("json_value"),
9144
+ "xml_value": current_config.get("xml_value"),
9145
+ "result_type": current_config.get("result_type"),
9146
+ "result_format_path": [],
9147
+ "query_params": current_config.get("query_params") or [],
9148
+ "auto_trigger": current_config.get("auto_trigger"),
9149
+ "custom_button_text_enabled": current_config.get("custom_button_text_enabled"),
9150
+ "custom_button_text": current_config.get("custom_button_text"),
9151
+ "being_insert_value_directly": current_config.get("being_insert_value_directly"),
9152
+ "being_hide_on_form": current_config.get("being_hide_on_form"),
9153
+ }
9154
+ if explicit_remote_lookup_config and current_config_raw is not None and current_view != request_view:
9155
+ raise ValueError(f"q_linker field '{field.get('name')}' has conflicting remote_lookup_config and q_linker_binding.request")
9156
+
9157
+ compiled_inputs: list[dict[str, Any]] = []
9158
+ compiled_headers = deepcopy(cast(list[dict[str, Any]], request.get("headers") or []))
9159
+ compiled_query_params = deepcopy(cast(list[dict[str, Any]], request.get("query_params") or []))
9160
+ compiled_url_encoded = deepcopy(cast(list[dict[str, Any]], request.get("url_encoded_value") or []))
9161
+ compiled_url = str(request.get("url") or "")
9162
+ compiled_json_value = None if request.get("json_value") is None else str(request.get("json_value") or "")
9163
+ compiled_xml_value = None if request.get("xml_value") is None else str(request.get("xml_value") or "")
9164
+ for index, input_item in enumerate(cast(list[dict[str, Any]], binding.get("inputs") or [])):
9165
+ source_field = _resolve_field_selector_with_uniqueness(
9166
+ fields=next_fields,
9167
+ selector_payload=cast(dict[str, Any], input_item.get("field") or {}),
9168
+ location=f"q_linker_binding.inputs[{index}].field",
9169
+ )
9170
+ source_que_id = _coerce_positive_int(source_field.get("que_id"))
9171
+ if source_que_id is None:
9172
+ source_que_id = _coerce_any_int(source_field.get("que_temp_id"))
9173
+ if source_que_id is None:
9174
+ raise ValueError(f"q_linker input field '{source_field.get('name')}' does not have a stable que_id")
9175
+ key = str(input_item.get("key") or "").strip()
9176
+ source_kind = str(input_item.get("source") or "").strip().lower()
9177
+ compiled_inputs.append(
9178
+ {
9179
+ "field": {"name": source_field.get("name"), "que_id": source_que_id},
9180
+ "key": key,
9181
+ "source": source_kind,
9182
+ }
9183
+ )
9184
+ kv_item = {"key": key, "value": str(source_que_id), "type": 2}
9185
+ if source_kind == "header":
9186
+ compiled_headers.append(kv_item)
9187
+ elif source_kind == "query_param":
9188
+ compiled_query_params.append(kv_item)
9189
+ elif source_kind == "url_encoded":
9190
+ compiled_url_encoded.append(kv_item)
9191
+ elif source_kind == "json_path":
9192
+ token = "{{" + key + "}}"
9193
+ reference = _compose_qlinker_value_reference(field_name=str(source_field.get("name") or ""), que_id=source_que_id)
9194
+ compiled_url = compiled_url.replace(token, reference)
9195
+ if compiled_json_value is not None:
9196
+ compiled_json_value = compiled_json_value.replace(token, reference)
9197
+ if compiled_xml_value is not None:
9198
+ compiled_xml_value = compiled_xml_value.replace(token, reference)
9199
+ else:
9200
+ raise ValueError(f"q_linker_binding.inputs[{index}].source must be one of query_param, header, url_encoded, json_path")
9201
+
9202
+ compiled_outputs: list[dict[str, Any]] = []
9203
+ existing_alias_by_name = {
9204
+ str(item.get("alias_name") or ""): item
9205
+ for item in cast(list[dict[str, Any]], current_config.get("result_format_path") or [])
9206
+ if str(item.get("alias_name") or "")
9207
+ }
9208
+ for output_index, output_item in enumerate(cast(list[dict[str, Any]], binding.get("outputs") or [])):
9209
+ target_payload = output_item.get("target_field")
9210
+ if not isinstance(target_payload, dict):
9211
+ raise ValueError(f"q_linker_binding.outputs[{output_index}].target_field is required")
9212
+ target_field = _resolve_field_selector_with_uniqueness(
9213
+ fields=next_fields,
9214
+ selector_payload=target_payload,
9215
+ location=f"q_linker_binding.outputs[{output_index}].target_field",
9216
+ )
9217
+ target_type = str(target_field.get("type") or "")
9218
+ if not _q_linker_target_type_supported(target_type):
9219
+ raise ValueError(f"q_linker output target field '{target_field.get('name')}' uses an unsupported field type")
9220
+ target_ref = _coerce_positive_int(target_field.get("que_id"))
9221
+ if target_ref is None:
9222
+ target_ref = _coerce_any_int(target_field.get("que_temp_id"))
9223
+ alias_name = str(output_item.get("alias") or "").strip()
9224
+ alias_path = str(output_item.get("path") or "").strip()
9225
+ compiled_outputs.append(
9226
+ {
9227
+ "alias_name": alias_name,
9228
+ "alias_path": alias_path,
9229
+ "alias_id": _coerce_positive_int((existing_alias_by_name.get(alias_name) or {}).get("alias_id")),
9230
+ "target_field": {
9231
+ "field_id": target_field.get("field_id"),
9232
+ "que_id": target_ref,
9233
+ "name": target_field.get("name"),
9234
+ },
9235
+ }
9236
+ )
9237
+
9238
+ current_config.update(
9239
+ {
9240
+ "config_mode": 1,
9241
+ "url": compiled_url,
9242
+ "method": str(request.get("method") or "GET"),
9243
+ "headers": compiled_headers,
9244
+ "body_type": int(request.get("body_type") or 1),
9245
+ "url_encoded_value": compiled_url_encoded,
9246
+ "json_value": compiled_json_value,
9247
+ "xml_value": compiled_xml_value,
9248
+ "result_type": int(request.get("result_type") or 1),
9249
+ "result_format_path": compiled_outputs,
9250
+ "query_params": compiled_query_params,
9251
+ "auto_trigger": request.get("auto_trigger"),
9252
+ "custom_button_text_enabled": request.get("custom_button_text_enabled"),
9253
+ "custom_button_text": request.get("custom_button_text"),
9254
+ "being_insert_value_directly": request.get("being_insert_value_directly"),
9255
+ "being_hide_on_form": request.get("being_hide_on_form"),
9256
+ }
9257
+ )
9258
+ field["remote_lookup_config"] = current_config
9259
+ field["_explicit_remote_lookup_config"] = False
9260
+ field["config"] = deepcopy(current_config)
9261
+ field["auto_trigger"] = request.get("auto_trigger")
9262
+ field["custom_button_text_enabled"] = request.get("custom_button_text_enabled")
9263
+ field["custom_button_text"] = request.get("custom_button_text")
9264
+ field["q_linker_binding"] = {
9265
+ "inputs": compiled_inputs,
9266
+ "request": request_view,
9267
+ "outputs": compiled_outputs,
9268
+ }
9269
+ source_ref = _coerce_positive_int(field.get("que_id"))
9270
+ if source_ref is None:
9271
+ source_ref = _coerce_any_int(field.get("que_temp_id"))
9272
+ if source_ref is not None:
9273
+ affected_source_refs.add(source_ref)
9274
+ for output_item in compiled_outputs:
9275
+ target_payload = output_item.get("target_field")
9276
+ if not isinstance(target_payload, dict):
9277
+ continue
9278
+ target_field = by_name.get(str(target_payload.get("name") or ""))
9279
+ if not isinstance(target_field, dict):
9280
+ continue
9281
+ target_ref = _coerce_positive_int(target_field.get("que_id"))
9282
+ if target_ref is None:
9283
+ target_ref = _coerce_any_int(target_field.get("que_temp_id"))
9284
+ if target_ref is None or source_ref is None:
9285
+ continue
9286
+ target_field["default_type"] = DEFAULT_TYPE_RELATION
9287
+ affected_target_refs.add(target_ref)
9288
+ relation_specs.append(
9289
+ {
9290
+ "source_field_name": field.get("name"),
9291
+ "source_field_ref": source_ref,
9292
+ "target_field_name": target_field.get("name"),
9293
+ "target_field_ref": target_ref,
9294
+ "alias": output_item.get("alias_name"),
9295
+ }
9296
+ )
9297
+
9298
+ carried_relations: list[dict[str, Any]] = []
9299
+ for relation in existing_relations:
9300
+ if not isinstance(relation, dict):
9301
+ continue
9302
+ if _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_Q_LINKER:
9303
+ carried_relations.append(deepcopy(relation))
9304
+ continue
9305
+ alias_config = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
9306
+ source_ref = _coerce_positive_int(alias_config.get("queId")) or _coerce_positive_int(relation.get("qlinkerQueId"))
9307
+ target_ref = _coerce_positive_int(relation.get("queId"))
9308
+ if (source_ref is not None and source_ref in affected_source_refs) or (target_ref is not None and target_ref in affected_target_refs):
9309
+ continue
9310
+ carried_relations.append(deepcopy(relation))
9311
+ carried_relations.extend(_materialize_q_linker_question_relations(fields=next_fields, relation_specs=relation_specs))
9312
+ existing_target_refs = _collect_q_linker_relation_target_refs(cast(list[dict[str, Any]], existing_relations))
9313
+ final_target_refs = _collect_q_linker_relation_target_refs(carried_relations)
9314
+ for field in next_fields:
9315
+ if not isinstance(field, dict):
9316
+ continue
9317
+ field_ref = _coerce_positive_int(field.get("que_id"))
9318
+ if field_ref is None:
9319
+ field_ref = _coerce_any_int(field.get("que_temp_id"))
9320
+ if field_ref is None or field_ref not in existing_target_refs:
9321
+ continue
9322
+ if field_ref in final_target_refs:
9323
+ field["default_type"] = DEFAULT_TYPE_RELATION
9324
+ continue
9325
+ if _coerce_any_int(field.get("default_type")) == DEFAULT_TYPE_RELATION:
9326
+ field["default_type"] = 1
9327
+ return next_fields, carried_relations
9328
+
9329
+
9330
+ def _materialize_q_linker_question_relations(
9331
+ fields: list[dict[str, Any]],
9332
+ relation_specs: list[dict[str, Any]],
9333
+ ) -> list[dict[str, Any]]:
9334
+ by_name = {str(field.get("name") or ""): field for field in fields if isinstance(field, dict) and str(field.get("name") or "")}
9335
+ materialized: list[dict[str, Any]] = []
9336
+ for relation in relation_specs:
9337
+ if not isinstance(relation, dict):
9338
+ continue
9339
+ source_field = by_name.get(str(relation.get("source_field_name") or ""))
9340
+ target_field = by_name.get(str(relation.get("target_field_name") or ""))
9341
+ if not isinstance(source_field, dict) or not isinstance(target_field, dict):
9342
+ continue
9343
+ source_ref = _coerce_positive_int(source_field.get("que_id"))
9344
+ if source_ref is None:
9345
+ source_ref = _coerce_any_int(source_field.get("que_temp_id"))
9346
+ if source_ref is None:
9347
+ source_ref = _coerce_any_int(relation.get("source_field_ref"))
9348
+ target_ref = _coerce_positive_int(target_field.get("que_id"))
9349
+ if target_ref is None:
9350
+ target_ref = _coerce_any_int(target_field.get("que_temp_id"))
9351
+ if target_ref is None:
9352
+ target_ref = _coerce_any_int(relation.get("target_field_ref"))
9353
+ if source_ref is None or target_ref is None:
9354
+ continue
9355
+ materialized.append(
9356
+ {
9357
+ "queId": target_ref,
9358
+ "relationType": RELATION_TYPE_Q_LINKER,
9359
+ "displayedQueId": None,
9360
+ "displayedQueInfo": None,
9361
+ "qlinkerQueId": source_ref,
9362
+ "qlinkerAlias": relation.get("alias"),
9363
+ "aliasConfig": {
9364
+ "queId": source_ref,
9365
+ "queTitle": source_field.get("name"),
9366
+ "qlinkerAlias": relation.get("alias"),
9367
+ "aliasId": None,
9368
+ },
9369
+ "matchRuleType": 1,
9370
+ "matchRules": [],
9371
+ "matchRuleFormula": None,
9372
+ "tableMatchRules": [],
9373
+ "sortConfig": None,
9374
+ }
9375
+ )
9376
+ return materialized
9377
+
9378
+
9379
+ def _collect_q_linker_relation_target_refs(relations: list[dict[str, Any]]) -> set[int]:
9380
+ target_refs: set[int] = set()
9381
+ for relation in relations:
9382
+ if not isinstance(relation, dict):
9383
+ continue
9384
+ if _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_Q_LINKER:
9385
+ continue
9386
+ target_ref = _coerce_any_int(relation.get("queId"))
9387
+ if target_ref is not None:
9388
+ target_refs.add(target_ref)
9389
+ return target_refs
9390
+
9391
+
9392
+ def _q_linker_relations_need_source_rebind(question_relations: list[dict[str, Any]]) -> bool:
9393
+ for relation in question_relations:
9394
+ if not isinstance(relation, dict):
9395
+ continue
9396
+ if _coerce_positive_int(relation.get("relationType")) != RELATION_TYPE_Q_LINKER:
9397
+ continue
9398
+ alias_config = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
9399
+ source_ref = _coerce_any_int(alias_config.get("queId")) or _coerce_any_int(relation.get("qlinkerQueId"))
9400
+ if source_ref is not None and source_ref <= 0:
9401
+ return True
9402
+ return False
9403
+
9404
+
9405
+ def _overlay_q_linker_binding_fields(*, target_fields: list[dict[str, Any]], source_fields: list[dict[str, Any]]) -> None:
9406
+ source_by_name = {
9407
+ str(field.get("name") or ""): field
9408
+ for field in source_fields
9409
+ if isinstance(field, dict) and str(field.get("name") or "")
9410
+ }
9411
+ for field in target_fields:
9412
+ if not isinstance(field, dict):
9413
+ continue
9414
+ source = source_by_name.get(str(field.get("name") or ""))
9415
+ if not isinstance(source, dict):
9416
+ continue
9417
+ if source.get("q_linker_binding") is not None:
9418
+ binding = deepcopy(source.get("q_linker_binding"))
9419
+ if isinstance(binding, dict):
9420
+ for input_item in binding.get("inputs") or []:
9421
+ if not isinstance(input_item, dict):
9422
+ continue
9423
+ selector = input_item.get("field")
9424
+ if isinstance(selector, dict):
9425
+ selector["field_id"] = None
9426
+ if _coerce_positive_int(selector.get("que_id")) is None:
9427
+ selector["que_id"] = None
9428
+ for output_item in binding.get("outputs") or []:
9429
+ if not isinstance(output_item, dict):
9430
+ continue
9431
+ selector = output_item.get("target_field")
9432
+ if isinstance(selector, dict):
9433
+ selector["field_id"] = None
9434
+ if _coerce_positive_int(selector.get("que_id")) is None:
9435
+ selector["que_id"] = None
9436
+ field["q_linker_binding"] = binding
9437
+
9438
+
7689
9439
  def _append_field_to_layout(layout: dict[str, Any], field_name: str) -> dict[str, Any]:
7690
9440
  next_layout = deepcopy(layout)
7691
9441
  if next_layout.get("sections"):
@@ -7846,6 +9596,20 @@ def _detect_layout_mode(layout: dict[str, Any]) -> str:
7846
9596
  return "empty"
7847
9597
 
7848
9598
 
9599
+ def _decorate_layout_sections_as_paragraphs(sections: Any) -> list[dict[str, Any]]:
9600
+ if not isinstance(sections, list):
9601
+ return []
9602
+ decorated: list[dict[str, Any]] = []
9603
+ for section in sections:
9604
+ if not isinstance(section, dict):
9605
+ continue
9606
+ payload = deepcopy(section)
9607
+ payload["type"] = "paragraph"
9608
+ payload["paragraph_id"] = payload.get("section_id")
9609
+ decorated.append(payload)
9610
+ return decorated
9611
+
9612
+
7849
9613
  def _build_verification_hints(
7850
9614
  *,
7851
9615
  tag_ids: list[int],
@@ -8160,6 +9924,13 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
8160
9924
  )
8161
9925
  if field.get("que_id"):
8162
9926
  question["queId"] = field["que_id"]
9927
+ else:
9928
+ question["queTempId"] = temp_id
9929
+ field["que_temp_id"] = temp_id
9930
+ if field.get("default_type") is not None:
9931
+ question["queDefaultType"] = _coerce_positive_int(field.get("default_type")) or 1
9932
+ if "default_value" in field:
9933
+ question["queDefaultValue"] = field.get("default_value")
8163
9934
  if field.get("type") == FieldType.relation.value:
8164
9935
  reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
8165
9936
  reference["referAppKey"] = field.get("target_app_key")
@@ -8168,15 +9939,95 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
8168
9939
  reference["referQueId"] = field.get("target_field_que_id")
8169
9940
  reference["optionalDataNum"] = _relation_mode_to_optional_data_num(field.get("relation_mode"))
8170
9941
  question["referenceConfig"] = reference
9942
+ if field.get("type") == FieldType.code_block.value:
9943
+ code_block_config = _normalize_code_block_config(field.get("code_block_config") or field.get("config") or {}) or {
9944
+ "config_mode": 1,
9945
+ "code_content": "",
9946
+ "being_hide_on_form": False,
9947
+ "result_alias_path": [],
9948
+ }
9949
+ question["codeBlockConfig"] = {
9950
+ "configMode": code_block_config["config_mode"],
9951
+ "codeContent": code_block_config["code_content"],
9952
+ "resultAliasPath": [
9953
+ _serialize_code_block_alias_path_item(item) for item in code_block_config.get("result_alias_path", [])
9954
+ ],
9955
+ "beingHideOnForm": code_block_config["being_hide_on_form"],
9956
+ }
9957
+ question["autoTrigger"] = bool(field.get("auto_trigger", False))
9958
+ question["customBtnTextStatus"] = bool(field.get("custom_button_text_enabled", False))
9959
+ question["customBtnText"] = str(field.get("custom_button_text") or "")
9960
+ if field.get("type") == FieldType.q_linker.value:
9961
+ remote_lookup_config = _normalize_remote_lookup_config(field.get("remote_lookup_config") or field.get("config") or {}) or {
9962
+ "config_mode": 1,
9963
+ "url": "",
9964
+ "method": "GET",
9965
+ "headers": [],
9966
+ "body_type": 1,
9967
+ "url_encoded_value": [],
9968
+ "json_value": None,
9969
+ "xml_value": None,
9970
+ "result_type": 1,
9971
+ "result_format_path": [],
9972
+ "query_params": [],
9973
+ "auto_trigger": None,
9974
+ "custom_button_text_enabled": None,
9975
+ "custom_button_text": None,
9976
+ "being_insert_value_directly": None,
9977
+ "being_hide_on_form": None,
9978
+ }
9979
+ question["remoteLookupConfig"] = {
9980
+ "configMode": remote_lookup_config["config_mode"],
9981
+ "url": remote_lookup_config["url"],
9982
+ "method": remote_lookup_config["method"],
9983
+ "headers": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("headers", [])],
9984
+ "bodyType": remote_lookup_config["body_type"],
9985
+ "urlEncodedValue": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("url_encoded_value", [])],
9986
+ "jsonValue": remote_lookup_config.get("json_value"),
9987
+ "xmlValue": remote_lookup_config.get("xml_value"),
9988
+ "resultType": remote_lookup_config["result_type"],
9989
+ "resultFormatPath": [_serialize_q_linker_alias_path_item(item) for item in remote_lookup_config.get("result_format_path", [])],
9990
+ "queryParams": [_serialize_remote_lookup_key_value_item(item) for item in remote_lookup_config.get("query_params", [])],
9991
+ "beingInsertValueDirectly": bool(remote_lookup_config.get("being_insert_value_directly", False)),
9992
+ "beingHideOnForm": bool(remote_lookup_config.get("being_hide_on_form", False)),
9993
+ }
9994
+ if remote_lookup_config["config_mode"] == 1:
9995
+ question["remoteLookupConfig"]["openAppConfig"] = {"event": {"eventId": 0, "name": "custom"}}
9996
+ question["autoTrigger"] = bool(field.get("auto_trigger", remote_lookup_config.get("auto_trigger", False)))
9997
+ question["customBtnTextStatus"] = bool(field.get("custom_button_text_enabled", remote_lookup_config.get("custom_button_text_enabled", False)))
9998
+ question["customBtnText"] = str(field.get("custom_button_text") or remote_lookup_config.get("custom_button_text") or "")
8171
9999
  return question
8172
10000
 
8173
10001
 
10002
+ def _serialize_code_block_alias_path_item(value: Any) -> dict[str, Any]:
10003
+ normalized = _normalize_code_block_alias_path_item(value) or {
10004
+ "alias_name": "",
10005
+ "alias_path": "",
10006
+ "alias_type": 1,
10007
+ "alias_id": None,
10008
+ "sub_alias": [],
10009
+ }
10010
+ payload = {
10011
+ "aliasName": normalized["alias_name"],
10012
+ "aliasPath": normalized["alias_path"],
10013
+ "aliasType": normalized["alias_type"],
10014
+ }
10015
+ if normalized.get("alias_id") is not None:
10016
+ payload["aliasId"] = normalized["alias_id"]
10017
+ if normalized.get("sub_alias"):
10018
+ payload["subAlias"] = [
10019
+ _serialize_code_block_alias_path_item(item) for item in normalized.get("sub_alias", [])
10020
+ ]
10021
+ return payload
10022
+
10023
+
8174
10024
  def _build_form_payload_from_fields(
8175
10025
  *,
8176
10026
  title: str,
8177
10027
  current_schema: dict[str, Any],
8178
10028
  fields: list[dict[str, Any]],
8179
10029
  layout: dict[str, Any],
10030
+ question_relations: list[dict[str, Any]] | None = None,
8180
10031
  ) -> dict[str, Any]:
8181
10032
  questions_by_name: dict[str, dict[str, Any]] = {}
8182
10033
  temp_id = -10000
@@ -8197,35 +10048,34 @@ def _build_form_payload_from_fields(
8197
10048
  inner_rows.append(questions)
8198
10049
  if not inner_rows:
8199
10050
  continue
8200
- form_rows.append(
8201
- [
8202
- {
8203
- "queId": 0,
8204
- "queTempId": -(20000 + sum(ord(ch) for ch in str(section.get("section_id") or section.get("title") or "section"))),
8205
- "queType": 24,
8206
- "queTitle": section.get("title") or "未命名分组",
8207
- "queWidth": 100,
8208
- "scanType": 1,
8209
- "status": 1,
8210
- "required": False,
8211
- "queHint": "",
8212
- "linkedQuestions": {},
8213
- "logicalShow": True,
8214
- "queDefaultValue": None,
8215
- "queDefaultType": 1,
8216
- "subQueWidth": 2,
8217
- "innerQuestions": inner_rows,
8218
- "beingHide": False,
8219
- "beingDesensitized": False,
8220
- "sectionId": section.get("section_id"),
8221
- }
8222
- ]
8223
- )
10051
+ wrapper = {
10052
+ "queId": 0,
10053
+ "queTempId": -(20000 + sum(ord(ch) for ch in str(section.get("section_id") or section.get("title") or "section"))),
10054
+ "queType": 24,
10055
+ "queTitle": section.get("title") or "未命名分组",
10056
+ "queWidth": 100,
10057
+ "scanType": 1,
10058
+ "status": 1,
10059
+ "required": False,
10060
+ "queHint": "",
10061
+ "linkedQuestions": {},
10062
+ "logicalShow": True,
10063
+ "queDefaultValue": None,
10064
+ "queDefaultType": 1,
10065
+ "subQueWidth": 2,
10066
+ "innerQuestions": inner_rows,
10067
+ "beingHide": False,
10068
+ "beingDesensitized": False,
10069
+ }
10070
+ parsed_section_id = _coerce_positive_int(section.get("section_id"))
10071
+ if parsed_section_id is not None:
10072
+ wrapper["sectionId"] = parsed_section_id
10073
+ form_rows.append([wrapper])
8224
10074
  for row in form_rows:
8225
10075
  _apply_row_widths(row)
8226
10076
  payload = default_form_payload(title, form_rows)
8227
10077
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
8228
- payload["questionRelations"] = deepcopy(current_schema.get("questionRelations") or [])
10078
+ payload["questionRelations"] = deepcopy(question_relations if question_relations is not None else (current_schema.get("questionRelations") or []))
8229
10079
  return payload
8230
10080
 
8231
10081
 
@@ -9550,7 +11400,11 @@ def _build_form_payload_from_existing_schema(
9550
11400
  "beingDesensitized": False,
9551
11401
  }
9552
11402
  wrapper["queTitle"] = section.get("title") or wrapper.get("queTitle") or "未命名分组"
9553
- wrapper["sectionId"] = section.get("section_id") or wrapper.get("sectionId")
11403
+ parsed_section_id = _coerce_positive_int(section.get("section_id"))
11404
+ if parsed_section_id is not None:
11405
+ wrapper["sectionId"] = parsed_section_id
11406
+ elif _coerce_positive_int(wrapper.get("sectionId")) is None:
11407
+ wrapper.pop("sectionId", None)
9554
11408
  wrapper["innerQuestions"] = inner_rows
9555
11409
  wrapper["queWidth"] = 100
9556
11410
  form_rows.append([wrapper])
@@ -9602,7 +11456,10 @@ def _select_section_template(section_templates: list[dict[str, Any]], section: d
9602
11456
  for template in section_templates:
9603
11457
  if not isinstance(template, dict):
9604
11458
  continue
9605
- if section_id and str(template.get("sectionId") or "") == section_id:
11459
+ template_section_id = _coerce_positive_int(template.get("queId"))
11460
+ if template_section_id is None:
11461
+ template_section_id = _coerce_positive_int(template.get("sectionId"))
11462
+ if section_id and template_section_id is not None and str(template_section_id) == section_id:
9606
11463
  return template
9607
11464
  if title and str(template.get("queTitle") or "") == title:
9608
11465
  return template