@josephyan/qingflow-cli 0.2.0-beta.66 → 0.2.0-beta.68

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,
@@ -192,7 +206,11 @@ class AiBuilderFacade:
192
206
  )
193
207
  items = listing.get("items") if isinstance(listing.get("items"), list) else []
194
208
  matches = [
195
- {"tag_id": item.get("tagId"), "tag_name": item.get("tagName")}
209
+ {
210
+ "tag_id": item.get("tagId"),
211
+ "tag_name": item.get("tagName"),
212
+ "tag_icon": str(item.get("tagIcon") or "").strip() or None,
213
+ }
196
214
  for item in items
197
215
  if isinstance(item, dict) and item.get("tagName") == requested and _coerce_positive_int(item.get("tagId")) is not None
198
216
  ]
@@ -226,12 +244,24 @@ class AiBuilderFacade:
226
244
  "verification": {},
227
245
  "tag_id": match["tag_id"],
228
246
  "tag_name": match["tag_name"],
247
+ "tag_icon": match.get("tag_icon"),
229
248
  "match_mode": "exact",
230
249
  }
231
250
 
232
- def package_create(self, *, profile: str, package_name: str) -> JSONObject:
251
+ def package_create(
252
+ self,
253
+ *,
254
+ profile: str,
255
+ package_name: str,
256
+ icon: str | None = None,
257
+ color: str | None = None,
258
+ ) -> JSONObject:
233
259
  requested = str(package_name or "").strip()
234
- normalized_args = {"package_name": requested}
260
+ normalized_args = {
261
+ "package_name": requested,
262
+ **({"icon": icon} if icon else {}),
263
+ **({"color": color} if color else {}),
264
+ }
235
265
  if not requested:
236
266
  return _failed(
237
267
  "PACKAGE_NAME_REQUIRED",
@@ -239,6 +269,12 @@ class AiBuilderFacade:
239
269
  normalized_args=normalized_args,
240
270
  suggested_next_call=None,
241
271
  )
272
+ desired_tag_icon = encode_workspace_icon_with_defaults(
273
+ icon=icon,
274
+ color=color,
275
+ title=requested,
276
+ fallback_icon_name="files-folder",
277
+ )
242
278
  existing = self.package_resolve(profile=profile, package_name=requested)
243
279
  lookup_permission_blocked = None
244
280
  if existing.get("status") == "success":
@@ -257,6 +293,7 @@ class AiBuilderFacade:
257
293
  "verification": {"existing_package_reused": True},
258
294
  "tag_id": existing.get("tag_id"),
259
295
  "tag_name": existing.get("tag_name"),
296
+ "tag_icon": existing.get("tag_icon"),
260
297
  }
261
298
  if existing.get("error_code") == "AMBIGUOUS_PACKAGE":
262
299
  return existing
@@ -271,7 +308,10 @@ class AiBuilderFacade:
271
308
  elif existing.get("error_code") not in {"PACKAGE_NOT_FOUND"}:
272
309
  return existing
273
310
  try:
274
- created = self.packages.package_create(profile=profile, payload={"tagName": requested})
311
+ created = self.packages.package_create(
312
+ profile=profile,
313
+ payload={"tagName": requested, "tagIcon": desired_tag_icon},
314
+ )
275
315
  except (QingflowApiError, RuntimeError) as error:
276
316
  api_error = _coerce_api_error(error)
277
317
  return _failed_from_api_error(
@@ -282,16 +322,26 @@ class AiBuilderFacade:
282
322
  "package_name": requested,
283
323
  **({"lookup_permission_blocked": lookup_permission_blocked} if lookup_permission_blocked is not None else {}),
284
324
  },
285
- suggested_next_call={"tool_name": "package_create", "arguments": {"profile": profile, "package_name": requested}},
325
+ suggested_next_call={
326
+ "tool_name": "package_create",
327
+ "arguments": {
328
+ "profile": profile,
329
+ "package_name": requested,
330
+ **({"icon": icon} if icon else {}),
331
+ **({"color": color} if color else {}),
332
+ },
333
+ },
286
334
  )
287
335
  result = created.get("result") if isinstance(created.get("result"), dict) else {}
288
336
  tag_id = _coerce_positive_int(result.get("tagId"))
289
337
  tag_name = str(result.get("tagName") or requested).strip() or requested
