@qingflow-tech/qingflow-cli 1.1.43-beta.2 → 1.1.43-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -5
- package/entry_point.py +1 -1
- package/npm/bin/qingflow.mjs +1 -2
- package/npm/lib/runtime.mjs +3 -7
- package/package.json +1 -2
- package/pyproject.toml +1 -3
- package/skills/qingflow-app-form/SKILL.md +23 -11
- package/skills/qingflow-cli/SKILL.md +4 -2
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
- package/skills/qingflow-cli/reference/QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md +1 -1
- package/skills/qingflow-cli/reference/builder/10-build-single-app.md +3 -3
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +1 -1
- package/skills/qingflow-cli/reference/builder/30-schema-fields.md +25 -24
- package/skills/qingflow-cli/reference/builder/40-layout.md +5 -5
- package/skills/qingflow-cli/reference/builder/README.md +3 -3
- package/skills/qingflow-cli/reference/builder/code-integrations/README.md +1 -1
- package/skills/qingflow-cli/reference/builder/reference/app-delivery-sop.md +7 -7
- package/skills/qingflow-cli/reference/core/QINGFLOW_CLI_FIELD_DATA_TYPES.md +1 -1
- package/skills/qingflow-cli/reference/examples/README.md +1 -1
- package/skills/qingflow-common/SKILL.md +47 -0
- package/skills/qingflow-common/agents/openai.yaml +4 -0
- package/src/qingflow_mcp/__main__.py +2 -3
- package/src/qingflow_mcp/app_form_compat.py +11 -27
- package/src/qingflow_mcp/app_form_validator.py +201 -35
- package/src/qingflow_mcp/builder_facade/app_form.py +762 -148
- package/src/qingflow_mcp/builder_facade/app_form_schema.py +114 -3
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +310 -245
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +85 -32
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +82 -30
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +113 -37
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +87 -64
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +83 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +83 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +85 -32
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +95 -40
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +100 -28
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +212 -117
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +82 -27
- package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +143 -161
- package/src/qingflow_mcp/builder_facade/service.py +3 -2
- package/src/qingflow_mcp/response_trim.py +2 -0
- package/src/qingflow_mcp/server.py +24 -3
- package/src/qingflow_mcp/server_app_builder.py +2 -2
- package/src/qingflow_mcp/tools/record_tools.py +3 -3
- package/src/qingflow_mcp/version.py +2 -4
- package/docs/local-agent-install.md +0 -370
- package/skills/qingflow-mcp-setup/SKILL.md +0 -115
- package/skills/qingflow-mcp-setup/agents/openai.yaml +0 -4
- package/skills/qingflow-mcp-setup/references/claude-desktop.md +0 -34
- package/skills/qingflow-mcp-setup/references/environments.md +0 -62
- package/skills/qingflow-mcp-setup/references/generic-stdio.md +0 -32
- package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +0 -38
|
@@ -8,18 +8,20 @@ import re
|
|
|
8
8
|
from typing import Any, Iterable
|
|
9
9
|
from urllib.parse import unquote_plus
|
|
10
10
|
|
|
11
|
-
from ..app_form_compat import normalize_app_form_declaration
|
|
11
|
+
from ..app_form_compat import normalize_app_form_declaration
|
|
12
12
|
from ..errors import QingflowApiError, is_auth_like_error
|
|
13
13
|
from ..json_types import JSONObject
|
|
14
14
|
from ..solution.compiler.form_compiler import build_options, build_question, default_member_auth
|
|
15
|
-
from ..solution.compiler.icon_utils import
|
|
15
|
+
from ..solution.compiler.icon_utils import encode_workspace_icon_with_defaults
|
|
16
16
|
from ..solution.spec_models import FieldType
|
|
17
17
|
from ..tools.app_tools import AppTools
|
|
18
18
|
from ..tools.package_tools import PackageTools
|
|
19
19
|
from .app_form_schema import AppFormContractError, AppFormSchemaContext, AppFormSchemaRegistry
|
|
20
20
|
from .service import (
|
|
21
|
+
_coerce_any_int,
|
|
21
22
|
_compile_code_block_binding_fields,
|
|
22
23
|
_compile_q_linker_binding_fields,
|
|
24
|
+
_encrypt_question_id,
|
|
23
25
|
_field_to_question,
|
|
24
26
|
_looks_like_backend_encoded_formula,
|
|
25
27
|
_normalize_formula_defaults_for_save,
|
|
@@ -288,11 +290,39 @@ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
|
|
|
288
290
|
"displayedFieldType": displayed.get("queType"),
|
|
289
291
|
"matchRuleType": relation.get("matchRuleType"),
|
|
290
292
|
"matchRules": relation.get("matchRules"),
|
|
291
|
-
"matchRuleFormula": relation.get("matchRuleFormula"),
|
|
292
293
|
}
|
|
294
|
+
formula = str(relation.get("matchRuleFormula") or "").strip()
|
|
295
|
+
if formula:
|
|
296
|
+
result["matchRuleFormula"] = formula
|
|
293
297
|
return _without_none(result)
|
|
294
298
|
|
|
295
299
|
|
|
300
|
+
def _canonical_attachment_default_values(value: Any) -> JSONObject | None:
|
|
301
|
+
if not isinstance(value, dict):
|
|
302
|
+
return None
|
|
303
|
+
values = []
|
|
304
|
+
for item in value.get("values") or []:
|
|
305
|
+
if not isinstance(item, dict):
|
|
306
|
+
continue
|
|
307
|
+
url = str(item.get("value") or "").strip()
|
|
308
|
+
if not url:
|
|
309
|
+
data_value = str(item.get("dataValue") or "").strip()
|
|
310
|
+
url = data_value if "://" in data_value else ""
|
|
311
|
+
if not url:
|
|
312
|
+
continue
|
|
313
|
+
file_name = str(item.get("otherInfo") or item.get("valueStr") or "").strip()
|
|
314
|
+
values.append(
|
|
315
|
+
_without_none(
|
|
316
|
+
{
|
|
317
|
+
"value": url,
|
|
318
|
+
"otherInfo": file_name or None,
|
|
319
|
+
"pluginValue": item.get("pluginValue"),
|
|
320
|
+
}
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
return {"values": values} if values else None
|
|
324
|
+
|
|
325
|
+
|
|
296
326
|
def _selector(que_id: Any, name: Any) -> JSONObject:
|
|
297
327
|
result: JSONObject = {}
|
|
298
328
|
normalized_id = _positive_int(que_id)
|
|
@@ -361,6 +391,135 @@ def _canonical_equal(left: JSONObject, right: JSONObject) -> bool:
|
|
|
361
391
|
return _semantic(left) == _semantic(right)
|
|
362
392
|
|
|
363
393
|
|
|
394
|
+
def _iter_backend_questions(value: Any) -> Iterable[JSONObject]:
|
|
395
|
+
if isinstance(value, list):
|
|
396
|
+
for item in value:
|
|
397
|
+
yield from _iter_backend_questions(item)
|
|
398
|
+
elif isinstance(value, dict):
|
|
399
|
+
if "queType" in value:
|
|
400
|
+
yield value
|
|
401
|
+
yield from _iter_backend_questions(value.get("innerQuestions"))
|
|
402
|
+
yield from _iter_backend_questions(value.get("subQuestions"))
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _normalize_published_scope_omissions(form: JSONObject) -> JSONObject:
|
|
406
|
+
"""Allow published GET responses that omit custom member/department scopes."""
|
|
407
|
+
result = deepcopy(form)
|
|
408
|
+
for question in _iter_backend_questions(result.get("formQues")):
|
|
409
|
+
if (
|
|
410
|
+
_positive_int(question.get("memberSelectScopeType")) == 2
|
|
411
|
+
and not isinstance(question.get("memberSelectScope"), dict)
|
|
412
|
+
):
|
|
413
|
+
question["memberSelectScopeType"] = 1
|
|
414
|
+
question["memberSelectScope"] = {}
|
|
415
|
+
if (
|
|
416
|
+
_positive_int(question.get("deptSelectScopeType")) == 2
|
|
417
|
+
and not isinstance(question.get("deptSelectScope"), dict)
|
|
418
|
+
):
|
|
419
|
+
question["deptSelectScopeType"] = 1
|
|
420
|
+
question["deptSelectScope"] = {}
|
|
421
|
+
return result
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _without_selectable_scopes(resource: JSONObject) -> JSONObject:
|
|
425
|
+
"""Remove only selectable scope values from a published-state comparison."""
|
|
426
|
+
result = deepcopy(resource)
|
|
427
|
+
fields = _iter_fields(result.get("spec", {}).get("body") or [])
|
|
428
|
+
for field in _iter_field_tree(fields):
|
|
429
|
+
config = field.get("config") if isinstance(field.get("config"), dict) else None
|
|
430
|
+
if config is None:
|
|
431
|
+
continue
|
|
432
|
+
config.pop("memberScope", None)
|
|
433
|
+
config.pop("departmentScope", None)
|
|
434
|
+
return result
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _published_field_map(resource: JSONObject) -> dict[tuple[str, Any], JSONObject]:
|
|
438
|
+
fields = _iter_field_tree(_iter_fields(resource.get("spec", {}).get("body") or []))
|
|
439
|
+
result: dict[tuple[str, Any], JSONObject] = {}
|
|
440
|
+
for field in fields:
|
|
441
|
+
que_id = _positive_int(field.get("queId"))
|
|
442
|
+
key = ("id", que_id) if que_id is not None else ("name", (_field_type(field), field.get("name")))
|
|
443
|
+
result[key] = field
|
|
444
|
+
return result
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _matching_published_field(field: JSONObject, published_fields: dict[tuple[str, Any], JSONObject]) -> JSONObject | None:
|
|
448
|
+
que_id = _positive_int(field.get("queId"))
|
|
449
|
+
if que_id is not None and ("id", que_id) in published_fields:
|
|
450
|
+
return published_fields[("id", que_id)]
|
|
451
|
+
return published_fields.get(("name", (_field_type(field), field.get("name"))))
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _normalize_published_get_equivalents(draft: JSONObject, published: JSONObject) -> tuple[JSONObject, JSONObject]:
|
|
455
|
+
"""Normalize values that the published GET endpoint omits or materializes."""
|
|
456
|
+
draft_result = _without_selectable_scopes(draft)
|
|
457
|
+
published_result = _without_selectable_scopes(published)
|
|
458
|
+
published_fields = _published_field_map(published_result)
|
|
459
|
+
for draft_field in _published_field_map(draft_result).values():
|
|
460
|
+
published_field = _matching_published_field(draft_field, published_fields)
|
|
461
|
+
if published_field is None:
|
|
462
|
+
continue
|
|
463
|
+
field_type = _field_type(draft_field)
|
|
464
|
+
draft_config = draft_field.get("config") if isinstance(draft_field.get("config"), dict) else {}
|
|
465
|
+
published_config = published_field.get("config") if isinstance(published_field.get("config"), dict) else {}
|
|
466
|
+
|
|
467
|
+
if field_type == "member":
|
|
468
|
+
draft_default = draft_config.get("defaultMembers")
|
|
469
|
+
published_default = published_config.get("defaultMembers")
|
|
470
|
+
if isinstance(draft_default, dict) and isinstance(published_default, dict):
|
|
471
|
+
# Published GET resolves department defaults into runtime members.
|
|
472
|
+
for key in ("departments", "includeSubDeparts"):
|
|
473
|
+
if key in draft_default:
|
|
474
|
+
published_default[key] = deepcopy(draft_default[key])
|
|
475
|
+
|
|
476
|
+
if field_type == "data_relation":
|
|
477
|
+
draft_filters = draft_config.get("filters") if isinstance(draft_config.get("filters"), dict) else {}
|
|
478
|
+
published_filters = published_config.get("filters") if isinstance(published_config.get("filters"), dict) else {}
|
|
479
|
+
draft_groups = draft_filters.get("groups") if isinstance(draft_filters.get("groups"), list) else []
|
|
480
|
+
published_groups = published_filters.get("groups") if isinstance(published_filters.get("groups"), list) else []
|
|
481
|
+
for draft_group, published_group in zip(draft_groups, published_groups):
|
|
482
|
+
if not isinstance(draft_group, list) or not isinstance(published_group, list):
|
|
483
|
+
continue
|
|
484
|
+
for draft_rule, published_rule in zip(draft_group, published_group):
|
|
485
|
+
if not isinstance(draft_rule, dict) or not isinstance(published_rule, dict):
|
|
486
|
+
continue
|
|
487
|
+
draft_source = draft_rule.get("sourceField") if isinstance(draft_rule.get("sourceField"), dict) else {}
|
|
488
|
+
published_source = published_rule.get("sourceField") if isinstance(published_rule.get("sourceField"), dict) else {}
|
|
489
|
+
same_source = _positive_int(draft_source.get("queId")) == _positive_int(published_source.get("queId"))
|
|
490
|
+
if same_source and draft_source.get("name") and not published_source.get("name"):
|
|
491
|
+
published_source["name"] = draft_source["name"]
|
|
492
|
+
draft_operand = draft_rule.get("operand") if isinstance(draft_rule.get("operand"), dict) else {}
|
|
493
|
+
published_operand = published_rule.get("operand") if isinstance(published_rule.get("operand"), dict) else {}
|
|
494
|
+
if (
|
|
495
|
+
same_source
|
|
496
|
+
and draft_rule.get("operator") == published_rule.get("operator")
|
|
497
|
+
and draft_operand.get("kind") == published_operand.get("kind") == "literal"
|
|
498
|
+
and draft_operand.get("values")
|
|
499
|
+
and published_operand.get("values") == []
|
|
500
|
+
):
|
|
501
|
+
published_operand["values"] = deepcopy(draft_operand["values"])
|
|
502
|
+
# The backend intentionally emits sortConfig only for beingDraft=true.
|
|
503
|
+
if "sort" in draft_config and "sort" not in published_config:
|
|
504
|
+
published_config["sort"] = deepcopy(draft_config["sort"])
|
|
505
|
+
|
|
506
|
+
if field_type == "subtable":
|
|
507
|
+
if draft_config.get("tableRowsLimit") in {None, 200} and published_config.get("tableRowsLimit") in {None, 200}:
|
|
508
|
+
draft_config.pop("tableRowsLimit", None)
|
|
509
|
+
published_config.pop("tableRowsLimit", None)
|
|
510
|
+
|
|
511
|
+
if field_type in {"number", "amount"}:
|
|
512
|
+
if draft_config.get("currencyStatus") in {None, False} and published_config.get("currencyStatus") in {None, False}:
|
|
513
|
+
draft_config.pop("currencyStatus", None)
|
|
514
|
+
published_config.pop("currencyStatus", None)
|
|
515
|
+
return draft_result, published_result
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _published_equal(draft: JSONObject, published: JSONObject) -> bool:
|
|
519
|
+
comparable_draft, comparable_published = _normalize_published_get_equivalents(draft, published)
|
|
520
|
+
return _canonical_equal(comparable_draft, comparable_published)
|
|
521
|
+
|
|
522
|
+
|
|
364
523
|
def _layout_semantic(body: list[Any]) -> list[JSONObject]:
|
|
365
524
|
def row_layout(row: Any) -> JSONObject:
|
|
366
525
|
fields = row.get("fields") if isinstance(row, dict) else []
|
|
@@ -368,7 +527,7 @@ def _layout_semantic(body: list[Any]) -> list[JSONObject]:
|
|
|
368
527
|
"fields": [
|
|
369
528
|
{
|
|
370
529
|
"name": field.get("name"),
|
|
371
|
-
"
|
|
530
|
+
"questionType": _field_type(field),
|
|
372
531
|
"width": field.get("width"),
|
|
373
532
|
}
|
|
374
533
|
for field in fields or []
|
|
@@ -407,6 +566,16 @@ def _field_semantic(value: JSONObject) -> Any:
|
|
|
407
566
|
return _semantic({key: item for key, item in value.items() if key != "width"})
|
|
408
567
|
|
|
409
568
|
|
|
569
|
+
def _field_type(field: JSONObject) -> str:
|
|
570
|
+
"""Read the AppForm field type while accepting pre-rename declarations."""
|
|
571
|
+
return str(field.get("questionType") or field.get("type") or "")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _field_required(field: JSONObject) -> bool:
|
|
575
|
+
"""Read the AppForm required flag while accepting pre-rename declarations."""
|
|
576
|
+
return bool(field.get("beingRequired") if "beingRequired" in field else field.get("required"))
|
|
577
|
+
|
|
578
|
+
|
|
410
579
|
def _validate_payload_limits(value: Any) -> None:
|
|
411
580
|
stack = [(value, 1)]
|
|
412
581
|
while stack:
|
|
@@ -437,6 +606,9 @@ class AppFormNormalizer:
|
|
|
437
606
|
relations = [item for item in form.get("questionRelations") or [] if isinstance(item, dict)]
|
|
438
607
|
select_scope_relations = [item for item in form.get("selectScopeRelations") or [] if isinstance(item, dict)]
|
|
439
608
|
field_names = self._field_index(form)
|
|
609
|
+
definitions = context.complete_schema.get("$defs") if isinstance(context.complete_schema.get("$defs"), dict) else {}
|
|
610
|
+
relation_properties = definitions.get("RelationConfig", {}).get("properties", {}) if isinstance(definitions.get("RelationConfig"), dict) else {}
|
|
611
|
+
supports_reference_default_formula = "defaultValueFormula" in relation_properties
|
|
440
612
|
title_id = _positive_int(form.get("dataTitleQueId"))
|
|
441
613
|
cover_id = _positive_int(form.get("dataCoverQueId"))
|
|
442
614
|
body: list[JSONObject] = []
|
|
@@ -449,7 +621,7 @@ class AppFormNormalizer:
|
|
|
449
621
|
if section_id is None:
|
|
450
622
|
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
|
|
451
623
|
rows = [
|
|
452
|
-
{"fields": [self._field(question, relations, select_scope_relations,
|
|
624
|
+
{"fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in inner if isinstance(question, dict)]}
|
|
453
625
|
for inner in section.get("innerQuestions") or []
|
|
454
626
|
if isinstance(inner, list) and inner
|
|
455
627
|
]
|
|
@@ -457,15 +629,15 @@ class AppFormNormalizer:
|
|
|
457
629
|
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
|
|
458
630
|
body.append({"kind": "section", "sectionId": str(section_id), "title": str(section.get("queTitle") or ""), "rows": rows})
|
|
459
631
|
else:
|
|
460
|
-
body.append({"kind": "row", "fields": [self._field(question, relations, select_scope_relations,
|
|
632
|
+
body.append({"kind": "row", "fields": [self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula) for question in row if isinstance(question, dict)]})
|
|
461
633
|
resource = {
|
|
462
634
|
"apiVersion": context.api_version,
|
|
463
635
|
"appKey": app_key,
|
|
464
636
|
"spec": {
|
|
465
637
|
"appName": str(form.get("formTitle") or ""),
|
|
466
|
-
"
|
|
467
|
-
"
|
|
468
|
-
"
|
|
638
|
+
"appDescription": form.get("formDesc") or None,
|
|
639
|
+
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
|
|
640
|
+
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
|
|
469
641
|
"attachments": self._attachments(form.get("formAttach")),
|
|
470
642
|
"serialNumber": self._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
|
|
471
643
|
"body": body,
|
|
@@ -478,9 +650,8 @@ class AppFormNormalizer:
|
|
|
478
650
|
question: JSONObject,
|
|
479
651
|
relations: list[JSONObject],
|
|
480
652
|
select_scope_relations: list[JSONObject],
|
|
481
|
-
title_id: int | None,
|
|
482
|
-
cover_id: int | None,
|
|
483
653
|
field_names: dict[int, str],
|
|
654
|
+
supports_reference_default_formula: bool,
|
|
484
655
|
) -> JSONObject:
|
|
485
656
|
que_id = _positive_int(question.get("queId"))
|
|
486
657
|
if que_id is None:
|
|
@@ -506,9 +677,9 @@ class AppFormNormalizer:
|
|
|
506
677
|
)
|
|
507
678
|
field: JSONObject = {
|
|
508
679
|
"name": str(question.get("queTitle") or ""),
|
|
509
|
-
"
|
|
510
|
-
"
|
|
511
|
-
"
|
|
680
|
+
"questionType": field_type,
|
|
681
|
+
"beingRequired": bool(question.get("required")),
|
|
682
|
+
"questionDescription": question.get("queHint") or None,
|
|
512
683
|
}
|
|
513
684
|
default_type = _positive_int(question.get("queDefaultType"))
|
|
514
685
|
if default_type is not None and (
|
|
@@ -526,16 +697,12 @@ class AppFormNormalizer:
|
|
|
526
697
|
)
|
|
527
698
|
if "queDefaultValue" in question and raw_default_value is not None and not isinstance(raw_default_value, (dict, list)):
|
|
528
699
|
default_value = question.get("queDefaultValue")
|
|
529
|
-
field["defaultValue"] = _decode_formula_default(default_value)
|
|
700
|
+
field["defaultValue"] = _decode_formula_default(default_value)
|
|
530
701
|
if que_id is not None:
|
|
531
702
|
field["queId"] = que_id
|
|
532
703
|
width = _positive_int(question.get("queWidth"))
|
|
533
704
|
if width is not None:
|
|
534
705
|
field["width"] = width
|
|
535
|
-
if title_id is not None and que_id == title_id:
|
|
536
|
-
field["dataTitle"] = True
|
|
537
|
-
if cover_id is not None and que_id == cover_id:
|
|
538
|
-
field["dataCover"] = True
|
|
539
706
|
if field_type in {"text", "long_text", "link", "phone", "email"}:
|
|
540
707
|
config = _without_none({
|
|
541
708
|
"minWordCount": _canonical_unlimited(question.get("minWordCount")),
|
|
@@ -643,7 +810,7 @@ class AppFormNormalizer:
|
|
|
643
810
|
"maxFileLimit": question.get("maxFileLimit"),
|
|
644
811
|
"canScan": question.get("canScan"),
|
|
645
812
|
"scanType": question.get("scanType"),
|
|
646
|
-
"defaultValues":
|
|
813
|
+
"defaultValues": _canonical_attachment_default_values(question.get("queDefaultValues")),
|
|
647
814
|
})
|
|
648
815
|
elif field_type == "address":
|
|
649
816
|
field["config"] = _without_none({
|
|
@@ -659,30 +826,31 @@ class AppFormNormalizer:
|
|
|
659
826
|
display_id = _positive_int(reference.get("referQueId"))
|
|
660
827
|
display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
|
|
661
828
|
visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
829
|
+
relation_config: JSONObject = {
|
|
830
|
+
"targetAppKey": reference.get("referAppKey"),
|
|
831
|
+
"relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
|
|
832
|
+
"displayField": _require_selector(
|
|
833
|
+
display_id,
|
|
834
|
+
(display or {}).get("queTitle"),
|
|
835
|
+
message="relation display field cannot be normalized",
|
|
836
|
+
details={"queId": que_id},
|
|
837
|
+
),
|
|
838
|
+
"visibleFields": [
|
|
839
|
+
_require_selector(
|
|
840
|
+
item.get("queId"),
|
|
841
|
+
item.get("queTitle"),
|
|
842
|
+
message="relation visible field cannot be normalized",
|
|
670
843
|
details={"queId": que_id},
|
|
671
|
-
)
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
"beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
|
|
682
|
-
"defaultValueMatchRules": reference.get("defaultValueMatchRules"),
|
|
683
|
-
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
684
|
-
}
|
|
685
|
-
)
|
|
844
|
+
)
|
|
845
|
+
for item in visible
|
|
846
|
+
],
|
|
847
|
+
"beingDefaultFormulaAutoFillEnabled": reference.get("beingDefaultFormulaAutoFillEnabled"),
|
|
848
|
+
"defaultValueMatchRules": reference.get("defaultValueMatchRules"),
|
|
849
|
+
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
850
|
+
}
|
|
851
|
+
if supports_reference_default_formula:
|
|
852
|
+
relation_config["defaultValueFormula"] = reference.get("defaultValueFormula")
|
|
853
|
+
field["config"] = _without_none(relation_config)
|
|
686
854
|
elif field_type == "q_linker":
|
|
687
855
|
field["config"] = {"qLinkerBinding": self._q_linker_binding(question, relations, field_names)}
|
|
688
856
|
elif field_type == "code_block":
|
|
@@ -691,14 +859,12 @@ class AppFormNormalizer:
|
|
|
691
859
|
field["config"] = self._data_relation_config(question, relations)
|
|
692
860
|
elif field_type == "subtable":
|
|
693
861
|
field["subfields"] = [
|
|
694
|
-
self._field(item, relations, select_scope_relations,
|
|
862
|
+
self._field(item, relations, select_scope_relations, field_names, supports_reference_default_formula)
|
|
695
863
|
for item in question.get("subQuestions") or []
|
|
696
864
|
if isinstance(item, dict)
|
|
697
865
|
]
|
|
698
866
|
for subfield in field["subfields"]:
|
|
699
867
|
subfield.pop("width", None)
|
|
700
|
-
subfield.pop("dataTitle", None)
|
|
701
|
-
subfield.pop("dataCover", None)
|
|
702
868
|
field["config"] = _without_none({
|
|
703
869
|
"tableRowsLimit": question.get("tableRowsLimit"),
|
|
704
870
|
"tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
|
|
@@ -708,12 +874,6 @@ class AppFormNormalizer:
|
|
|
708
874
|
})
|
|
709
875
|
return field
|
|
710
876
|
|
|
711
|
-
@staticmethod
|
|
712
|
-
def _style(value: Any) -> JSONObject | None:
|
|
713
|
-
if not isinstance(value, dict) or not any(value.get(key) is not None for key in ("bgImage", "bgColor", "opacity", "headerImage", "headerColor", "fontColor")):
|
|
714
|
-
return None
|
|
715
|
-
return {key: value.get(key) for key in ("bgImage", "bgColor", "opacity", "headerImage", "headerColor", "fontColor") if key in value}
|
|
716
|
-
|
|
717
877
|
@staticmethod
|
|
718
878
|
def _attachments(value: Any) -> list[JSONObject]:
|
|
719
879
|
result = [
|
|
@@ -812,7 +972,14 @@ class AppFormNormalizer:
|
|
|
812
972
|
if not isinstance(item, dict):
|
|
813
973
|
continue
|
|
814
974
|
if _positive_int(item.get("type")) == 2:
|
|
815
|
-
|
|
975
|
+
input_que_id = _positive_int(item.get("value"))
|
|
976
|
+
inputs.append(
|
|
977
|
+
{
|
|
978
|
+
"field": _selector(input_que_id, field_names.get(input_que_id or -1)),
|
|
979
|
+
"key": str(item.get("key") or ""),
|
|
980
|
+
"source": source,
|
|
981
|
+
}
|
|
982
|
+
)
|
|
816
983
|
else:
|
|
817
984
|
static[key].append({"key": str(item.get("key") or ""), "value": item.get("value")})
|
|
818
985
|
request_values = {
|
|
@@ -828,9 +995,19 @@ class AppFormNormalizer:
|
|
|
828
995
|
def replace_reference(match: re.Match[str]) -> str:
|
|
829
996
|
name = match.group("name").strip()
|
|
830
997
|
if name:
|
|
998
|
+
token = match.group("token").strip()
|
|
999
|
+
matching_ids = [
|
|
1000
|
+
que_id
|
|
1001
|
+
for que_id, field_name in field_names.items()
|
|
1002
|
+
if field_name == name and _encrypt_question_id(que_id) == token
|
|
1003
|
+
]
|
|
831
1004
|
json_path_inputs.setdefault(
|
|
832
1005
|
name,
|
|
833
|
-
{
|
|
1006
|
+
{
|
|
1007
|
+
"field": _selector(matching_ids[0] if len(matching_ids) == 1 else None, name),
|
|
1008
|
+
"key": name,
|
|
1009
|
+
"source": "json_path",
|
|
1010
|
+
},
|
|
834
1011
|
)
|
|
835
1012
|
return "{{" + name + "}}"
|
|
836
1013
|
|
|
@@ -973,7 +1150,7 @@ class AppFormNormalizer:
|
|
|
973
1150
|
if not bool(sort.get("status")):
|
|
974
1151
|
result["sort"] = {"enabled": False}
|
|
975
1152
|
elif _positive_int(sort.get("queId")):
|
|
976
|
-
result["sort"] = {"enabled": True, "by": {"kind": "field", "field": _selector(sort.get("queId"),
|
|
1153
|
+
result["sort"] = {"enabled": True, "by": {"kind": "field", "field": _selector(sort.get("queId"), None)}, "direction": "asc" if sort.get("ascend") else "desc"}
|
|
977
1154
|
elif str(sort.get("qlinkerAlias") or "").strip():
|
|
978
1155
|
producer = _require_selector(
|
|
979
1156
|
alias.get("queId"),
|
|
@@ -994,12 +1171,12 @@ class AppFormNormalizer:
|
|
|
994
1171
|
else:
|
|
995
1172
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation sort cannot be normalized", "NORMALIZE", details={"queId": que_id})
|
|
996
1173
|
button = question.get("customButtonConfig") if isinstance(question.get("customButtonConfig"), dict) else None
|
|
997
|
-
if button is not None:
|
|
1174
|
+
if button is not None and bool(button.get("status")):
|
|
998
1175
|
detail = button.get("customButtonDetailInfo") if isinstance(button.get("customButtonDetailInfo"), dict) else {}
|
|
999
1176
|
button_id = _positive_int(detail.get("buttonId")) or _positive_int(button.get("buttonId"))
|
|
1000
|
-
if
|
|
1177
|
+
if button_id is None:
|
|
1001
1178
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "enabled data_relation custom button has no buttonId", "NORMALIZE", details={"queId": que_id})
|
|
1002
|
-
result["customButton"] = {"enabled":
|
|
1179
|
+
result["customButton"] = {"enabled": True, "buttonId": button_id}
|
|
1003
1180
|
return result
|
|
1004
1181
|
|
|
1005
1182
|
@staticmethod
|
|
@@ -1013,7 +1190,16 @@ class AppFormNormalizer:
|
|
|
1013
1190
|
}
|
|
1014
1191
|
match_type = _positive_int(value.get("matchType")) or 1
|
|
1015
1192
|
if match_type == 1:
|
|
1016
|
-
|
|
1193
|
+
details = value.get("judgeValueDetails")
|
|
1194
|
+
if isinstance(details, list):
|
|
1195
|
+
literal_values = [
|
|
1196
|
+
str(item.get("value") if item.get("value") is not None else item.get("id"))
|
|
1197
|
+
for item in details
|
|
1198
|
+
if isinstance(item, dict) and (item.get("value") is not None or item.get("id") is not None)
|
|
1199
|
+
]
|
|
1200
|
+
else:
|
|
1201
|
+
literal_values = deepcopy(value.get("judgeValues") or [])
|
|
1202
|
+
condition["operand"] = {"kind": "literal", "values": literal_values}
|
|
1017
1203
|
elif match_type == 2:
|
|
1018
1204
|
detail = value.get("judgeQueDetail") if isinstance(value.get("judgeQueDetail"), dict) else {}
|
|
1019
1205
|
condition["operand"] = {
|
|
@@ -1103,6 +1289,62 @@ class AppFormCompiler:
|
|
|
1103
1289
|
details={"field": name},
|
|
1104
1290
|
)
|
|
1105
1291
|
field["queId"] = que_id
|
|
1292
|
+
field_type = _field_type(field)
|
|
1293
|
+
alias_binding = {
|
|
1294
|
+
"code_block": ("codeBlockBinding", "codeBlockConfig", "resultAliasPath"),
|
|
1295
|
+
"q_linker": ("qLinkerBinding", "remoteLookupConfig", "resultFormatPath"),
|
|
1296
|
+
}.get(field_type)
|
|
1297
|
+
if alias_binding is not None:
|
|
1298
|
+
binding_key, backend_config_key, backend_aliases_key = alias_binding
|
|
1299
|
+
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
1300
|
+
binding = config.get(binding_key) if isinstance(config.get(binding_key), dict) else {}
|
|
1301
|
+
outputs = binding.get("outputs") if isinstance(binding.get("outputs"), list) else []
|
|
1302
|
+
backend_config = question.get(backend_config_key) if isinstance(question.get(backend_config_key), dict) else {}
|
|
1303
|
+
backend_aliases = [
|
|
1304
|
+
item
|
|
1305
|
+
for item in backend_config.get(backend_aliases_key) or []
|
|
1306
|
+
if isinstance(item, dict)
|
|
1307
|
+
]
|
|
1308
|
+
for output_index, output in enumerate(outputs):
|
|
1309
|
+
if not isinstance(output, dict):
|
|
1310
|
+
continue
|
|
1311
|
+
alias_name = str(output.get("alias") or "").strip()
|
|
1312
|
+
alias_path = str(output.get("path") or "").strip()
|
|
1313
|
+
matches = [
|
|
1314
|
+
item
|
|
1315
|
+
for item in backend_aliases
|
|
1316
|
+
if str(item.get("aliasName") or "").strip() == alias_name
|
|
1317
|
+
and str(item.get("aliasPath") or "").strip() == alias_path
|
|
1318
|
+
]
|
|
1319
|
+
if len(matches) != 1:
|
|
1320
|
+
raise AppFormFailure(
|
|
1321
|
+
"RELATION_REBIND_FAILED",
|
|
1322
|
+
"identity write did not return a unique output alias identity",
|
|
1323
|
+
"IDENTITY_REBOUND",
|
|
1324
|
+
details={
|
|
1325
|
+
"field": name,
|
|
1326
|
+
"fieldType": field_type,
|
|
1327
|
+
"outputIndex": output_index,
|
|
1328
|
+
"alias": alias_name,
|
|
1329
|
+
"path": alias_path,
|
|
1330
|
+
"matches": len(matches),
|
|
1331
|
+
},
|
|
1332
|
+
)
|
|
1333
|
+
alias_id = _positive_int(matches[0].get("aliasId"))
|
|
1334
|
+
if alias_id is None:
|
|
1335
|
+
raise AppFormFailure(
|
|
1336
|
+
"RELATION_REBIND_FAILED",
|
|
1337
|
+
"identity write returned an output alias without aliasId",
|
|
1338
|
+
"IDENTITY_REBOUND",
|
|
1339
|
+
details={
|
|
1340
|
+
"field": name,
|
|
1341
|
+
"fieldType": field_type,
|
|
1342
|
+
"outputIndex": output_index,
|
|
1343
|
+
"alias": alias_name,
|
|
1344
|
+
"path": alias_path,
|
|
1345
|
+
},
|
|
1346
|
+
)
|
|
1347
|
+
output["aliasId"] = alias_id
|
|
1106
1348
|
subquestions = [item for item in question.get("subQuestions") or [] if isinstance(item, dict)]
|
|
1107
1349
|
for subfield in field.get("subfields") or []:
|
|
1108
1350
|
if not isinstance(subfield, dict):
|
|
@@ -1169,17 +1411,18 @@ class AppFormCompiler:
|
|
|
1169
1411
|
) -> JSONObject:
|
|
1170
1412
|
resource = self.bind_backend_identities(form=form, resource=declaration)
|
|
1171
1413
|
spec = resource["spec"]
|
|
1414
|
+
field_names = AppFormNormalizer._field_index(form)
|
|
1415
|
+
title_id = _positive_int(form.get("dataTitleQueId"))
|
|
1416
|
+
cover_id = _positive_int(form.get("dataCoverQueId"))
|
|
1172
1417
|
spec.setdefault("appName", str(form.get("formTitle") or ""))
|
|
1173
|
-
spec.setdefault("
|
|
1174
|
-
spec.setdefault("
|
|
1175
|
-
spec.setdefault("
|
|
1418
|
+
spec.setdefault("appDescription", form.get("formDesc") or None)
|
|
1419
|
+
spec.setdefault("dataTitleField", _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None)
|
|
1420
|
+
spec.setdefault("dataCoverField", _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None)
|
|
1176
1421
|
spec.setdefault("attachments", AppFormNormalizer._attachments(form.get("formAttach")))
|
|
1177
1422
|
spec.setdefault(
|
|
1178
1423
|
"serialNumber",
|
|
1179
1424
|
AppFormNormalizer._serial_number(form.get("serialNumType"), form.get("serialNumConfig")),
|
|
1180
1425
|
)
|
|
1181
|
-
spec.pop("icon", None)
|
|
1182
|
-
spec.pop("color", None)
|
|
1183
1426
|
resource["apiVersion"] = context.api_version
|
|
1184
1427
|
resource["appKey"] = app_key
|
|
1185
1428
|
resource.pop("packageId", None)
|
|
@@ -1189,23 +1432,28 @@ class AppFormCompiler:
|
|
|
1189
1432
|
current_spec = current["spec"]
|
|
1190
1433
|
declared_spec = declaration["spec"]
|
|
1191
1434
|
target_spec = deepcopy(current_spec)
|
|
1192
|
-
for key in ("appName", "
|
|
1435
|
+
for key in ("appName", "appDescription", "dataTitleField", "dataCoverField", "attachments", "serialNumber"):
|
|
1193
1436
|
if key in declared_spec:
|
|
1194
1437
|
target_spec[key] = deepcopy(declared_spec[key])
|
|
1195
|
-
if "style" in declared_spec:
|
|
1196
|
-
if declared_spec["style"] is None:
|
|
1197
|
-
target_spec["style"] = None
|
|
1198
|
-
else:
|
|
1199
|
-
merged = deepcopy(current_spec.get("style") or {})
|
|
1200
|
-
merged.update(deepcopy(declared_spec["style"]))
|
|
1201
|
-
target_spec["style"] = merged or None
|
|
1202
1438
|
target_spec["body"] = self._target_body(current_spec.get("body") or [], declared_spec.get("body") or [])
|
|
1203
|
-
|
|
1439
|
+
target_fields = list(_iter_fields(target_spec["body"]))
|
|
1440
|
+
for key in ("dataTitleField", "dataCoverField"):
|
|
1441
|
+
if key in declared_spec or not isinstance(target_spec.get(key), dict):
|
|
1442
|
+
continue
|
|
1443
|
+
selector_id = _positive_int(target_spec[key].get("queId"))
|
|
1444
|
+
selected = [field for field in target_fields if selector_id is not None and _positive_int(field.get("queId")) == selector_id]
|
|
1445
|
+
if len(selected) == 1:
|
|
1446
|
+
target_spec[key] = _selector(selected[0].get("queId"), selected[0].get("name"))
|
|
1447
|
+
data_title, data_cover = self._validate_target(target_spec)
|
|
1448
|
+
target_spec["dataTitleField"] = _selector(data_title.get("queId"), data_title.get("name")) if data_title is not None else None
|
|
1449
|
+
target_spec["dataCoverField"] = _selector(data_cover.get("queId"), data_cover.get("name")) if data_cover is not None else None
|
|
1204
1450
|
return {"apiVersion": context.api_version, "appKey": app_key, "spec": target_spec}
|
|
1205
1451
|
|
|
1206
1452
|
def preflight(self, *, profile: str, app_key: str, declaration: JSONObject, current_form: JSONObject | None) -> None:
|
|
1207
|
-
|
|
1208
|
-
|
|
1453
|
+
declaration = self._resolve_declared_field_names(declaration, current_form)
|
|
1454
|
+
spec = declaration.get("spec", {})
|
|
1455
|
+
body = spec.get("body") or []
|
|
1456
|
+
self._validate_target(spec, allow_missing_data_title=current_form is not None)
|
|
1209
1457
|
fields = list(_iter_fields(body))
|
|
1210
1458
|
templates = self._question_templates(current_form or {})
|
|
1211
1459
|
internal_fields = [self._internal_field(field, templates=templates) for field in fields]
|
|
@@ -1215,16 +1463,17 @@ class AppFormCompiler:
|
|
|
1215
1463
|
internal_fields, relations = _compile_code_block_binding_fields(fields=internal_fields, current_schema=current_form or {})
|
|
1216
1464
|
relation_context = deepcopy(current_form or {})
|
|
1217
1465
|
relation_context["questionRelations"] = relations
|
|
1218
|
-
internal_fields,
|
|
1466
|
+
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
1467
|
+
self._validate_unique_relation_targets(relations, internal_fields)
|
|
1219
1468
|
except (KeyError, ValueError) as error:
|
|
1220
1469
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "CONTRACT_PRECHECK") from error
|
|
1221
1470
|
self._assign_subfield_temp_ids(internal_fields)
|
|
1222
1471
|
for field in fields:
|
|
1223
1472
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
1224
|
-
if field
|
|
1473
|
+
if _field_type(field) == "relation":
|
|
1225
1474
|
template = templates.get(_positive_int(field.get("queId")) or -1, {}).get("referenceConfig")
|
|
1226
1475
|
self._reference_config(profile, config, template if isinstance(template, dict) else None)
|
|
1227
|
-
elif field
|
|
1476
|
+
elif _field_type(field) == "q_linker":
|
|
1228
1477
|
for index, item in enumerate((config.get("qLinkerBinding") or {}).get("inputs") or []):
|
|
1229
1478
|
if not isinstance(item, dict) or item.get("source") != "json_path":
|
|
1230
1479
|
continue
|
|
@@ -1236,11 +1485,51 @@ class AppFormCompiler:
|
|
|
1236
1485
|
"CONTRACT_PRECHECK",
|
|
1237
1486
|
details={"key": item.get("key"), "field": selected.get("name")},
|
|
1238
1487
|
)
|
|
1239
|
-
elif field
|
|
1488
|
+
elif _field_type(field) == "data_relation" and "customButton" in config:
|
|
1240
1489
|
template = templates.get(_positive_int(field.get("queId")) or -1)
|
|
1241
1490
|
self._data_relation_custom_button(config.get("customButton") or {}, template)
|
|
1242
1491
|
self._data_relation_relations(profile, app_key, fields, internal_fields)
|
|
1243
1492
|
|
|
1493
|
+
@staticmethod
|
|
1494
|
+
def _resolve_declared_field_names(
|
|
1495
|
+
declaration: JSONObject,
|
|
1496
|
+
current_form: JSONObject | None,
|
|
1497
|
+
) -> JSONObject:
|
|
1498
|
+
"""Fill names for ID-only updates before compilation consumes field names."""
|
|
1499
|
+
result = deepcopy(declaration)
|
|
1500
|
+
current_names = AppFormNormalizer._field_index(current_form or {})
|
|
1501
|
+
|
|
1502
|
+
def visit(field: Any, path: str) -> None:
|
|
1503
|
+
if not isinstance(field, dict):
|
|
1504
|
+
return
|
|
1505
|
+
if not str(field.get("name") or "").strip():
|
|
1506
|
+
que_id = _positive_int(field.get("queId"))
|
|
1507
|
+
name = current_names.get(que_id or 0)
|
|
1508
|
+
if name:
|
|
1509
|
+
field["name"] = name
|
|
1510
|
+
else:
|
|
1511
|
+
raise AppFormFailure(
|
|
1512
|
+
"INVALID_SPEC",
|
|
1513
|
+
"field name is required unless queId identifies an existing field",
|
|
1514
|
+
"CONTRACT_PRECHECK",
|
|
1515
|
+
details={"path": path, "queId": field.get("queId")},
|
|
1516
|
+
)
|
|
1517
|
+
for index, child in enumerate(field.get("subfields") or []):
|
|
1518
|
+
visit(child, f"{path}/subfields/{index}")
|
|
1519
|
+
|
|
1520
|
+
spec = result.get("spec") if isinstance(result.get("spec"), dict) else {}
|
|
1521
|
+
body = spec.get("body") or []
|
|
1522
|
+
for block_index, block in enumerate(body):
|
|
1523
|
+
if not isinstance(block, dict):
|
|
1524
|
+
continue
|
|
1525
|
+
rows = [block] if block.get("kind") == "row" else block.get("rows") or []
|
|
1526
|
+
for row_index, row in enumerate(rows):
|
|
1527
|
+
if not isinstance(row, dict):
|
|
1528
|
+
continue
|
|
1529
|
+
for field_index, field in enumerate(row.get("fields") or []):
|
|
1530
|
+
visit(field, f"/spec/body/{block_index}/rows/{row_index}/fields/{field_index}")
|
|
1531
|
+
return result
|
|
1532
|
+
|
|
1244
1533
|
@staticmethod
|
|
1245
1534
|
def _assign_layout_temp_ids(body: list[Any], internal_fields: list[JSONObject]) -> None:
|
|
1246
1535
|
"""Assign one temporary-ID sequence to fields and new section wrappers."""
|
|
@@ -1319,7 +1608,42 @@ class AppFormCompiler:
|
|
|
1319
1608
|
for question in row if isinstance(row, list) else []:
|
|
1320
1609
|
visit(question)
|
|
1321
1610
|
|
|
1322
|
-
|
|
1611
|
+
@staticmethod
|
|
1612
|
+
def _validate_unique_relation_targets(relations: list[JSONObject], fields: list[JSONObject]) -> None:
|
|
1613
|
+
field_names: dict[int, str] = {}
|
|
1614
|
+
for field in fields:
|
|
1615
|
+
field_ref = _positive_int(field.get("que_id"))
|
|
1616
|
+
if field_ref is None:
|
|
1617
|
+
field_ref = _coerce_any_int(field.get("que_temp_id"))
|
|
1618
|
+
if field_ref is not None:
|
|
1619
|
+
field_names[field_ref] = str(field.get("name") or field_ref)
|
|
1620
|
+
by_target: dict[int, JSONObject] = {}
|
|
1621
|
+
for relation in relations:
|
|
1622
|
+
target_ref = _coerce_any_int(relation.get("queId"))
|
|
1623
|
+
if target_ref is None:
|
|
1624
|
+
continue
|
|
1625
|
+
previous = by_target.get(target_ref)
|
|
1626
|
+
if previous is None:
|
|
1627
|
+
by_target[target_ref] = relation
|
|
1628
|
+
continue
|
|
1629
|
+
target_name = field_names.get(target_ref, str(target_ref))
|
|
1630
|
+
producers = []
|
|
1631
|
+
for item in (previous, relation):
|
|
1632
|
+
alias_config = item.get("aliasConfig") if isinstance(item.get("aliasConfig"), dict) else {}
|
|
1633
|
+
producer = str(alias_config.get("queTitle") or "").strip()
|
|
1634
|
+
producers.append(producer or f"relationType={item.get('relationType')}")
|
|
1635
|
+
raise ValueError(
|
|
1636
|
+
f"field '{target_name}' is targeted by multiple default-value relations "
|
|
1637
|
+
f"({producers[0]}, {producers[1]}); each field can receive output from only one relation"
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
def _target_body(
|
|
1641
|
+
self,
|
|
1642
|
+
current_body: list[Any],
|
|
1643
|
+
declared_body: list[Any],
|
|
1644
|
+
*,
|
|
1645
|
+
subfields: bool = False,
|
|
1646
|
+
) -> list[JSONObject]:
|
|
1323
1647
|
current_fields = list(_iter_fields(current_body))
|
|
1324
1648
|
by_id = {_positive_int(field.get("queId")): field for field in current_fields if _positive_int(field.get("queId"))}
|
|
1325
1649
|
sections_by_id = {
|
|
@@ -1337,8 +1661,8 @@ class AppFormCompiler:
|
|
|
1337
1661
|
target = _deep_overlay(current or {}, declared)
|
|
1338
1662
|
if "config" in declared:
|
|
1339
1663
|
target["config"] = deepcopy(declared["config"])
|
|
1340
|
-
target.setdefault("
|
|
1341
|
-
target.setdefault("
|
|
1664
|
+
target.setdefault("beingRequired", False)
|
|
1665
|
+
target.setdefault("questionDescription", None)
|
|
1342
1666
|
if target.get("defaultValue") is not None:
|
|
1343
1667
|
target.setdefault("defaultType", 1)
|
|
1344
1668
|
if "defaultValue" in declared and declared.get("defaultValue") is None:
|
|
@@ -1349,8 +1673,8 @@ class AppFormCompiler:
|
|
|
1349
1673
|
target["queId"] = current.get("queId")
|
|
1350
1674
|
else:
|
|
1351
1675
|
target.pop("queId", None)
|
|
1352
|
-
if target
|
|
1353
|
-
target["config"] = {"options": ["是", "否"] if target
|
|
1676
|
+
if _field_type(target) in CHOICE_TYPES and "config" not in declared and current is None:
|
|
1677
|
+
target["config"] = {"options": ["是", "否"] if _field_type(target) == "single_choice" else []}
|
|
1354
1678
|
return target
|
|
1355
1679
|
|
|
1356
1680
|
def target_field(declared: JSONObject, *, nested: bool) -> JSONObject:
|
|
@@ -1363,7 +1687,7 @@ class AppFormCompiler:
|
|
|
1363
1687
|
comparable.pop("queId", None)
|
|
1364
1688
|
candidates = []
|
|
1365
1689
|
for candidate in current_fields:
|
|
1366
|
-
if id(candidate) in claimed_fields or candidate
|
|
1690
|
+
if id(candidate) in claimed_fields or _field_type(candidate) != _field_type(declared):
|
|
1367
1691
|
continue
|
|
1368
1692
|
merged = merge_field(candidate, comparable)
|
|
1369
1693
|
if _field_semantic(merged) == _field_semantic(candidate):
|
|
@@ -1376,16 +1700,17 @@ class AppFormCompiler:
|
|
|
1376
1700
|
details={"name": declared.get("name"), "queIds": [item.get("queId") for item in candidates]},
|
|
1377
1701
|
)
|
|
1378
1702
|
current = candidates[0] if candidates else None
|
|
1379
|
-
if current is not None and current
|
|
1380
|
-
raise AppFormFailure("FIELD_TYPE_IMMUTABLE", "field type cannot be changed in place", "CONTRACT_PRECHECK", details={"queId": current.get("queId"), "currentType": current
|
|
1703
|
+
if current is not None and _field_type(current) != _field_type(declared):
|
|
1704
|
+
raise AppFormFailure("FIELD_TYPE_IMMUTABLE", "field type cannot be changed in place", "CONTRACT_PRECHECK", details={"queId": current.get("queId"), "currentType": _field_type(current), "targetType": _field_type(declared)})
|
|
1381
1705
|
if current is not None:
|
|
1382
1706
|
claimed_fields.add(id(current))
|
|
1383
1707
|
target = merge_field(current, declared)
|
|
1384
|
-
if target
|
|
1708
|
+
if _field_type(target) == "subtable":
|
|
1385
1709
|
current_subfields = current.get("subfields") if isinstance((current or {}).get("subfields"), list) else []
|
|
1386
1710
|
target["subfields"] = self._target_body(
|
|
1387
1711
|
[{"kind": "row", "fields": current_subfields}],
|
|
1388
1712
|
[{"kind": "row", "fields": target.get("subfields") or []}],
|
|
1713
|
+
subfields=True,
|
|
1389
1714
|
)[0]["fields"]
|
|
1390
1715
|
for item in target["subfields"]:
|
|
1391
1716
|
item.pop("width", None)
|
|
@@ -1397,13 +1722,15 @@ class AppFormCompiler:
|
|
|
1397
1722
|
for block in declared_body:
|
|
1398
1723
|
target_block = deepcopy(block)
|
|
1399
1724
|
if block.get("kind") == "row":
|
|
1400
|
-
target_block["fields"] = [target_field(field, nested=
|
|
1401
|
-
|
|
1725
|
+
target_block["fields"] = [target_field(field, nested=subfields) for field in block.get("fields") or []]
|
|
1726
|
+
if not subfields:
|
|
1727
|
+
self._fill_widths(target_block["fields"])
|
|
1402
1728
|
else:
|
|
1403
1729
|
target_block["rows"] = []
|
|
1404
1730
|
for row in block.get("rows") or []:
|
|
1405
|
-
fields = [target_field(field, nested=
|
|
1406
|
-
|
|
1731
|
+
fields = [target_field(field, nested=subfields) for field in row.get("fields") or []]
|
|
1732
|
+
if not subfields:
|
|
1733
|
+
self._fill_widths(fields)
|
|
1407
1734
|
target_block["rows"].append({"fields": fields})
|
|
1408
1735
|
section_id = str(block.get("sectionId") or "")
|
|
1409
1736
|
current_section = sections_by_id.get(section_id) if section_id else None
|
|
@@ -1443,40 +1770,75 @@ class AppFormCompiler:
|
|
|
1443
1770
|
if any(present) and not all(present):
|
|
1444
1771
|
raise AppFormFailure("INVALID_ROW_WIDTH", "all fields in a row must provide width together", "CONTRACT_PRECHECK")
|
|
1445
1772
|
if all(present):
|
|
1773
|
+
if any(_positive_int(field.get("width")) not in {25, 50, 75, 100} for field in fields):
|
|
1774
|
+
raise AppFormFailure("INVALID_ROW_WIDTH", "field widths must be one of 25, 50, 75, or 100", "CONTRACT_PRECHECK")
|
|
1446
1775
|
if sum(int(field["width"]) for field in fields) != 100:
|
|
1447
1776
|
raise AppFormFailure("INVALID_ROW_WIDTH", "field widths in a row must total 100", "CONTRACT_PRECHECK")
|
|
1448
1777
|
return
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1778
|
+
widths = {
|
|
1779
|
+
1: [100],
|
|
1780
|
+
2: [50, 50],
|
|
1781
|
+
3: [50, 25, 25],
|
|
1782
|
+
4: [25, 25, 25, 25],
|
|
1783
|
+
}[len(fields)]
|
|
1784
|
+
for field, width in zip(fields, widths, strict=True):
|
|
1785
|
+
field["width"] = width
|
|
1452
1786
|
|
|
1453
1787
|
@staticmethod
|
|
1454
|
-
def _validate_target(
|
|
1788
|
+
def _validate_target(
|
|
1789
|
+
spec: JSONObject,
|
|
1790
|
+
*,
|
|
1791
|
+
allow_missing_data_title: bool = False,
|
|
1792
|
+
) -> tuple[JSONObject | None, JSONObject | None]:
|
|
1793
|
+
body = spec.get("body") or []
|
|
1455
1794
|
fields = list(_iter_fields(body))
|
|
1456
1795
|
names: set[str] = set()
|
|
1457
|
-
title_count = 0
|
|
1458
|
-
cover_count = 0
|
|
1459
1796
|
for field in fields:
|
|
1460
1797
|
name = str(field.get("name") or "")
|
|
1461
1798
|
if name in names:
|
|
1462
1799
|
raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate top-level field name: {name}", "CONTRACT_PRECHECK")
|
|
1463
1800
|
names.add(name)
|
|
1464
|
-
|
|
1465
|
-
cover_count += int(bool(field.get("dataCover")))
|
|
1466
|
-
if field.get("dataCover") and field.get("type") != "attachment":
|
|
1467
|
-
raise AppFormFailure("INVALID_DATA_COVER_FIELD", "dataCover must be an attachment field", "CONTRACT_PRECHECK")
|
|
1468
|
-
if field.get("type") == "subtable":
|
|
1801
|
+
if _field_type(field) == "subtable":
|
|
1469
1802
|
sub_names: set[str] = set()
|
|
1470
1803
|
for subfield in field.get("subfields") or []:
|
|
1471
|
-
if subfield
|
|
1804
|
+
if _field_type(subfield) not in SUBFIELD_TYPES:
|
|
1472
1805
|
raise AppFormFailure("INVALID_SPEC", "unsupported subtable field type", "CONTRACT_PRECHECK", details={"allowedTypes": sorted(SUBFIELD_TYPES)})
|
|
1473
1806
|
if subfield.get("name") in sub_names:
|
|
1474
1807
|
raise AppFormFailure("DUPLICATE_FIELD_NAME", f"duplicate subtable field name: {subfield.get('name')}", "CONTRACT_PRECHECK")
|
|
1475
1808
|
sub_names.add(str(subfield.get("name") or ""))
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
if
|
|
1479
|
-
|
|
1809
|
+
title_selector = spec.get("dataTitleField")
|
|
1810
|
+
data_title: JSONObject | None = None
|
|
1811
|
+
if fields:
|
|
1812
|
+
if title_selector is None and allow_missing_data_title and "dataTitleField" not in spec:
|
|
1813
|
+
pass
|
|
1814
|
+
elif not isinstance(title_selector, dict):
|
|
1815
|
+
raise AppFormFailure(
|
|
1816
|
+
"INVALID_DATA_TITLE_FIELD",
|
|
1817
|
+
"non-empty body requires a dataTitleField selector",
|
|
1818
|
+
"CONTRACT_PRECHECK",
|
|
1819
|
+
)
|
|
1820
|
+
else:
|
|
1821
|
+
data_title = _resolve_canonical_selector(title_selector, fields, "dataTitleField")
|
|
1822
|
+
elif title_selector is not None:
|
|
1823
|
+
raise AppFormFailure(
|
|
1824
|
+
"INVALID_DATA_TITLE_FIELD",
|
|
1825
|
+
"dataTitleField must be null when body is empty",
|
|
1826
|
+
"CONTRACT_PRECHECK",
|
|
1827
|
+
)
|
|
1828
|
+
|
|
1829
|
+
cover_selector = spec.get("dataCoverField")
|
|
1830
|
+
data_cover: JSONObject | None = None
|
|
1831
|
+
if cover_selector is not None:
|
|
1832
|
+
if not isinstance(cover_selector, dict):
|
|
1833
|
+
raise AppFormFailure("INVALID_DATA_COVER_FIELD", "dataCoverField must be a field selector or null", "CONTRACT_PRECHECK")
|
|
1834
|
+
data_cover = _resolve_canonical_selector(cover_selector, fields, "dataCoverField")
|
|
1835
|
+
if _field_type(data_cover) != "attachment":
|
|
1836
|
+
raise AppFormFailure(
|
|
1837
|
+
"INVALID_DATA_COVER_FIELD",
|
|
1838
|
+
"dataCoverField must select a top-level attachment field",
|
|
1839
|
+
"CONTRACT_PRECHECK",
|
|
1840
|
+
)
|
|
1841
|
+
return data_title, data_cover
|
|
1480
1842
|
|
|
1481
1843
|
def payload(
|
|
1482
1844
|
self,
|
|
@@ -1497,6 +1859,7 @@ class AppFormCompiler:
|
|
|
1497
1859
|
relation_context = deepcopy(current_form)
|
|
1498
1860
|
relation_context["questionRelations"] = relations
|
|
1499
1861
|
internal_fields, relations = _compile_q_linker_binding_fields(fields=internal_fields, current_schema=relation_context)
|
|
1862
|
+
self._validate_unique_relation_targets(relations, internal_fields)
|
|
1500
1863
|
except (KeyError, ValueError) as error:
|
|
1501
1864
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", str(error), "COMPILE") from error
|
|
1502
1865
|
self._assign_subfield_temp_ids(internal_fields)
|
|
@@ -1553,8 +1916,9 @@ class AppFormCompiler:
|
|
|
1553
1916
|
rows.append([section])
|
|
1554
1917
|
payload["formQues"] = rows
|
|
1555
1918
|
self._validate_unique_temp_ids(rows)
|
|
1556
|
-
data_title =
|
|
1557
|
-
|
|
1919
|
+
data_title = _resolve_canonical_selector(target["spec"]["dataTitleField"], target_fields, "dataTitleField") if target_fields else None
|
|
1920
|
+
data_cover_selector = target["spec"].get("dataCoverField")
|
|
1921
|
+
data_cover = _resolve_canonical_selector(data_cover_selector, target_fields, "dataCoverField") if isinstance(data_cover_selector, dict) else None
|
|
1558
1922
|
payload["dataTitleQueId"] = self._field_runtime_id(data_title, internal_fields)
|
|
1559
1923
|
payload["dataCoverQueId"] = self._field_runtime_id(data_cover, internal_fields) if data_cover else None
|
|
1560
1924
|
payload["questionRelations"].extend(data_relations)
|
|
@@ -1569,8 +1933,8 @@ class AppFormCompiler:
|
|
|
1569
1933
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
1570
1934
|
binding = config.get("qLinkerBinding") or config.get("codeBlockBinding")
|
|
1571
1935
|
if _positive_int(field.get("queId")) is None and (
|
|
1572
|
-
field
|
|
1573
|
-
or field
|
|
1936
|
+
_field_type(field) in {"q_linker", "data_relation"}
|
|
1937
|
+
or _field_type(field) == "code_block" and bool((binding or {}).get("outputs"))
|
|
1574
1938
|
):
|
|
1575
1939
|
return True
|
|
1576
1940
|
if isinstance(binding, dict):
|
|
@@ -1578,7 +1942,7 @@ class AppFormCompiler:
|
|
|
1578
1942
|
selectors += [item.get("targetField") for item in binding.get("outputs") or [] if isinstance(item, dict)]
|
|
1579
1943
|
if any(isinstance(selector, dict) and not _positive_int(selector.get("queId")) and selector.get("name") in missing_names for selector in selectors):
|
|
1580
1944
|
return True
|
|
1581
|
-
if field
|
|
1945
|
+
if _field_type(field) == "data_relation" and json.dumps(config, ensure_ascii=False, sort_keys=True):
|
|
1582
1946
|
for selector in _walk_selectors(config):
|
|
1583
1947
|
if not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
|
|
1584
1948
|
return True
|
|
@@ -1593,8 +1957,8 @@ class AppFormCompiler:
|
|
|
1593
1957
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
1594
1958
|
binding = config.get("qLinkerBinding") or config.get("codeBlockBinding")
|
|
1595
1959
|
if _positive_int(field.get("queId")) is None and (
|
|
1596
|
-
field
|
|
1597
|
-
or field
|
|
1960
|
+
_field_type(field) in {"q_linker", "data_relation"}
|
|
1961
|
+
or _field_type(field) == "code_block" and bool((binding or {}).get("outputs"))
|
|
1598
1962
|
):
|
|
1599
1963
|
pending.append(
|
|
1600
1964
|
{
|
|
@@ -1612,7 +1976,7 @@ class AppFormCompiler:
|
|
|
1612
1976
|
selector = item.get("targetField") if isinstance(item, dict) else None
|
|
1613
1977
|
if isinstance(selector, dict) and not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
|
|
1614
1978
|
pending.append({"field": field.get("name"), "path": f"outputs/{index}", "selector": deepcopy(selector)})
|
|
1615
|
-
if field
|
|
1979
|
+
if _field_type(field) == "data_relation":
|
|
1616
1980
|
for selector in _walk_selectors(config):
|
|
1617
1981
|
if not _positive_int(selector.get("queId")) and selector.get("name") in missing_names:
|
|
1618
1982
|
pending.append({"field": field.get("name"), "path": "config", "selector": deepcopy(selector)})
|
|
@@ -1706,14 +2070,14 @@ class AppFormCompiler:
|
|
|
1706
2070
|
temp_id -= 1
|
|
1707
2071
|
|
|
1708
2072
|
def _internal_field(self, field: JSONObject, *, templates: dict[int, JSONObject]) -> JSONObject:
|
|
1709
|
-
canonical_type =
|
|
2073
|
+
canonical_type = _field_type(field)
|
|
1710
2074
|
internal_type = "boolean" if canonical_type == "single_choice" else canonical_type
|
|
1711
2075
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
1712
2076
|
result: JSONObject = {
|
|
1713
2077
|
"name": field["name"],
|
|
1714
2078
|
"type": internal_type,
|
|
1715
|
-
"required":
|
|
1716
|
-
"description": field.get("
|
|
2079
|
+
"required": _field_required(field),
|
|
2080
|
+
"description": field.get("questionDescription"),
|
|
1717
2081
|
"que_id": _positive_int(field.get("queId")),
|
|
1718
2082
|
"options": deepcopy(config.get("options") or []),
|
|
1719
2083
|
"subfields": [self._internal_field(item, templates=templates) for item in field.get("subfields") or []],
|
|
@@ -1775,7 +2139,7 @@ class AppFormCompiler:
|
|
|
1775
2139
|
return result
|
|
1776
2140
|
|
|
1777
2141
|
def _question(self, profile: str, app_key: str, field: JSONObject, internal: JSONObject, temp_id: int, relations: list[JSONObject]) -> JSONObject:
|
|
1778
|
-
field_type =
|
|
2142
|
+
field_type = _field_type(field)
|
|
1779
2143
|
que_type = CANONICAL_TO_QUE_TYPE[field_type]
|
|
1780
2144
|
build_type = "boolean" if field_type == "single_choice" else "text" if field_type in {"link", "data_relation"} else field_type
|
|
1781
2145
|
built_internal = deepcopy(internal)
|
|
@@ -1786,7 +2150,7 @@ class AppFormCompiler:
|
|
|
1786
2150
|
generated = _field_to_question(built_internal, temp_id=temp_id)
|
|
1787
2151
|
except (KeyError, ValueError):
|
|
1788
2152
|
generated, _ = build_question(
|
|
1789
|
-
{"label": field["name"], "type": FieldType.text, "required":
|
|
2153
|
+
{"label": field["name"], "type": FieldType.text, "required": _field_required(field), "description": field.get("questionDescription"), "config": {}},
|
|
1790
2154
|
temp_id,
|
|
1791
2155
|
)
|
|
1792
2156
|
template = internal.get("_question_template") if isinstance(internal.get("_question_template"), dict) else None
|
|
@@ -1805,8 +2169,8 @@ class AppFormCompiler:
|
|
|
1805
2169
|
"queType": que_type,
|
|
1806
2170
|
"queTitle": field["name"],
|
|
1807
2171
|
"queWidth": int(field.get("width") or 100),
|
|
1808
|
-
"required":
|
|
1809
|
-
"queHint": field.get("
|
|
2172
|
+
"required": _field_required(field),
|
|
2173
|
+
"queHint": field.get("questionDescription") or "",
|
|
1810
2174
|
}
|
|
1811
2175
|
)
|
|
1812
2176
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
@@ -1973,6 +2337,7 @@ class AppFormCompiler:
|
|
|
1973
2337
|
],
|
|
1974
2338
|
"referAuthQues": [{"queId": item["queId"], "queAuth": 1} for item in visible],
|
|
1975
2339
|
"beingDefaultFormulaAutoFillEnabled": config.get("beingDefaultFormulaAutoFillEnabled"),
|
|
2340
|
+
"defaultValueFormula": config.get("defaultValueFormula"),
|
|
1976
2341
|
"defaultValueMatchRules": deepcopy(config.get("defaultValueMatchRules")),
|
|
1977
2342
|
}
|
|
1978
2343
|
)
|
|
@@ -2045,7 +2410,7 @@ class AppFormCompiler:
|
|
|
2045
2410
|
available_internal_fields = list(_iter_field_tree(internal_fields))
|
|
2046
2411
|
result = []
|
|
2047
2412
|
for field, internal in _iter_field_pairs(fields, internal_fields):
|
|
2048
|
-
if field
|
|
2413
|
+
if _field_type(field) != "data_relation":
|
|
2049
2414
|
continue
|
|
2050
2415
|
config = field.get("config") or {}
|
|
2051
2416
|
source = config.get("source") or {}
|
|
@@ -2196,7 +2561,17 @@ class AppFormCompiler:
|
|
|
2196
2561
|
"judgeType": JUDGE_TYPE_TO_BACKEND[operator],
|
|
2197
2562
|
}
|
|
2198
2563
|
if operand.get("kind") == "literal":
|
|
2199
|
-
|
|
2564
|
+
judge_values, judge_value_details = cls._data_relation_literal_values(
|
|
2565
|
+
source,
|
|
2566
|
+
deepcopy(operand.get("values") or []),
|
|
2567
|
+
)
|
|
2568
|
+
payload.update(
|
|
2569
|
+
{
|
|
2570
|
+
"matchType": 1,
|
|
2571
|
+
"judgeValues": judge_values,
|
|
2572
|
+
"judgeValueDetails": judge_value_details,
|
|
2573
|
+
}
|
|
2574
|
+
)
|
|
2200
2575
|
elif operand.get("kind") == "current_field":
|
|
2201
2576
|
target = _resolve_internal_selector(operand.get("field") or {}, current_fields, "operand.field")
|
|
2202
2577
|
target_id = target.get("que_id") or target.get("que_temp_id")
|
|
@@ -2213,6 +2588,53 @@ class AppFormCompiler:
|
|
|
2213
2588
|
)
|
|
2214
2589
|
return payload
|
|
2215
2590
|
|
|
2591
|
+
@staticmethod
|
|
2592
|
+
def _data_relation_literal_values(source: JSONObject, values: list[Any]) -> tuple[list[str], list[JSONObject]]:
|
|
2593
|
+
field_type = _canonical_backend_field_type(source)
|
|
2594
|
+
options = [item for item in source.get("options") or [] if isinstance(item, dict)]
|
|
2595
|
+
option_by_name = {
|
|
2596
|
+
str(item.get("optValue")): item
|
|
2597
|
+
for item in options
|
|
2598
|
+
if item.get("optValue") is not None
|
|
2599
|
+
}
|
|
2600
|
+
option_by_id = {
|
|
2601
|
+
str(item.get("optId")): item
|
|
2602
|
+
for item in options
|
|
2603
|
+
if _positive_int(item.get("optId")) is not None
|
|
2604
|
+
}
|
|
2605
|
+
judge_values: list[str] = []
|
|
2606
|
+
details: list[JSONObject] = []
|
|
2607
|
+
for value in values:
|
|
2608
|
+
if isinstance(value, bool):
|
|
2609
|
+
text = "true" if value else "false"
|
|
2610
|
+
elif isinstance(value, (str, int, float)):
|
|
2611
|
+
text = str(value)
|
|
2612
|
+
else:
|
|
2613
|
+
raise AppFormFailure(
|
|
2614
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
2615
|
+
"literal data_relation values must be strings",
|
|
2616
|
+
"CONTRACT_PRECHECK",
|
|
2617
|
+
details={"field": AppFormCompiler._data_relation_field_name(source), "value": value},
|
|
2618
|
+
)
|
|
2619
|
+
if field_type in CHOICE_TYPES and options:
|
|
2620
|
+
option = option_by_name.get(text) or option_by_id.get(text)
|
|
2621
|
+
if option is None:
|
|
2622
|
+
raise AppFormFailure(
|
|
2623
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
2624
|
+
f"literal value {text!r} is not an option of data_relation source field "
|
|
2625
|
+
f"{AppFormCompiler._data_relation_field_name(source)!r}",
|
|
2626
|
+
"CONTRACT_PRECHECK",
|
|
2627
|
+
details={"value": text, "allowedValues": sorted(option_by_name)},
|
|
2628
|
+
)
|
|
2629
|
+
option_id = _positive_int(option.get("optId"))
|
|
2630
|
+
option_name = str(option.get("optValue") or text)
|
|
2631
|
+
judge_values.append(str(option_id) if option_id is not None else option_name)
|
|
2632
|
+
details.append(_without_none({"id": option_id, "value": option_name}))
|
|
2633
|
+
continue
|
|
2634
|
+
judge_values.append(text)
|
|
2635
|
+
details.append({"value": text})
|
|
2636
|
+
return judge_values, details
|
|
2637
|
+
|
|
2216
2638
|
@staticmethod
|
|
2217
2639
|
def _resolve_data_relation_source_field(
|
|
2218
2640
|
selector: JSONObject,
|
|
@@ -2238,7 +2660,7 @@ class AppFormCompiler:
|
|
|
2238
2660
|
backend_type = _positive_int(field.get("queType"))
|
|
2239
2661
|
if backend_type is not None:
|
|
2240
2662
|
return backend_type
|
|
2241
|
-
return CANONICAL_TO_QUE_TYPE.get(str(field.get("_app_form_type") or field
|
|
2663
|
+
return CANONICAL_TO_QUE_TYPE.get(str(field.get("_app_form_type") or _field_type(field)), 2)
|
|
2242
2664
|
|
|
2243
2665
|
@staticmethod
|
|
2244
2666
|
def _data_relation_alias_metadata(
|
|
@@ -2270,7 +2692,7 @@ class AppFormCompiler:
|
|
|
2270
2692
|
output = matches[0]
|
|
2271
2693
|
selector = output.get("target_field") or output.get("targetField") or {}
|
|
2272
2694
|
target = _resolve_internal_selector(selector, internal_fields, "source.alias.targetField")
|
|
2273
|
-
origin_type = CANONICAL_TO_QUE_TYPE.get(str(target.get("_app_form_type") or target
|
|
2695
|
+
origin_type = CANONICAL_TO_QUE_TYPE.get(str(target.get("_app_form_type") or _field_type(target)))
|
|
2274
2696
|
if origin_type is None:
|
|
2275
2697
|
raise AppFormFailure(
|
|
2276
2698
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
@@ -2324,9 +2746,7 @@ class AppFormCompiler:
|
|
|
2324
2746
|
|
|
2325
2747
|
def _apply_form_settings(self, payload: JSONObject, *, current_form: JSONObject, target_spec: JSONObject, internal_fields: list[JSONObject]) -> None:
|
|
2326
2748
|
payload["formTitle"] = target_spec["appName"]
|
|
2327
|
-
payload["formDesc"] = target_spec.get("
|
|
2328
|
-
payload["formTheme"] = target_spec.get("theme")
|
|
2329
|
-
payload["formStyle"] = deepcopy(target_spec.get("style"))
|
|
2749
|
+
payload["formDesc"] = target_spec.get("appDescription") or ""
|
|
2330
2750
|
current_attachments = {
|
|
2331
2751
|
(str(item.get("fileName") or item.get("name") or ""), str(item.get("fileUrl") or item.get("url") or "")): item
|
|
2332
2752
|
for item in current_form.get("formAttach") or []
|
|
@@ -2438,6 +2858,8 @@ class AppFormReconciler:
|
|
|
2438
2858
|
context = self.registry.resolve_schema_version(schema_version)
|
|
2439
2859
|
normalizer, _compiler = self._runtime_for(context)
|
|
2440
2860
|
form = self._read_form(profile, app_key, being_draft=being_draft)
|
|
2861
|
+
if not being_draft:
|
|
2862
|
+
form = _normalize_published_scope_omissions(form)
|
|
2441
2863
|
resource = normalizer.from_backend(context=context, app_key=app_key, form=form)
|
|
2442
2864
|
context.validate(resource)
|
|
2443
2865
|
return {"status": "success", "schemaVersion": context.schema_version, "source": "draft" if being_draft else "published", "resource": resource}
|
|
@@ -2474,8 +2896,6 @@ class AppFormReconciler:
|
|
|
2474
2896
|
"create mode requires packageId and spec.appName",
|
|
2475
2897
|
details={"mode": "create"},
|
|
2476
2898
|
)
|
|
2477
|
-
if not is_update:
|
|
2478
|
-
validate_app_form_create_icon(declaration)
|
|
2479
2899
|
apply_dto = context.parse_apply(declaration)
|
|
2480
2900
|
context.assert_apply_dto(apply_dto)
|
|
2481
2901
|
declaration = apply_dto.resource
|
|
@@ -2517,7 +2937,7 @@ class AppFormReconciler:
|
|
|
2517
2937
|
raise
|
|
2518
2938
|
app_key = self._create(profile, declaration)
|
|
2519
2939
|
created = True
|
|
2520
|
-
diagnostics["operation"] = "
|
|
2940
|
+
diagnostics["operation"] = "CREATE_IN_PROGRESS"
|
|
2521
2941
|
try:
|
|
2522
2942
|
package_attached = self._package_contains(profile, package_id, app_key) if package_id is not None else None
|
|
2523
2943
|
except (QingflowApiError, RuntimeError) as error:
|
|
@@ -2548,9 +2968,13 @@ class AppFormReconciler:
|
|
|
2548
2968
|
publish_needed = created
|
|
2549
2969
|
if not form_changed and not created:
|
|
2550
2970
|
try:
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2971
|
+
published = self._published_resource(
|
|
2972
|
+
profile=profile,
|
|
2973
|
+
app_key=app_key,
|
|
2974
|
+
context=context,
|
|
2975
|
+
normalizer=normalizer,
|
|
2976
|
+
)
|
|
2977
|
+
publish_needed = not _published_equal(current, published)
|
|
2554
2978
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
2555
2979
|
raise AppFormFailure("PUBLISHED_FORM_READ_FAILED", str(error), "PRECHECK_PUBLISHED_READ") from error
|
|
2556
2980
|
diagnostics["diff"] = self._diff(current, target)
|
|
@@ -2624,6 +3048,7 @@ class AppFormReconciler:
|
|
|
2624
3048
|
self._raise_write_failure(error, diagnostics, first_write=False)
|
|
2625
3049
|
diagnostics["writeCount"] += 1
|
|
2626
3050
|
diagnostics["formPhase"] = "RELATION_REBIND"
|
|
3051
|
+
diagnostics["pendingBindings"] = []
|
|
2627
3052
|
|
|
2628
3053
|
if wrote_form:
|
|
2629
3054
|
draft_form = self._read_form(profile, app_key, being_draft=True)
|
|
@@ -2655,9 +3080,13 @@ class AppFormReconciler:
|
|
|
2655
3080
|
publish_needed = created or wrote_form
|
|
2656
3081
|
if not publish_needed:
|
|
2657
3082
|
try:
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
3083
|
+
published = self._published_resource(
|
|
3084
|
+
profile=profile,
|
|
3085
|
+
app_key=app_key,
|
|
3086
|
+
context=context,
|
|
3087
|
+
normalizer=normalizer,
|
|
3088
|
+
)
|
|
3089
|
+
diagnostics["published"] = _published_equal(draft, published)
|
|
2661
3090
|
publish_needed = not diagnostics["published"]
|
|
2662
3091
|
except (QingflowApiError, RuntimeError, AppFormFailure) as error:
|
|
2663
3092
|
self._finish_edit(profile, app_key, edit_version_no, diagnostics)
|
|
@@ -2685,10 +3114,86 @@ class AppFormReconciler:
|
|
|
2685
3114
|
diagnostics["operation"] = "CREATED" if created else "APPLIED" if wrote_form or publish_needed or diagnostics["packageAttachCount"] else "UNCHANGED"
|
|
2686
3115
|
return self._success(context, draft, diagnostics, warnings)
|
|
2687
3116
|
except AppFormContractError as error:
|
|
2688
|
-
|
|
3117
|
+
if created and app_key:
|
|
3118
|
+
return self._rollback_created_after_failure(
|
|
3119
|
+
profile=profile,
|
|
3120
|
+
app_key=app_key,
|
|
3121
|
+
error_code=error.error_code,
|
|
3122
|
+
message=error.message,
|
|
3123
|
+
phase="VALIDATE",
|
|
3124
|
+
details={**error.details, **({"path": error.path} if error.path else {})},
|
|
3125
|
+
diagnostics=diagnostics,
|
|
3126
|
+
warnings=warnings,
|
|
3127
|
+
)
|
|
3128
|
+
diagnostics["operation"] = {
|
|
3129
|
+
"create": "CREATE_FAILED",
|
|
3130
|
+
"update": "UPDATE_FAILED",
|
|
3131
|
+
}.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
|
|
3132
|
+
validation_diagnostics = {
|
|
3133
|
+
key: diagnostics[key]
|
|
3134
|
+
for key in ("operation", "applyMode")
|
|
3135
|
+
if diagnostics.get(key) is not None
|
|
3136
|
+
}
|
|
3137
|
+
return self._failure(
|
|
3138
|
+
error.error_code,
|
|
3139
|
+
error.message,
|
|
3140
|
+
"VALIDATE",
|
|
3141
|
+
details={**error.details, **({"path": error.path} if error.path else {})},
|
|
3142
|
+
diagnostics=validation_diagnostics,
|
|
3143
|
+
app_key=app_key,
|
|
3144
|
+
warnings=warnings,
|
|
3145
|
+
)
|
|
2689
3146
|
except AppFormFailure as error:
|
|
3147
|
+
preserve_created_app = error.error_code == "APPLY_READBACK_MISMATCH"
|
|
3148
|
+
if (
|
|
3149
|
+
created
|
|
3150
|
+
and app_key
|
|
3151
|
+
and not preserve_created_app
|
|
3152
|
+
and error.status != "unknown"
|
|
3153
|
+
and diagnostics.get("writeMayHaveSucceeded") is not True
|
|
3154
|
+
):
|
|
3155
|
+
return self._rollback_created_after_failure(
|
|
3156
|
+
profile=profile,
|
|
3157
|
+
app_key=app_key,
|
|
3158
|
+
error_code=error.error_code,
|
|
3159
|
+
message=error.message,
|
|
3160
|
+
phase=error.phase,
|
|
3161
|
+
details=error.details,
|
|
3162
|
+
diagnostics=diagnostics,
|
|
3163
|
+
warnings=warnings,
|
|
3164
|
+
)
|
|
3165
|
+
if created:
|
|
3166
|
+
diagnostics["operation"] = "CREATE_INCOMPLETE"
|
|
3167
|
+
else:
|
|
3168
|
+
diagnostics["operation"] = {
|
|
3169
|
+
"create": "CREATE_FAILED",
|
|
3170
|
+
"update": "UPDATE_FAILED",
|
|
3171
|
+
}.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
|
|
2690
3172
|
status = "partial_success" if created and error.status == "failed" else error.status
|
|
2691
|
-
|
|
3173
|
+
result = self._failure(error.error_code, error.message, error.phase, status=status, details=error.details, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
|
|
3174
|
+
if error.error_code == "APPLY_READBACK_MISMATCH" and app_key:
|
|
3175
|
+
result["requiresFurtherRepair"] = True
|
|
3176
|
+
result["mustReadCurrentStateBeforeRepair"] = True
|
|
3177
|
+
result["nextApplyMode"] = "update"
|
|
3178
|
+
result["suggested_next_call"] = {
|
|
3179
|
+
"tool_name": "app_form_get",
|
|
3180
|
+
"arguments": {
|
|
3181
|
+
"profile": profile,
|
|
3182
|
+
"app_key": app_key,
|
|
3183
|
+
"being_draft": True,
|
|
3184
|
+
},
|
|
3185
|
+
}
|
|
3186
|
+
if created:
|
|
3187
|
+
result["message"] = (
|
|
3188
|
+
"the application was created and its form was saved, but backend readback still differs from the apply target; "
|
|
3189
|
+
"the application was preserved and was not published"
|
|
3190
|
+
)
|
|
3191
|
+
result["createdAppPreserved"] = True
|
|
3192
|
+
result["recoveryHint"] = (
|
|
3193
|
+
"first read the current draft using the returned appKey; compare it with remainingDiff, then merge the intended changes into that current state, "
|
|
3194
|
+
"keep the appKey in the declaration, validate, and apply again in update mode"
|
|
3195
|
+
)
|
|
3196
|
+
return result
|
|
2692
3197
|
except (QingflowApiError, RuntimeError) as error:
|
|
2693
3198
|
uncertain = self._is_uncertain_api_error(error)
|
|
2694
3199
|
code = (
|
|
@@ -2700,6 +3205,24 @@ class AppFormReconciler:
|
|
|
2700
3205
|
if uncertain
|
|
2701
3206
|
else "APP_FORM_APPLY_FAILED"
|
|
2702
3207
|
)
|
|
3208
|
+
if created and app_key and not uncertain:
|
|
3209
|
+
return self._rollback_created_after_failure(
|
|
3210
|
+
profile=profile,
|
|
3211
|
+
app_key=app_key,
|
|
3212
|
+
error_code=code,
|
|
3213
|
+
message=str(self._error_details(error).get("message") or error),
|
|
3214
|
+
phase="APPLY",
|
|
3215
|
+
details=self._error_details(error),
|
|
3216
|
+
diagnostics=diagnostics,
|
|
3217
|
+
warnings=warnings,
|
|
3218
|
+
)
|
|
3219
|
+
if created:
|
|
3220
|
+
diagnostics["operation"] = "CREATE_INCOMPLETE"
|
|
3221
|
+
else:
|
|
3222
|
+
diagnostics["operation"] = {
|
|
3223
|
+
"create": "CREATE_FAILED",
|
|
3224
|
+
"update": "UPDATE_FAILED",
|
|
3225
|
+
}.get(str(diagnostics.get("applyMode") or ""), "VALIDATION_FAILED")
|
|
2703
3226
|
mutation_started = bool(
|
|
2704
3227
|
created
|
|
2705
3228
|
or diagnostics["writeAttempts"]
|
|
@@ -2709,6 +3232,60 @@ class AppFormReconciler:
|
|
|
2709
3232
|
status = "unknown" if uncertain else "partial_success" if mutation_started else "failed"
|
|
2710
3233
|
return self._api_failure(code, error, "CREATE" if not app_key else "APPLY", status=status, diagnostics=diagnostics, app_key=app_key, warnings=warnings)
|
|
2711
3234
|
|
|
3235
|
+
def _rollback_created_after_failure(
|
|
3236
|
+
self,
|
|
3237
|
+
*,
|
|
3238
|
+
profile: str,
|
|
3239
|
+
app_key: str,
|
|
3240
|
+
error_code: str,
|
|
3241
|
+
message: str,
|
|
3242
|
+
phase: str,
|
|
3243
|
+
details: JSONObject | None,
|
|
3244
|
+
diagnostics: JSONObject,
|
|
3245
|
+
warnings: list[JSONObject],
|
|
3246
|
+
) -> JSONObject:
|
|
3247
|
+
diagnostics["createRollbackAttempted"] = True
|
|
3248
|
+
failure_details = deepcopy(details or {})
|
|
3249
|
+
transport_error = failure_details.pop("transportError", None)
|
|
3250
|
+
if isinstance(transport_error, dict):
|
|
3251
|
+
failure_details["failureCause"] = deepcopy(transport_error)
|
|
3252
|
+
try:
|
|
3253
|
+
self.apps.app_delete(profile=profile, app_key=app_key)
|
|
3254
|
+
except (QingflowApiError, RuntimeError) as rollback_error:
|
|
3255
|
+
rollback_details = self._error_details(rollback_error)
|
|
3256
|
+
uncertain = self._is_uncertain_api_error(rollback_error)
|
|
3257
|
+
diagnostics["createRollbackSucceeded"] = None if uncertain else False
|
|
3258
|
+
diagnostics["operation"] = "CREATE_ROLLBACK_UNKNOWN" if uncertain else "CREATE_ROLLBACK_FAILED"
|
|
3259
|
+
failure_details["rollbackError"] = rollback_details
|
|
3260
|
+
return self._failure(
|
|
3261
|
+
error_code,
|
|
3262
|
+
(
|
|
3263
|
+
"AppForm creation failed, and cleanup of the newly created application "
|
|
3264
|
+
f"{'could not be confirmed' if uncertain else 'also failed'}: {message}"
|
|
3265
|
+
),
|
|
3266
|
+
phase,
|
|
3267
|
+
status="unknown" if uncertain else "partial_success",
|
|
3268
|
+
details=failure_details,
|
|
3269
|
+
diagnostics=diagnostics,
|
|
3270
|
+
app_key=app_key,
|
|
3271
|
+
warnings=warnings,
|
|
3272
|
+
)
|
|
3273
|
+
diagnostics["createRollbackSucceeded"] = True
|
|
3274
|
+
diagnostics["operation"] = "CREATE_ROLLED_BACK"
|
|
3275
|
+
result = self._failure(
|
|
3276
|
+
error_code,
|
|
3277
|
+
f"AppForm creation failed and the newly created application was deleted: {message}",
|
|
3278
|
+
phase,
|
|
3279
|
+
status="failed",
|
|
3280
|
+
details=failure_details,
|
|
3281
|
+
diagnostics=diagnostics,
|
|
3282
|
+
app_key=app_key,
|
|
3283
|
+
warnings=warnings,
|
|
3284
|
+
)
|
|
3285
|
+
result["recoverable"] = True
|
|
3286
|
+
result["recoveryHint"] = "fix the original failure cause, then submit the create declaration again"
|
|
3287
|
+
return result
|
|
3288
|
+
|
|
2712
3289
|
def delete(self, *, profile: str, app_key: str) -> JSONObject:
|
|
2713
3290
|
try:
|
|
2714
3291
|
result = self.apps.app_delete(profile=profile, app_key=app_key)
|
|
@@ -2720,11 +3297,9 @@ class AppFormReconciler:
|
|
|
2720
3297
|
spec = declaration["spec"]
|
|
2721
3298
|
app_name = str(spec["appName"])
|
|
2722
3299
|
package_id = int(declaration["packageId"])
|
|
2723
|
-
icon = str(spec["icon"])
|
|
2724
|
-
color = str(spec["color"])
|
|
2725
3300
|
payload = {
|
|
2726
3301
|
"appName": app_name,
|
|
2727
|
-
"appIcon":
|
|
3302
|
+
"appIcon": encode_workspace_icon_with_defaults(icon=None, color=None, title=app_name),
|
|
2728
3303
|
"auth": default_member_auth(),
|
|
2729
3304
|
"tagIds": [package_id],
|
|
2730
3305
|
}
|
|
@@ -2912,14 +3487,30 @@ class AppFormReconciler:
|
|
|
2912
3487
|
diagnostics: JSONObject,
|
|
2913
3488
|
) -> bool | None:
|
|
2914
3489
|
try:
|
|
2915
|
-
published_form = self._read_form(profile, app_key, being_draft=False)
|
|
2916
3490
|
normalizer, _compiler = self._runtime_for(context)
|
|
2917
|
-
published =
|
|
2918
|
-
|
|
3491
|
+
published = self._published_resource(
|
|
3492
|
+
profile=profile,
|
|
3493
|
+
app_key=app_key,
|
|
3494
|
+
context=context,
|
|
3495
|
+
normalizer=normalizer,
|
|
3496
|
+
)
|
|
3497
|
+
diagnostics["published"] = _published_equal(draft, published)
|
|
2919
3498
|
except (QingflowApiError, RuntimeError, AppFormFailure):
|
|
2920
3499
|
diagnostics["published"] = None
|
|
2921
3500
|
return diagnostics["published"]
|
|
2922
3501
|
|
|
3502
|
+
def _published_resource(
|
|
3503
|
+
self,
|
|
3504
|
+
*,
|
|
3505
|
+
profile: str,
|
|
3506
|
+
app_key: str,
|
|
3507
|
+
context: AppFormSchemaContext,
|
|
3508
|
+
normalizer: AppFormNormalizer,
|
|
3509
|
+
) -> JSONObject:
|
|
3510
|
+
published_form = self._read_form(profile, app_key, being_draft=False)
|
|
3511
|
+
comparable_form = _normalize_published_scope_omissions(published_form)
|
|
3512
|
+
return normalizer.from_backend(context=context, app_key=app_key, form=comparable_form)
|
|
3513
|
+
|
|
2923
3514
|
def _finish_edit(self, profile: str, app_key: str, edit_version_no: int, diagnostics: JSONObject) -> None:
|
|
2924
3515
|
try:
|
|
2925
3516
|
self.apps.app_edit_finished(profile=profile, app_key=app_key, payload={"editVersionNo": edit_version_no})
|
|
@@ -2963,9 +3554,13 @@ class AppFormReconciler:
|
|
|
2963
3554
|
details={**diagnostics, "transportError": details},
|
|
2964
3555
|
) from error
|
|
2965
3556
|
diagnostics["writeMayHaveSucceeded"] = self._is_uncertain_api_error(error)
|
|
3557
|
+
backend_message = str(details.get("message") or "").strip()
|
|
3558
|
+
message = "form write failed or could not be confirmed"
|
|
3559
|
+
if backend_message:
|
|
3560
|
+
message = f"{message}: {backend_message}"
|
|
2966
3561
|
raise AppFormFailure(
|
|
2967
3562
|
"APPLY_RESULT_UNKNOWN" if diagnostics["writeMayHaveSucceeded"] else "RELATION_REBIND_FAILED" if not first_write else "APP_FORM_WRITE_FAILED",
|
|
2968
|
-
|
|
3563
|
+
message,
|
|
2969
3564
|
diagnostics["formPhase"],
|
|
2970
3565
|
status="unknown" if diagnostics["writeMayHaveSucceeded"] else "partial_success" if not first_write else "failed",
|
|
2971
3566
|
details={**diagnostics, "transportError": details},
|
|
@@ -3351,6 +3946,25 @@ def _resolve_backend_selector(selector: JSONObject, candidates: list[JSONObject]
|
|
|
3351
3946
|
return matches[0]
|
|
3352
3947
|
|
|
3353
3948
|
|
|
3949
|
+
def _resolve_canonical_selector(selector: JSONObject, candidates: list[JSONObject], location: str) -> JSONObject:
|
|
3950
|
+
que_id = _positive_int(selector.get("queId"))
|
|
3951
|
+
name = str(selector.get("name") or "")
|
|
3952
|
+
matches = [
|
|
3953
|
+
item
|
|
3954
|
+
for item in candidates
|
|
3955
|
+
if (que_id is None or _positive_int(item.get("queId")) == que_id)
|
|
3956
|
+
and (not name or str(item.get("name") or "") == name)
|
|
3957
|
+
]
|
|
3958
|
+
if len(matches) != 1:
|
|
3959
|
+
raise AppFormFailure(
|
|
3960
|
+
"INVALID_FIELD_SELECTOR",
|
|
3961
|
+
f"{location} must resolve to exactly one top-level field",
|
|
3962
|
+
"CONTRACT_PRECHECK",
|
|
3963
|
+
details={"selector": selector, "matches": len(matches)},
|
|
3964
|
+
)
|
|
3965
|
+
return matches[0]
|
|
3966
|
+
|
|
3967
|
+
|
|
3354
3968
|
def _resolve_internal_selector(selector: JSONObject, fields: list[JSONObject], location: str) -> JSONObject:
|
|
3355
3969
|
que_id = _positive_int(selector.get("queId") or selector.get("que_id"))
|
|
3356
3970
|
name = str(selector.get("name") or "")
|