@qingflow-tech/qingflow-cli 1.1.44 → 1.1.45
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
CHANGED
|
@@ -7,7 +7,7 @@ Qingflow CLI 是轻流面向开发者和 AI Agent 的命令行工具。它把轻
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npm install @qingflow-tech/qingflow-cli@1.1.
|
|
10
|
+
npm install @qingflow-tech/qingflow-cli@1.1.45
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
推荐从官网复制这一条一键安装 Qingflow CLI:
|
|
@@ -21,7 +21,7 @@ npx qingflow-cli@latest install
|
|
|
21
21
|
## 运行
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
24
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.45 qingflow
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
全局安装后可以直接运行:
|
|
@@ -62,7 +62,7 @@ qingflow-skills install --agent codex --scope user
|
|
|
62
62
|
如果你通过一次性 `npx -p` 执行 skills 安装,建议加 `--copy`,避免 symlink 指向 npm 临时执行缓存:
|
|
63
63
|
|
|
64
64
|
```bash
|
|
65
|
-
npx -y -p @qingflow-tech/qingflow-cli@1.1.
|
|
65
|
+
npx -y -p @qingflow-tech/qingflow-cli@1.1.45 qingflow-skills install --agent codex --scope user --copy
|
|
66
66
|
```
|
|
67
67
|
|
|
68
68
|
`qingflow-skills install` 默认使用 symlink,且遇到同名 skill 不会覆盖;只有显式传入 `--force` 才会替换。
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -219,6 +219,8 @@ current CLI payload.
|
|
|
219
219
|
| `data_relation` | 数据关联/引用填充 |
|
|
220
220
|
| `subtable` | 子表(**`subfields[]`**) |
|
|
221
221
|
|
|
222
|
+
历史表单中的后端 `queType=1` 是“描述文字”布局控件,不属于上述新建字段类型。`app-form get` 为了保持完整声明可校验,会将它兼容输出为 `long_text`;`app-form apply` 会根据已有字段模板恢复原始 `queType=1` 和 `queDefaultValue`,所以修改其他字段时不会丢失描述文字。新建可填写多行文本仍使用 `long_text`,不要用它创建描述文字控件。
|
|
223
|
+
|
|
222
224
|
**`config.relationMode`**:仅 **`relation`** 使用,取值为 **`single`** 或 **`multiple`**。一个应用可以有多个 `relation` 字段;不要因为 relation 数量超过 1 就改成文本字段。
|
|
223
225
|
|
|
224
226
|
### 1.1 历史字段类型别名
|
|
@@ -246,6 +248,10 @@ current CLI payload.
|
|
|
246
248
|
| `q_linker` | **`config.qLinkerBinding`**:`inputs`、`request`、**`outputs[]` 每项须含 `targetField`**(能解析到本表单字段) | `request.url` 等须符合**租户/后端远程查询策略**;以字段类型 Schema 返回的配置为准 |
|
|
247
249
|
| `code_block` | **`config.codeBlockBinding`**:`inputs`、`code`、**`outputs[]` + `targetField`**;配置 outputs 时,代码必须显式写 **`qf_output = {...}`**,仅 `return {...}` 不会产生可回写结果,且每个 `outputs[].path` 必须从 `qf_output` 取值 | 以字段类型 Schema 返回的 camelCase 配置为准 |
|
|
248
250
|
|
|
251
|
+
部门字段的 `defaultDepartments` 也遵循同一约束:`custom` 模式必须包含至少一个部门。读取历史后端数据时,如果默认部门值存在但部门列表为空,AppForm 归一化会将其视为“未配置默认部门”并省略 `defaultDepartments`;不要在新声明中写 `departments: []`。
|
|
252
|
+
|
|
253
|
+
读取历史数据时,空的应用/段落/字段标题会按稳定 ID 生成兼容名称,空行和空筛选组会被忽略;选择项、子表列、序列号组件、Q-Linker 输出和代码源码等语义必需值无法安全推断时,读取会返回结构化 `UNSUPPORTED_FIELD_CONFIG`,不会伪造声明值。`validate/apply` 对新声明仍保持严格 Schema 约束。
|
|
254
|
+
|
|
249
255
|
别名:契约会把 `title`/`label` 映射到 **`name`** 等;字段类型自然别名见 §1.1。
|
|
250
256
|
|
|
251
257
|
### 2.1 `relation` 选择器形态
|
|
@@ -31,6 +31,10 @@ from .service import (
|
|
|
31
31
|
|
|
32
32
|
API_VERSION = "builder.qingflow.com/v1alpha1"
|
|
33
33
|
QUE_TYPE_TO_CANONICAL = {
|
|
34
|
+
# Historical description-text controls are read as long_text for the
|
|
35
|
+
# public declaration shape; the compiler restores queType=1 from the
|
|
36
|
+
# existing backend template when writing them back.
|
|
37
|
+
1: "long_text",
|
|
34
38
|
2: "text",
|
|
35
39
|
3: "long_text",
|
|
36
40
|
5: "member",
|
|
@@ -227,23 +231,33 @@ def _canonical_member_scope(scope: Any, scope_type: Any, *, department: bool = F
|
|
|
227
231
|
}
|
|
228
232
|
if department:
|
|
229
233
|
result["departments"] = [
|
|
230
|
-
_without_none(
|
|
234
|
+
_without_none(
|
|
235
|
+
{
|
|
236
|
+
"deptId": _positive_int(item.get("deptId") or item.get("id")),
|
|
237
|
+
"deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
|
|
238
|
+
}
|
|
239
|
+
)
|
|
231
240
|
for item in _scope_items(raw, "depart")
|
|
232
241
|
if _positive_int(item.get("deptId") or item.get("id")) is not None
|
|
233
242
|
]
|
|
234
243
|
else:
|
|
235
244
|
result["members"] = [
|
|
236
|
-
_without_none({"uid": _positive_int(item.get("uid")), "nickName": item.get("nickName")})
|
|
245
|
+
_without_none({"uid": _positive_int(item.get("uid")), "nickName": _compat_label(item.get("nickName"), "Member", item.get("uid"))})
|
|
237
246
|
for item in _scope_items(raw, "member")
|
|
238
247
|
if _positive_int(item.get("uid")) is not None
|
|
239
248
|
]
|
|
240
249
|
result["departments"] = [
|
|
241
|
-
_without_none(
|
|
250
|
+
_without_none(
|
|
251
|
+
{
|
|
252
|
+
"deptId": _positive_int(item.get("deptId")),
|
|
253
|
+
"deptName": _compat_label(item.get("deptName"), "Department", item.get("deptId")),
|
|
254
|
+
}
|
|
255
|
+
)
|
|
242
256
|
for item in _scope_items(raw, "depart")
|
|
243
257
|
if _positive_int(item.get("deptId")) is not None
|
|
244
258
|
]
|
|
245
259
|
result["roles"] = [
|
|
246
|
-
_without_none({"roleId": _positive_int(item.get("roleId")), "roleName": item.get("roleName")})
|
|
260
|
+
_without_none({"roleId": _positive_int(item.get("roleId")), "roleName": _compat_label(item.get("roleName"), "Role", item.get("roleId"))})
|
|
247
261
|
for item in _scope_items(raw, "role")
|
|
248
262
|
if _positive_int(item.get("roleId")) is not None
|
|
249
263
|
]
|
|
@@ -281,11 +295,12 @@ def _canonical_scope_relation(relation: Any) -> JSONObject | None:
|
|
|
281
295
|
return None
|
|
282
296
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
283
297
|
displayed_id = _positive_int(relation.get("displayedQueId") or displayed.get("queId"))
|
|
284
|
-
|
|
298
|
+
target_app_key = str(displayed.get("appKey") or "")
|
|
299
|
+
if displayed_id is None or not target_app_key.strip():
|
|
285
300
|
return None
|
|
286
301
|
result: JSONObject = {
|
|
287
302
|
"displayedField": _selector(displayed_id, displayed.get("queTitle")),
|
|
288
|
-
"targetAppKey":
|
|
303
|
+
"targetAppKey": target_app_key,
|
|
289
304
|
"targetAppTitle": displayed.get("appTitle"),
|
|
290
305
|
"displayedFieldType": displayed.get("queType"),
|
|
291
306
|
"matchRuleType": relation.get("matchRuleType"),
|
|
@@ -334,6 +349,22 @@ def _selector(que_id: Any, name: Any) -> JSONObject:
|
|
|
334
349
|
return result
|
|
335
350
|
|
|
336
351
|
|
|
352
|
+
def _compat_label(value: Any, kind: str, identity: Any) -> str:
|
|
353
|
+
"""Give legacy records with an empty display label a stable, non-empty name."""
|
|
354
|
+
label = str(value or "")
|
|
355
|
+
if label.strip():
|
|
356
|
+
return label
|
|
357
|
+
normalized_identity = str(identity or "").strip()
|
|
358
|
+
return f"{kind} {normalized_identity}" if normalized_identity else f"Unnamed {kind}"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _required_text(value: Any, *, message: str, details: JSONObject | None = None) -> str:
|
|
362
|
+
text = str(value or "")
|
|
363
|
+
if not text.strip():
|
|
364
|
+
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", message, "NORMALIZE", details=details)
|
|
365
|
+
return text
|
|
366
|
+
|
|
367
|
+
|
|
337
368
|
def _require_selector(que_id: Any, name: Any, *, message: str, details: JSONObject | None = None) -> JSONObject:
|
|
338
369
|
result = _selector(que_id, name)
|
|
339
370
|
if not result:
|
|
@@ -620,21 +651,40 @@ class AppFormNormalizer:
|
|
|
620
651
|
section_id = _positive_int(section.get("queId")) or _positive_int(section.get("sectionId"))
|
|
621
652
|
if section_id is None:
|
|
622
653
|
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section has no stable ID", "NORMALIZE")
|
|
623
|
-
rows = [
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
654
|
+
rows = []
|
|
655
|
+
for inner in section.get("innerQuestions") or []:
|
|
656
|
+
if not isinstance(inner, list) or not inner:
|
|
657
|
+
continue
|
|
658
|
+
fields = [
|
|
659
|
+
self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
|
|
660
|
+
for question in inner
|
|
661
|
+
if isinstance(question, dict)
|
|
662
|
+
]
|
|
663
|
+
if fields:
|
|
664
|
+
rows.append({"fields": fields})
|
|
628
665
|
if not rows:
|
|
629
666
|
raise AppFormFailure("UNSUPPORTED_LAYOUT", "existing section is empty", "NORMALIZE")
|
|
630
|
-
body.append(
|
|
667
|
+
body.append(
|
|
668
|
+
{
|
|
669
|
+
"kind": "section",
|
|
670
|
+
"sectionId": str(section_id),
|
|
671
|
+
"title": _compat_label(section.get("queTitle"), "Section", section_id),
|
|
672
|
+
"rows": rows,
|
|
673
|
+
}
|
|
674
|
+
)
|
|
631
675
|
else:
|
|
632
|
-
|
|
676
|
+
fields = [
|
|
677
|
+
self._field(question, relations, select_scope_relations, field_names, supports_reference_default_formula)
|
|
678
|
+
for question in row
|
|
679
|
+
if isinstance(question, dict)
|
|
680
|
+
]
|
|
681
|
+
if fields:
|
|
682
|
+
body.append({"kind": "row", "fields": fields})
|
|
633
683
|
resource = {
|
|
634
684
|
"apiVersion": context.api_version,
|
|
635
685
|
"appKey": app_key,
|
|
636
686
|
"spec": {
|
|
637
|
-
"appName":
|
|
687
|
+
"appName": _compat_label(form.get("formTitle"), "App", app_key),
|
|
638
688
|
"appDescription": form.get("formDesc") or None,
|
|
639
689
|
"dataTitleField": _selector(title_id, field_names.get(title_id or 0)) if title_id is not None else None,
|
|
640
690
|
"dataCoverField": _selector(cover_id, field_names.get(cover_id or 0)) if cover_id is not None else None,
|
|
@@ -676,18 +726,27 @@ class AppFormNormalizer:
|
|
|
676
726
|
details={"queId": que_id, "queType": que_type},
|
|
677
727
|
)
|
|
678
728
|
field: JSONObject = {
|
|
679
|
-
"name":
|
|
729
|
+
"name": _compat_label(question.get("queTitle"), "Field", que_id),
|
|
680
730
|
"questionType": field_type,
|
|
681
731
|
"beingRequired": bool(question.get("required")),
|
|
682
732
|
"questionDescription": question.get("queHint") or None,
|
|
683
733
|
}
|
|
684
734
|
default_type = _positive_int(question.get("queDefaultType"))
|
|
685
|
-
|
|
735
|
+
raw_default_value = question.get("queDefaultValue")
|
|
736
|
+
decoded_default_value = (
|
|
737
|
+
_decode_formula_default(raw_default_value)
|
|
738
|
+
if isinstance(raw_default_value, (str, int, float, bool))
|
|
739
|
+
else raw_default_value
|
|
740
|
+
)
|
|
741
|
+
empty_formula_default = default_type == 3 and (
|
|
742
|
+
decoded_default_value is None
|
|
743
|
+
or (isinstance(decoded_default_value, str) and not decoded_default_value.strip())
|
|
744
|
+
)
|
|
745
|
+
if default_type is not None and not empty_formula_default and (
|
|
686
746
|
question.get("queDefaultValue") is not None
|
|
687
747
|
or (default_type != 1 and field_type != "relation")
|
|
688
748
|
):
|
|
689
749
|
field["defaultType"] = default_type
|
|
690
|
-
raw_default_value = question.get("queDefaultValue")
|
|
691
750
|
if isinstance(raw_default_value, (dict, list)) and field_type not in {"address", "attachment", "relation", "subtable"}:
|
|
692
751
|
raise AppFormFailure(
|
|
693
752
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
@@ -695,9 +754,14 @@ class AppFormNormalizer:
|
|
|
695
754
|
"NORMALIZE",
|
|
696
755
|
details={"queId": que_id, "type": field_type},
|
|
697
756
|
)
|
|
698
|
-
if
|
|
699
|
-
|
|
700
|
-
|
|
757
|
+
if (
|
|
758
|
+
"queDefaultValue" in question
|
|
759
|
+
and raw_default_value is not None
|
|
760
|
+
and not isinstance(raw_default_value, (dict, list))
|
|
761
|
+
and not empty_formula_default
|
|
762
|
+
and not (default_type == 2 and isinstance(decoded_default_value, str) and not decoded_default_value.strip())
|
|
763
|
+
):
|
|
764
|
+
field["defaultValue"] = decoded_default_value
|
|
701
765
|
if que_id is not None:
|
|
702
766
|
field["queId"] = que_id
|
|
703
767
|
width = _positive_int(question.get("queWidth"))
|
|
@@ -735,9 +799,20 @@ class AppFormNormalizer:
|
|
|
735
799
|
if config:
|
|
736
800
|
field["config"] = config
|
|
737
801
|
elif field_type in CHOICE_TYPES:
|
|
738
|
-
options = [
|
|
802
|
+
options = [
|
|
803
|
+
str(item.get("optValue"))
|
|
804
|
+
for item in question.get("options") or []
|
|
805
|
+
if isinstance(item, dict) and item.get("optValue") is not None and str(item.get("optValue")).strip()
|
|
806
|
+
]
|
|
739
807
|
if not options and field_type == "single_choice":
|
|
740
808
|
options = ["是", "否"]
|
|
809
|
+
if not options:
|
|
810
|
+
raise AppFormFailure(
|
|
811
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
812
|
+
"choice field has no options",
|
|
813
|
+
"NORMALIZE",
|
|
814
|
+
details={"queId": que_id, "type": field_type, "path": "/config/options"},
|
|
815
|
+
)
|
|
741
816
|
field["config"] = _without_none({
|
|
742
817
|
"options": options,
|
|
743
818
|
"minOpts": _canonical_unlimited(question.get("minOpts")),
|
|
@@ -745,7 +820,14 @@ class AppFormNormalizer:
|
|
|
745
820
|
"optDirection": question.get("optDirection") if question.get("optDirection") not in {None, 0} else None,
|
|
746
821
|
"optSelectMode": question.get("optSelectMode"),
|
|
747
822
|
"beingShowColor": question.get("beingShowColor"),
|
|
748
|
-
"defaultOptions": [
|
|
823
|
+
"defaultOptions": [
|
|
824
|
+
str(item.get("optValue"))
|
|
825
|
+
for item in question.get("options") or []
|
|
826
|
+
if isinstance(item, dict)
|
|
827
|
+
and item.get("beingDefault") is True
|
|
828
|
+
and item.get("optValue") is not None
|
|
829
|
+
and str(item.get("optValue")).strip()
|
|
830
|
+
],
|
|
749
831
|
})
|
|
750
832
|
if not field["config"].get("defaultOptions"):
|
|
751
833
|
field["config"].pop("defaultOptions", None)
|
|
@@ -766,7 +848,12 @@ class AppFormNormalizer:
|
|
|
766
848
|
scope = question.get("deptSelectScope") if isinstance(question.get("deptSelectScope"), dict) else {}
|
|
767
849
|
departments = scope.get("depart") if isinstance(scope.get("depart"), list) else []
|
|
768
850
|
canonical_departments = [
|
|
769
|
-
_without_none(
|
|
851
|
+
_without_none(
|
|
852
|
+
{
|
|
853
|
+
"deptId": _positive_int(item.get("deptId") or item.get("id")),
|
|
854
|
+
"deptName": _compat_label(item.get("deptName") or item.get("name"), "Department", item.get("deptId") or item.get("id")),
|
|
855
|
+
}
|
|
856
|
+
)
|
|
770
857
|
for item in departments
|
|
771
858
|
if isinstance(item, dict) and _positive_int(item.get("deptId") or item.get("id")) is not None
|
|
772
859
|
]
|
|
@@ -791,7 +878,10 @@ class AppFormNormalizer:
|
|
|
791
878
|
else:
|
|
792
879
|
field["config"] = {"departmentScope": {"mode": "all", "includeSubDeparts": bool(scope.get("includeSubDeparts"))}}
|
|
793
880
|
if question.get("deptDftValue") is not None:
|
|
794
|
-
|
|
881
|
+
default_scope = _canonical_member_scope(question.get("deptDftValue"), 2, department=True)
|
|
882
|
+
# Older backend responses may include an empty default-value container when no default department is set.
|
|
883
|
+
if default_scope.get("departments"):
|
|
884
|
+
field["config"]["defaultDepartments"] = default_scope
|
|
795
885
|
relation = next((item for item in select_scope_relations if _positive_int(item.get("queId")) == que_id), None)
|
|
796
886
|
scope_relation = _canonical_scope_relation(relation)
|
|
797
887
|
if scope_relation is not None:
|
|
@@ -800,9 +890,14 @@ class AppFormNormalizer:
|
|
|
800
890
|
if question.get(key) is not None:
|
|
801
891
|
field["config"][key] = question[key]
|
|
802
892
|
elif field_type == "attachment":
|
|
893
|
+
file_types = question.get("fileType")
|
|
894
|
+
if isinstance(file_types, list):
|
|
895
|
+
file_types = [item for item in file_types if str(item or "").strip()]
|
|
896
|
+
elif isinstance(file_types, str) and not file_types.strip():
|
|
897
|
+
file_types = None
|
|
803
898
|
field["config"] = _without_none({
|
|
804
899
|
"fileSize": question.get("fileSize"),
|
|
805
|
-
"fileType":
|
|
900
|
+
"fileType": file_types,
|
|
806
901
|
"fileBeingCaptureOnly": question.get("fileBeingCaptureOnly"),
|
|
807
902
|
"imageCompress": question.get("imageCompress"),
|
|
808
903
|
"pluginStatus": question.get("pluginStatus"),
|
|
@@ -826,14 +921,22 @@ class AppFormNormalizer:
|
|
|
826
921
|
display_id = _positive_int(reference.get("referQueId"))
|
|
827
922
|
display = next((item for item in refer_questions if _positive_int(item.get("queId")) == display_id), None)
|
|
828
923
|
visible = [item for item in refer_questions if _positive_int(item.get("queAuth")) != 2]
|
|
924
|
+
target_app_key = str(reference.get("referAppKey") or "")
|
|
925
|
+
if not target_app_key.strip():
|
|
926
|
+
raise AppFormFailure(
|
|
927
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
928
|
+
"relation target app cannot be normalized",
|
|
929
|
+
"NORMALIZE",
|
|
930
|
+
details={"queId": que_id, "path": "/config/targetAppKey"},
|
|
931
|
+
)
|
|
829
932
|
relation_config: JSONObject = {
|
|
830
|
-
"targetAppKey":
|
|
933
|
+
"targetAppKey": target_app_key,
|
|
831
934
|
"relationMode": "multiple" if _positive_int(reference.get("optionalDataNum")) not in {None, 1} else "single",
|
|
832
935
|
"displayField": _require_selector(
|
|
833
936
|
display_id,
|
|
834
937
|
(display or {}).get("queTitle"),
|
|
835
938
|
message="relation display field cannot be normalized",
|
|
836
|
-
details={"queId": que_id},
|
|
939
|
+
details={"queId": que_id, "path": "/config/displayField"},
|
|
837
940
|
),
|
|
838
941
|
"visibleFields": [
|
|
839
942
|
_require_selector(
|
|
@@ -849,7 +952,8 @@ class AppFormNormalizer:
|
|
|
849
952
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) else None,
|
|
850
953
|
}
|
|
851
954
|
if supports_reference_default_formula:
|
|
852
|
-
|
|
955
|
+
formula = reference.get("defaultValueFormula")
|
|
956
|
+
relation_config["defaultValueFormula"] = formula if not isinstance(formula, str) or formula.strip() else None
|
|
853
957
|
field["config"] = _without_none(relation_config)
|
|
854
958
|
elif field_type == "q_linker":
|
|
855
959
|
field["config"] = {"qLinkerBinding": self._q_linker_binding(question, relations, field_names)}
|
|
@@ -858,34 +962,54 @@ class AppFormNormalizer:
|
|
|
858
962
|
elif field_type == "data_relation":
|
|
859
963
|
field["config"] = self._data_relation_config(question, relations)
|
|
860
964
|
elif field_type == "subtable":
|
|
861
|
-
|
|
965
|
+
subfields = [
|
|
862
966
|
self._field(item, relations, select_scope_relations, field_names, supports_reference_default_formula)
|
|
863
967
|
for item in question.get("subQuestions") or []
|
|
864
968
|
if isinstance(item, dict)
|
|
865
969
|
]
|
|
970
|
+
if not subfields:
|
|
971
|
+
raise AppFormFailure(
|
|
972
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
973
|
+
"subtable has no subfields",
|
|
974
|
+
"NORMALIZE",
|
|
975
|
+
details={"queId": que_id, "path": "/subfields"},
|
|
976
|
+
)
|
|
977
|
+
field["subfields"] = subfields
|
|
866
978
|
for subfield in field["subfields"]:
|
|
867
979
|
subfield.pop("width", None)
|
|
980
|
+
row_height = question.get("tableRowHeight")
|
|
981
|
+
if isinstance(row_height, str) and not row_height.strip():
|
|
982
|
+
row_height = None
|
|
868
983
|
field["config"] = _without_none({
|
|
869
984
|
"tableRowsLimit": question.get("tableRowsLimit"),
|
|
870
985
|
"tableNoRepeatStatus": question.get("tableNoRepeatStatus"),
|
|
871
986
|
"tableNoRepeatQuestion": [_selector(item, field_names.get(_positive_int(item))) for item in question.get("tableNoRepeatQuestion") or [] if _positive_int(item) is not None],
|
|
872
|
-
"tableRowHeight":
|
|
987
|
+
"tableRowHeight": row_height,
|
|
873
988
|
"defaultValues": question.get("queDefaultValues") if isinstance(question.get("queDefaultValues"), dict) and (question.get("queDefaultValues", {}).get("values") or question.get("queDefaultValues", {}).get("tableValues")) else None,
|
|
874
989
|
})
|
|
875
990
|
return field
|
|
876
991
|
|
|
877
992
|
@staticmethod
|
|
878
993
|
def _attachments(value: Any) -> list[JSONObject]:
|
|
994
|
+
def attachment_text(item: JSONObject, *keys: str) -> Any:
|
|
995
|
+
for key in keys:
|
|
996
|
+
candidate = item.get(key)
|
|
997
|
+
if candidate:
|
|
998
|
+
return candidate
|
|
999
|
+
return None
|
|
1000
|
+
|
|
879
1001
|
result = [
|
|
880
1002
|
_without_none(
|
|
881
1003
|
{
|
|
882
1004
|
"attachId": _positive_int(item.get("attachId") or item.get("id")),
|
|
883
|
-
"fileName": item
|
|
884
|
-
"fileUrl": item
|
|
1005
|
+
"fileName": attachment_text(item, "fileName", "name"),
|
|
1006
|
+
"fileUrl": attachment_text(item, "fileUrl", "url"),
|
|
885
1007
|
}
|
|
886
1008
|
)
|
|
887
1009
|
for item in value or []
|
|
888
|
-
if isinstance(item, dict)
|
|
1010
|
+
if isinstance(item, dict)
|
|
1011
|
+
and str(attachment_text(item, "fileName", "name") or "").strip()
|
|
1012
|
+
and str(attachment_text(item, "fileUrl", "url") or "").strip()
|
|
889
1013
|
]
|
|
890
1014
|
return sorted(result, key=lambda item: (item.get("attachId", 0), item["fileName"], item["fileUrl"]))
|
|
891
1015
|
|
|
@@ -907,12 +1031,28 @@ class AppFormNormalizer:
|
|
|
907
1031
|
elif kind == "submit_datetime":
|
|
908
1032
|
component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
|
|
909
1033
|
elif kind == "fixed_text":
|
|
910
|
-
component["value"] =
|
|
1034
|
+
component["value"] = _required_text(
|
|
1035
|
+
item.get("customTitle"),
|
|
1036
|
+
message="custom serial number fixed text is empty",
|
|
1037
|
+
details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/value"},
|
|
1038
|
+
)
|
|
911
1039
|
else:
|
|
912
|
-
component["field"] =
|
|
1040
|
+
component["field"] = _require_selector(
|
|
1041
|
+
item.get("queId"),
|
|
1042
|
+
item.get("queTitle"),
|
|
1043
|
+
message="custom serial number field cannot be normalized",
|
|
1044
|
+
details={"componentId": _positive_int(item.get("id")), "path": "/spec/serialNumber/components/field"},
|
|
1045
|
+
)
|
|
913
1046
|
if item.get("dateFormat") is not None:
|
|
914
1047
|
component["dateFormat"] = date_names.get(item.get("dateFormat"), "year_month_day")
|
|
915
1048
|
components.append(component)
|
|
1049
|
+
if not components:
|
|
1050
|
+
raise AppFormFailure(
|
|
1051
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
1052
|
+
"custom serial number has no components",
|
|
1053
|
+
"NORMALIZE",
|
|
1054
|
+
details={"path": "/spec/serialNumber/components"},
|
|
1055
|
+
)
|
|
916
1056
|
return {"mode": "custom", "components": components}
|
|
917
1057
|
|
|
918
1058
|
@staticmethod
|
|
@@ -926,9 +1066,11 @@ class AppFormNormalizer:
|
|
|
926
1066
|
for inner in item.get("innerQuestions") or []:
|
|
927
1067
|
for nested in inner if isinstance(inner, list) else []:
|
|
928
1068
|
if isinstance(nested, dict) and _positive_int(nested.get("queId")):
|
|
929
|
-
|
|
1069
|
+
nested_id = _positive_int(nested.get("queId")) or 0
|
|
1070
|
+
result[nested_id] = _compat_label(nested.get("queTitle"), "Field", nested_id)
|
|
930
1071
|
elif _positive_int(item.get("queId")):
|
|
931
|
-
|
|
1072
|
+
item_id = _positive_int(item.get("queId")) or 0
|
|
1073
|
+
result[item_id] = _compat_label(item.get("queTitle"), "Field", item_id)
|
|
932
1074
|
return result
|
|
933
1075
|
|
|
934
1076
|
@staticmethod
|
|
@@ -939,7 +1081,16 @@ class AppFormNormalizer:
|
|
|
939
1081
|
for alias in config.get("resultFormatPath") or []:
|
|
940
1082
|
if not isinstance(alias, dict):
|
|
941
1083
|
continue
|
|
942
|
-
name =
|
|
1084
|
+
name = _required_text(
|
|
1085
|
+
alias.get("aliasName"),
|
|
1086
|
+
message="Q-Linker output alias is empty",
|
|
1087
|
+
details={"queId": source_id, "path": "/config/qLinkerBinding/outputs/alias"},
|
|
1088
|
+
)
|
|
1089
|
+
path = _required_text(
|
|
1090
|
+
alias.get("aliasPath"),
|
|
1091
|
+
message="Q-Linker output path is empty",
|
|
1092
|
+
details={"queId": source_id, "alias": name, "path": "/config/qLinkerBinding/outputs/path"},
|
|
1093
|
+
)
|
|
943
1094
|
relation = next(
|
|
944
1095
|
(
|
|
945
1096
|
item
|
|
@@ -953,7 +1104,7 @@ class AppFormNormalizer:
|
|
|
953
1104
|
_without_none(
|
|
954
1105
|
{
|
|
955
1106
|
"alias": name,
|
|
956
|
-
"path":
|
|
1107
|
+
"path": path,
|
|
957
1108
|
"aliasId": _positive_int(alias.get("aliasId")) or _positive_int((relation.get("aliasConfig") or {}).get("aliasId")),
|
|
958
1109
|
"targetField": _require_selector(
|
|
959
1110
|
relation.get("queId"),
|
|
@@ -973,15 +1124,30 @@ class AppFormNormalizer:
|
|
|
973
1124
|
continue
|
|
974
1125
|
if _positive_int(item.get("type")) == 2:
|
|
975
1126
|
input_que_id = _positive_int(item.get("value"))
|
|
1127
|
+
input_key = _required_text(
|
|
1128
|
+
item.get("key"),
|
|
1129
|
+
message="Q-Linker input key is empty",
|
|
1130
|
+
details={"queId": source_id, "source": source, "path": "/config/qLinkerBinding/inputs/key"},
|
|
1131
|
+
)
|
|
976
1132
|
inputs.append(
|
|
977
1133
|
{
|
|
978
|
-
"field":
|
|
979
|
-
|
|
1134
|
+
"field": _require_selector(
|
|
1135
|
+
input_que_id,
|
|
1136
|
+
field_names.get(input_que_id or -1),
|
|
1137
|
+
message="Q-Linker input field cannot be normalized",
|
|
1138
|
+
details={"queId": source_id, "key": input_key},
|
|
1139
|
+
),
|
|
1140
|
+
"key": input_key,
|
|
980
1141
|
"source": source,
|
|
981
1142
|
}
|
|
982
1143
|
)
|
|
983
1144
|
else:
|
|
984
|
-
|
|
1145
|
+
static_key = _required_text(
|
|
1146
|
+
item.get("key"),
|
|
1147
|
+
message="Q-Linker request key is empty",
|
|
1148
|
+
details={"queId": source_id, "source": source, "path": f"/config/qLinkerBinding/request/{source}/key"},
|
|
1149
|
+
)
|
|
1150
|
+
static[key].append({"key": static_key, "value": item.get("value")})
|
|
985
1151
|
request_values = {
|
|
986
1152
|
"url": config.get("url"),
|
|
987
1153
|
"jsonValue": config.get("jsonValue"),
|
|
@@ -1013,9 +1179,14 @@ class AppFormNormalizer:
|
|
|
1013
1179
|
|
|
1014
1180
|
request_values[request_key] = Q_LINKER_VALUE_REFERENCE_RE.sub(replace_reference, raw_value)
|
|
1015
1181
|
inputs.extend(json_path_inputs.values())
|
|
1182
|
+
request_url = _required_text(
|
|
1183
|
+
request_values["url"],
|
|
1184
|
+
message="Q-Linker request URL is empty",
|
|
1185
|
+
details={"queId": source_id, "path": "/config/qLinkerBinding/request/url"},
|
|
1186
|
+
)
|
|
1016
1187
|
request = _without_none(
|
|
1017
1188
|
{
|
|
1018
|
-
"url":
|
|
1189
|
+
"url": request_url,
|
|
1019
1190
|
"method": config.get("method") or "GET",
|
|
1020
1191
|
**static,
|
|
1021
1192
|
"bodyType": {1: "url_encoded", 2: "json", 3: "xml"}.get(_positive_int(config.get("bodyType")) or 1, "url_encoded"),
|
|
@@ -1029,6 +1200,13 @@ class AppFormNormalizer:
|
|
|
1029
1200
|
"hideOnForm": bool(config.get("beingHideOnForm")),
|
|
1030
1201
|
}
|
|
1031
1202
|
)
|
|
1203
|
+
if not outputs:
|
|
1204
|
+
raise AppFormFailure(
|
|
1205
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
1206
|
+
"Q-Linker has no output mapping",
|
|
1207
|
+
"NORMALIZE",
|
|
1208
|
+
details={"queId": source_id, "path": "/config/qLinkerBinding/outputs"},
|
|
1209
|
+
)
|
|
1032
1210
|
return {"inputs": inputs, "request": request, "outputs": outputs}
|
|
1033
1211
|
|
|
1034
1212
|
@staticmethod
|
|
@@ -1039,7 +1217,16 @@ class AppFormNormalizer:
|
|
|
1039
1217
|
for alias in config.get("resultAliasPath") or []:
|
|
1040
1218
|
if not isinstance(alias, dict):
|
|
1041
1219
|
continue
|
|
1042
|
-
name =
|
|
1220
|
+
name = _required_text(
|
|
1221
|
+
alias.get("aliasName"),
|
|
1222
|
+
message="code block output alias is empty",
|
|
1223
|
+
details={"queId": source_id, "path": "/config/codeBlockBinding/outputs/alias"},
|
|
1224
|
+
)
|
|
1225
|
+
path = _required_text(
|
|
1226
|
+
alias.get("aliasPath"),
|
|
1227
|
+
message="code block output path is empty",
|
|
1228
|
+
details={"queId": source_id, "alias": name, "path": "/config/codeBlockBinding/outputs/path"},
|
|
1229
|
+
)
|
|
1043
1230
|
relation = next(
|
|
1044
1231
|
(
|
|
1045
1232
|
item
|
|
@@ -1051,7 +1238,7 @@ class AppFormNormalizer:
|
|
|
1051
1238
|
)
|
|
1052
1239
|
output = {
|
|
1053
1240
|
"alias": name,
|
|
1054
|
-
"path":
|
|
1241
|
+
"path": path,
|
|
1055
1242
|
"targetField": _require_selector(
|
|
1056
1243
|
relation.get("queId"),
|
|
1057
1244
|
relation.get("queTitle") or field_names.get(_positive_int(relation.get("queId")) or -1),
|
|
@@ -1069,11 +1256,29 @@ class AppFormNormalizer:
|
|
|
1069
1256
|
line = lines.pop(0).strip()
|
|
1070
1257
|
var = line[6:].split("=", 1)[0].strip()
|
|
1071
1258
|
name = line.split("qf_field.{", 1)[1].split("$$", 1)[0]
|
|
1072
|
-
|
|
1259
|
+
input_name = _required_text(
|
|
1260
|
+
name,
|
|
1261
|
+
message="code block input field is empty",
|
|
1262
|
+
details={"queId": source_id, "path": "/config/codeBlockBinding/inputs/field"},
|
|
1263
|
+
)
|
|
1264
|
+
inputs.append(
|
|
1265
|
+
{
|
|
1266
|
+
"field": {"name": input_name},
|
|
1267
|
+
"var": var,
|
|
1268
|
+
}
|
|
1269
|
+
)
|
|
1270
|
+
code = "\n".join(lines).lstrip("\n")
|
|
1271
|
+
if not code.strip():
|
|
1272
|
+
raise AppFormFailure(
|
|
1273
|
+
"UNSUPPORTED_FIELD_CONFIG",
|
|
1274
|
+
"code block has no code",
|
|
1275
|
+
"NORMALIZE",
|
|
1276
|
+
details={"queId": source_id, "path": "/config/codeBlockBinding/code"},
|
|
1277
|
+
)
|
|
1073
1278
|
return _without_none(
|
|
1074
1279
|
{
|
|
1075
1280
|
"inputs": inputs,
|
|
1076
|
-
"code":
|
|
1281
|
+
"code": code,
|
|
1077
1282
|
"autoTrigger": question.get("autoTrigger"),
|
|
1078
1283
|
"customButtonTextEnabled": question.get("customBtnTextStatus"),
|
|
1079
1284
|
"customButtonText": question.get("customBtnText"),
|
|
@@ -1092,7 +1297,8 @@ class AppFormNormalizer:
|
|
|
1092
1297
|
displayed = relation.get("displayedQueInfo") if isinstance(relation.get("displayedQueInfo"), dict) else {}
|
|
1093
1298
|
alias = relation.get("aliasConfig") if isinstance(relation.get("aliasConfig"), dict) else {}
|
|
1094
1299
|
if relation_type == 1:
|
|
1095
|
-
|
|
1300
|
+
source_app_key = str(displayed.get("appKey") or "")
|
|
1301
|
+
if not source_app_key.strip():
|
|
1096
1302
|
raise AppFormFailure(
|
|
1097
1303
|
"UNSUPPORTED_FIELD_CONFIG",
|
|
1098
1304
|
"data_relation source app cannot be normalized",
|
|
@@ -1101,7 +1307,7 @@ class AppFormNormalizer:
|
|
|
1101
1307
|
)
|
|
1102
1308
|
source = {
|
|
1103
1309
|
"kind": "app",
|
|
1104
|
-
"appKey":
|
|
1310
|
+
"appKey": source_app_key,
|
|
1105
1311
|
"field": _require_selector(
|
|
1106
1312
|
relation.get("displayedQueId"),
|
|
1107
1313
|
displayed.get("queTitle"),
|
|
@@ -1143,7 +1349,9 @@ class AppFormNormalizer:
|
|
|
1143
1349
|
for group in relation.get("matchRules") or []:
|
|
1144
1350
|
if not isinstance(group, list):
|
|
1145
1351
|
raise AppFormFailure("UNSUPPORTED_FIELD_CONFIG", "data_relation match rules are malformed", "NORMALIZE", details={"queId": que_id})
|
|
1146
|
-
|
|
1352
|
+
conditions = [AppFormNormalizer._data_relation_condition(item, que_id=que_id) for item in group if isinstance(item, dict)]
|
|
1353
|
+
if conditions:
|
|
1354
|
+
groups.append(conditions)
|
|
1147
1355
|
result["filters"] = {"mode": "simple", "groups": groups}
|
|
1148
1356
|
sort = relation.get("sortConfig") if isinstance(relation.get("sortConfig"), dict) else None
|
|
1149
1357
|
if sort is not None:
|
|
@@ -2173,6 +2381,11 @@ class AppFormCompiler:
|
|
|
2173
2381
|
"queHint": field.get("questionDescription") or "",
|
|
2174
2382
|
}
|
|
2175
2383
|
)
|
|
2384
|
+
if _positive_int((template or {}).get("queType")) == 1 and field_type == "long_text":
|
|
2385
|
+
# queType=1 is a non-data description control. Keep its backend
|
|
2386
|
+
# type and template-only settings while allowing the declaration
|
|
2387
|
+
# to carry its stable identity and default content.
|
|
2388
|
+
question["queType"] = 1
|
|
2176
2389
|
config = field.get("config") if isinstance(field.get("config"), dict) else {}
|
|
2177
2390
|
if field_type in {"text", "long_text", "link", "phone", "email"}:
|
|
2178
2391
|
for key in ("minWordCount", "maxWordCount", "noRepeat", "canScan", "scanType"):
|