338
+ tag_icon = str(result.get("tagIcon") or desired_tag_icon or "").strip() or None
290
339
  if tag_id is None:
291
340
  resolved = self.package_resolve(profile=profile, package_name=requested)
292
341
  if resolved.get("status") == "success":
293
342
  tag_id = _coerce_positive_int(resolved.get("tag_id"))
294
343
  tag_name = str(resolved.get("tag_name") or tag_name)
344
+ tag_icon = str(resolved.get("tag_icon") or tag_icon or "").strip() or None
295
345
  verified = tag_id is not None
296
346
  return {
297
347
  "status": "success" if verified else "partial_success",
@@ -310,6 +360,7 @@ class AiBuilderFacade:
310
360
  "verification": {"tag_id_verified": verified},
311
361
  "tag_id": tag_id,
312
362
  "tag_name": tag_name,
363
+ "tag_icon": tag_icon,
313
364
  }
314
365
 
315
366
  def package_list(self, *, profile: str, trial_status: str = "all") -> JSONObject:
@@ -2051,7 +2102,7 @@ class AiBuilderFacade:
2051
2102
  layout = parsed["layout"]
2052
2103
  response = AppLayoutReadResponse(
2053
2104
  app_key=app_key,
2054
- sections=layout.get("sections", []),
2105
+ sections=_decorate_layout_sections_as_paragraphs(layout.get("sections", [])),
2055
2106
  unplaced_fields=_find_unplaced_fields(parsed["fields"], layout),
2056
2107
  layout_mode_detected=_detect_layout_mode(layout),
2057
2108
  )
@@ -2353,7 +2404,7 @@ class AiBuilderFacade:
2353
2404
  current_layout = self.app_read_layout_summary(profile=profile, app_key=request.app_key)
2354
2405
  if current_layout.get("status") == "failed":
2355
2406
  return current_layout
2356
- 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]
2357
2408
  if request.preset is not None:
2358
2409
  requested_sections = _build_layout_preset_sections(preset=request.preset, field_names=current_names)
2359
2410
  else:
@@ -2923,8 +2974,15 @@ class AiBuilderFacade:
2923
2974
  removed.append(field["name"])
2924
2975
  selector_map = _build_selector_map(current_fields)
2925
2976
 
2977
+ relation_permission_outcome: PermissionCheckOutcome | None = None
2978
+ relation_degraded_expectations: list[dict[str, Any]] = []
2979
+ relation_target_metadata_verified = True
2926
2980
  try:
2927
- 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)
2928
2986
  except (QingflowApiError, RuntimeError) as error:
2929
2987
  api_error = _coerce_api_error(error)
2930
2988
  return _failed_from_api_error(
@@ -2943,6 +3001,36 @@ class AiBuilderFacade:
2943
3001
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
2944
3002
  )
2945
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
+
2946
3034
  relation_field_count = _count_relation_fields(current_fields)
2947
3035
  relation_limit_verified = relation_field_count <= 1
2948
3036
  relation_warnings = (
@@ -2971,6 +3059,7 @@ class AiBuilderFacade:
2971
3059
  "fields_verified": True,
2972
3060
  "relation_field_limit_verified": relation_limit_verified,
2973
3061
  "app_visuals_verified": True,
3062
+ "relation_target_metadata_verified": relation_target_metadata_verified,
2974
3063
  },
2975
3064
  "app_key": target.app_key,
2976
3065
  "app_icon": str(visual_result.get("app_icon") or "").strip() or None,
@@ -2981,6 +3070,7 @@ class AiBuilderFacade:
2981
3070
  "package_attached": package_attached,
2982
3071
  }
2983
3072
  response["details"]["relation_field_count"] = relation_field_count
3073
+ response = _apply_permission_outcomes(response, relation_permission_outcome)
2984
3074
  return finalize(self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response))
2985
3075
 
2986
3076
  payload = _build_form_payload_from_fields(
@@ -2988,6 +3078,7 @@ class AiBuilderFacade:
2988
3078
  current_schema=schema_result,
2989
3079
  fields=current_fields,
2990
3080
  layout=layout,
3081
+ question_relations=compiled_question_relations,
2991
3082
  )
2992
3083
  payload["editVersionNo"] = self._resolve_form_edit_version(
2993
3084
  profile=profile,
@@ -3029,6 +3120,81 @@ class AiBuilderFacade:
3029
3120
  },
3030
3121
  suggested_next_call={"tool_name": "app_read_fields", "arguments": {"profile": profile, "app_key": target.app_key}},
3031
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
3032
3198
  response = {
3033
3199
  "status": "success",
3034
3200
  "error_code": None,
@@ -3047,6 +3213,7 @@ class AiBuilderFacade:
3047
3213
  "package_attached": None,
3048
3214
  "app_visuals_verified": True,
3049
3215
  "relation_field_limit_verified": relation_limit_verified,
3216
+ "relation_target_metadata_verified": relation_target_metadata_verified,
3050
3217
  },
3051
3218
  "app_key": target.app_key,
3052
3219
  "app_icon": str(visual_result.get("app_icon") or resolved.get("app_icon") or "").strip() or None,
@@ -3063,6 +3230,7 @@ class AiBuilderFacade:
3063
3230
  response["details"]["relation_field_count"] = relation_field_count
3064
3231
  if schema_readback_delayed:
3065
3232
  response["verification"]["schema_readback_delayed"] = True
3233
+ response = _apply_permission_outcomes(response, relation_permission_outcome)
3066
3234
  response = self._append_publish_result(profile=profile, app_key=target.app_key, publish=publish, response=response)
3067
3235
  verification_ok = False
3068
3236
  tag_ids_after: list[int] = []
@@ -3072,6 +3240,19 @@ class AiBuilderFacade:
3072
3240
  verified = self.app_read(profile=profile, app_key=target.app_key, include_raw=False)
3073
3241
  verified_field_names = {field["name"] for field in verified["schema"]["fields"]}
3074
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
3075
3256
  except (QingflowApiError, RuntimeError) as error:
3076
3257
  verification_error = _coerce_api_error(error)
3077
3258
  verification_ok = False
@@ -3133,7 +3314,7 @@ class AiBuilderFacade:
3133
3314
  sections: list[LayoutSectionPatch],
3134
3315
  publish: bool = True,
3135
3316
  ) -> JSONObject:
3136
- 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]
3137
3318
  normalized_args = {
3138
3319
  "app_key": app_key,
3139
3320
  "mode": mode.value,
@@ -6378,6 +6559,21 @@ def _coerce_nonnegative_int(value: Any) -> int | None:
6378
6559
  return None
6379
6560
 
6380
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
+
6381
6577
  def _coerce_int_list(values: Any) -> list[int]:
6382
6578
  if not isinstance(values, list):
6383
6579
  return []
@@ -7237,14 +7433,159 @@ def _normalize_relation_mode(value: Any) -> str:
7237
7433
  return _relation_mode_from_optional_data_num(value)
7238
7434
 
7239
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
+
7240
7580
  def _hydrate_relation_field_configs(
7241
7581
  facade: "AiBuilderFacade",
7242
7582
  *,
7243
7583
  profile: str,
7244
7584
  fields: list[dict[str, Any]],
7245
- ) -> list[dict[str, Any]]:
7585
+ ) -> RelationHydrationResult:
7246
7586
  resolved_fields = deepcopy(fields)
7247
7587
  target_field_cache: dict[str, list[dict[str, Any]]] = {}
7588
+ degraded_entries: list[dict[str, Any]] = []
7248
7589
  for field in resolved_fields:
7249
7590
  if not isinstance(field, dict) or field.get("type") != FieldType.relation.value:
7250
7591
  continue
@@ -7262,9 +7603,39 @@ def _hydrate_relation_field_configs(
7262
7603
  continue
7263
7604
  target_fields = target_field_cache.get(target_app_key)
7264
7605
  if target_fields is None:
7265
- target_schema, _ = facade._read_schema_with_fallback(profile=profile, app_key=target_app_key)
7266
- target_fields = _parse_schema(target_schema)["fields"]
7267
- 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
7268
7639
  if not target_fields:
7269
7640
  raise ValueError(f"target relation app '{target_app_key}' has no readable fields")
7270
7641
  display_field = _resolve_relation_target_field(
@@ -7276,41 +7647,17 @@ def _hydrate_relation_field_configs(
7276
7647
  _resolve_relation_target_field(target_fields=target_fields, selector_payload=item)
7277
7648
  for item in visible_selector_payloads
7278
7649
  ]
7279
- if not visible_fields:
7280
- visible_fields = [display_field]
7281
- elif not any(
7282
- str(item.get("field_id") or "") == str(display_field.get("field_id") or "")
7283
- or _coerce_positive_int(item.get("que_id")) == _coerce_positive_int(display_field.get("que_id"))
7284
- for item in visible_fields
7285
- ):
7286
- visible_fields = [display_field, *visible_fields]
7287
- config["target_field_label"] = display_field.get("name")
7288
- config["target_field_type"] = display_field.get("type")
7289
- config["target_field_que_id"] = display_field.get("que_id")
7290
- config["refer_field_ids"] = [item.get("field_id") for item in visible_fields]
7291
- config["refer_field_que_ids"] = [item.get("que_id") or 0 for item in visible_fields]
7292
- config["refer_field_labels"] = [item.get("name") for item in visible_fields]
7293
- config["refer_field_types"] = [item.get("type") for item in visible_fields]
7294
- config["auth_field_ids"] = [item.get("field_id") for item in visible_fields]
7295
- config["auth_field_que_ids"] = [item.get("que_id") or 0 for item in visible_fields]
7296
- config["field_name_show"] = bool(field.get("field_name_show", True))
7297
- field["target_field_id"] = display_field.get("field_id")
7298
- field["target_field_que_id"] = display_field.get("que_id")
7299
- field["config"] = config
7300
- field["display_field"] = {
7301
- "field_id": display_field.get("field_id"),
7302
- "que_id": display_field.get("que_id"),
7303
- "name": display_field.get("name"),
7304
- }
7305
- field["visible_fields"] = [
7306
- {
7307
- "field_id": item.get("field_id"),
7308
- "que_id": item.get("que_id"),
7309
- "name": item.get("name"),
7310
- }
7311
- for item in visible_fields
7312
- ]
7313
- 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
+ )
7314
7661
 
7315
7662
 
7316
7663
  def _slugify(text: str, *, default: str) -> str:
@@ -7343,9 +7690,12 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
7343
7690
  "options": [],
7344
7691
  "option_details": [],
7345
7692
  "target_app_key": None,
7693
+ "config": {},
7346
7694
  "subfields": [],
7347
7695
  "que_id": que_id,
7348
7696
  "que_type": que_type,
7697
+ "default_type": _coerce_positive_int(question.get("queDefaultType")) or 1,
7698
+ "default_value": question.get("queDefaultValue"),
7349
7699
  }
7350
7700
  if field_type in {FieldType.single_select.value, FieldType.multi_select.value, FieldType.boolean.value}:
7351
7701
  options = question.get("options")
@@ -7392,6 +7742,61 @@ def _parse_field(question: dict[str, Any], *, field_id_hint: str | None = None)
7392
7742
  }
7393
7743
  field["visible_fields"] = visible_fields
7394
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 "")
7395
7800
  if field_type == FieldType.subtable:
7396
7801
  subfields = []
7397
7802
  for sub_question in question.get("subQuestions", []) or []:
@@ -7425,9 +7830,12 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
7425
7830
  if labels:
7426
7831
  section_rows.append(labels)
7427
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"))
7428
7836
  sections.append(
7429
7837
  {
7430
- "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")),
7431
7839
  "title": section_question.get("queTitle") or "未命名分组",
7432
7840
  "rows": section_rows,
7433
7841
  }
@@ -7443,7 +7851,10 @@ def _parse_schema(schema: dict[str, Any]) -> dict[str, Any]:
7443
7851
  if labels:
7444
7852
  root_rows.append(labels)
7445
7853
  fields = list(fields_by_name.values())
7446
- 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
7447
7858
 
7448
7859
 
7449
7860
  def _resolve_layout_sections_to_names(
@@ -7479,8 +7890,11 @@ def _resolve_layout_sections_to_names(
7479
7890
  normalized_row.append(resolved_name)
7480
7891
  if normalized_row:
7481
7892
  normalized_rows.append(normalized_row)
7482
- normalized_section = deepcopy(section)
7483
- 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
+ }
7484
7898
  normalized_sections.append(normalized_section)
7485
7899
  return normalized_sections, missing_selectors
7486
7900
 
@@ -7519,74 +7933,525 @@ def _resolve_layout_field_name(
7519
7933
  return None
7520
7934
 
7521
7935
 
7522
- def _compact_public_field_read(*, field: dict[str, Any], layout: dict[str, Any]) -> dict[str, Any]:
7523
- payload = {
7524
- "field_id": field.get("field_id"),
7525
- "que_id": field.get("que_id"),
7526
- "name": field.get("name"),
7527
- "type": field.get("type"),
7528
- "required": bool(field.get("required")),
7529
- "section_id": _find_field_section_id(layout, str(field.get("name") or "")),
7530
- }
7531
- if field.get("type") == FieldType.relation.value:
7532
- payload["target_app_key"] = field.get("target_app_key")
7533
- payload["relation_mode"] = _normalize_relation_mode(field.get("relation_mode"))
7534
- payload["display_field"] = deepcopy(field.get("display_field"))
7535
- payload["visible_fields"] = deepcopy(field.get("visible_fields") or [])
7536
- 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
+ )
7537
7939
 
7538
7940
 
7539
- def _build_selector_map(fields: list[dict[str, Any]]) -> dict[str, int]:
7540
- mapping: dict[str, int] = {}
7541
- for index, field in enumerate(fields):
7542
- field_id = str(field.get("field_id") or "")
7543
- 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 [])
7544
7973
  que_id = _coerce_positive_int(field.get("que_id"))
7545
- if field_id:
7546
- mapping[f"field_id:{field_id}"] = index
7547
- if field_name:
7548
- mapping[f"name:{field_name}"] = index
7549
- if que_id is not None:
7550
- mapping[f"que_id:{que_id}"] = index
7551
- 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
7552
8014
 
7553
8015
 
7554
- def _resolve_selector(selector_map: dict[str, int], selector: FieldSelector) -> int | None:
7555
- if selector.field_id:
7556
- value = selector_map.get(f"field_id:{selector.field_id}")
7557
- if value is not None:
7558
- return value
7559
- if selector.que_id:
7560
- value = selector_map.get(f"que_id:{selector.que_id}")
7561
- if value is not None:
7562
- return value
7563
- if selector.name:
7564
- value = selector_map.get(f"name:{selector.name}")
7565
- if value is not None:
7566
- return value
7567
- 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
7568
8029
 
7569
8030
 
7570
- def _resolve_remove_selector(fields: list[dict[str, Any]], patch: FieldRemovePatch) -> int | None:
7571
- selector = FieldSelector(field_id=patch.field_id, que_id=patch.que_id, name=patch.name)
7572
- 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
+ }
7573
8043
 
7574
8044
 
7575
- def _field_patch_to_internal(patch: FieldPatch) -> dict[str, Any]:
7576
- 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 []
7577
8060
  return {
7578
- "field_id": field_id,
7579
- "name": patch.name,
7580
- "type": patch.type.value,
7581
- "required": patch.required,
7582
- "description": patch.description,
7583
- "options": list(patch.options),
7584
- "target_app_key": patch.target_app_key,
7585
- "display_field": patch.display_field.model_dump(mode="json", exclude_none=True) if patch.display_field is not None else None,
7586
- "visible_fields": [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields],
7587
- "relation_mode": patch.relation_mode.value if patch.relation_mode is not None else 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"))),
8077
+ }
8078
+
8079
+
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
+ }
8087
+
8088
+
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,
7588
8451
  "subfields": [_field_patch_to_internal(subfield) for subfield in patch.subfields],
7589
8452
  "que_id": None,
8453
+ "default_type": 1,
8454
+ "default_value": None,
7590
8455
  }
7591
8456
 
7592
8457
 
@@ -7601,6 +8466,13 @@ def _field_matches_patch(field: dict[str, Any], patch: FieldPatch) -> bool:
7601
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)
7602
8467
  and _field_selector_list_equal(field.get("visible_fields"), [selector.model_dump(mode="json", exclude_none=True) for selector in patch.visible_fields])
7603
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)
7604
8476
  and len(field.get("subfields") or []) == len(patch.subfields)
7605
8477
  )
7606
8478
 
@@ -7625,6 +8497,146 @@ def _field_selector_list_equal(left: Any, right: Any) -> bool:
7625
8497
  return all(_field_selector_payload_equal(item_left, item_right) for item_left, item_right in zip(left_items, right_items))
7626
8498
 
7627
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
+
7628
8640
  def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
7629
8641
  payload = mutation.model_dump(mode="json", exclude_none=True)
7630
8642
  if "name" in payload:
@@ -7645,10 +8657,785 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
7645
8657
  field["visible_fields"] = list(payload["visible_fields"])
7646
8658
  if "relation_mode" in payload:
7647
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"]
7648
8679
  if "subfields" in payload:
7649
8680
  field["subfields"] = [_field_patch_to_internal(item) for item in payload["subfields"]]
7650
8681
 
7651
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
+
7652
9439
  def _append_field_to_layout(layout: dict[str, Any], field_name: str) -> dict[str, Any]:
7653
9440
  next_layout = deepcopy(layout)
7654
9441
  if next_layout.get("sections"):
@@ -7809,6 +9596,20 @@ def _detect_layout_mode(layout: dict[str, Any]) -> str:
7809
9596
  return "empty"
7810
9597
 
7811
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
+
7812
9613
  def _build_verification_hints(
7813
9614
  *,
7814
9615
  tag_ids: list[int],
@@ -8123,6 +9924,13 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
8123
9924
  )
8124
9925
  if field.get("que_id"):
8125
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")
8126
9934
  if field.get("type") == FieldType.relation.value:
8127
9935
  reference = question.get("referenceConfig") if isinstance(question.get("referenceConfig"), dict) else {}
8128
9936
  reference["referAppKey"] = field.get("target_app_key")
@@ -8131,15 +9939,95 @@ def _field_to_question(field: dict[str, Any], *, temp_id: int) -> dict[str, Any]
8131
9939
  reference["referQueId"] = field.get("target_field_que_id")
8132
9940
  reference["optionalDataNum"] = _relation_mode_to_optional_data_num(field.get("relation_mode"))
8133
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 "")
8134
9999
  return question
8135
10000
 
8136
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
+
8137
10024
  def _build_form_payload_from_fields(
8138
10025
  *,
8139
10026
  title: str,
8140
10027
  current_schema: dict[str, Any],
8141
10028
  fields: list[dict[str, Any]],
8142
10029
  layout: dict[str, Any],
10030
+ question_relations: list[dict[str, Any]] | None = None,
8143
10031
  ) -> dict[str, Any]:
8144
10032
  questions_by_name: dict[str, dict[str, Any]] = {}
8145
10033
  temp_id = -10000
@@ -8160,35 +10048,34 @@ def _build_form_payload_from_fields(
8160
10048
  inner_rows.append(questions)
8161
10049
  if not inner_rows:
8162
10050
  continue
8163
- form_rows.append(
8164
- [
8165
- {
8166
- "queId": 0,
8167
- "queTempId": -(20000 + sum(ord(ch) for ch in str(section.get("section_id") or section.get("title") or "section"))),
8168
- "queType": 24,
8169
- "queTitle": section.get("title") or "未命名分组",
8170
- "queWidth": 100,
8171
- "scanType": 1,
8172
- "status": 1,
8173
- "required": False,
8174
- "queHint": "",
8175
- "linkedQuestions": {},
8176
- "logicalShow": True,
8177
- "queDefaultValue": None,
8178
- "queDefaultType": 1,
8179
- "subQueWidth": 2,
8180
- "innerQuestions": inner_rows,
8181
- "beingHide": False,
8182
- "beingDesensitized": False,
8183
- "sectionId": section.get("section_id"),
8184
- }
8185
- ]
8186
- )
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])
8187
10074
  for row in form_rows:
8188
10075
  _apply_row_widths(row)
8189
10076
  payload = default_form_payload(title, form_rows)
8190
10077
  payload["editVersionNo"] = int(current_schema.get("editVersionNo") or 1)
8191
- 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 []))
8192
10079
  return payload
8193
10080
 
8194
10081
 
@@ -9513,7 +11400,11 @@ def _build_form_payload_from_existing_schema(
9513
11400
  "beingDesensitized": False,
9514
11401
  }
9515
11402
  wrapper["queTitle"] = section.get("title") or wrapper.get("queTitle") or "未命名分组"
9516
- 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)
9517
11408
  wrapper["innerQuestions"] = inner_rows
9518
11409
  wrapper["queWidth"] = 100
9519
11410
  form_rows.append([wrapper])
@@ -9565,7 +11456,10 @@ def _select_section_template(section_templates: list[dict[str, Any]], section: d
9565
11456
  for template in section_templates:
9566
11457
  if not isinstance(template, dict):
9567
11458
  continue
9568
- 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:
9569
11463
  return template
9570
11464
  if title and str(template.get("queTitle") or "") == title:
9571
11465
  return template