@josephyan/qingflow-cli 1.1.4 → 1.1.5
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 +7 -3
- package/docs/local-agent-install.md +57 -6
- package/entry_point.py +1 -1
- package/npm/bin/qingflow-skills.mjs +5 -0
- package/npm/bin/qingflow.mjs +1 -34
- package/npm/lib/runtime.mjs +21 -101
- package/npm/scripts/postinstall.mjs +1 -10
- package/package.json +3 -2
- package/pyproject.toml +1 -1
- package/skills/qingflow-cli/SKILL.md +58 -44
- package/skills/qingflow-cli/manifest.yaml +1 -1
- package/skills/qingflow-cli/reference/00-INDEX.md +35 -0
- package/skills/qingflow-cli/reference/builder/10-build-single-app.md +38 -0
- package/skills/qingflow-cli/reference/builder/20-build-complete-system.md +39 -0
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_SCHEMA_APPLY_FIELD_TYPES_AND_SCENARIOS.md → builder/30-schema-fields.md} +52 -10
- package/skills/qingflow-cli/reference/builder/40-layout.md +52 -0
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_VIEWS_WORKFLOW.md → builder/50-views.md} +39 -15
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_CHARTS_WORKFLOW.md → builder/60-charts.md} +36 -13
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_PORTAL_WORKFLOW.md → builder/70-portal.md} +36 -13
- package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +41 -0
- package/skills/qingflow-cli/reference/builder/90-workflow.md +34 -0
- package/skills/qingflow-cli/reference/builder/99-publish-verify.md +46 -0
- package/skills/qingflow-cli/reference/builder/README.md +41 -0
- package/skills/qingflow-cli/reference/builder/code-integrations/README.md +130 -0
- package/skills/qingflow-cli/reference/builder/code-integrations/code-block.md +66 -0
- package/skills/qingflow-cli/reference/builder/code-integrations/q-linker.md +77 -0
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_APP_DELIVERY_WORKFLOW.md → builder/reference/app-delivery-sop.md} +26 -16
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/README.md +293 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/build-complete-system.md +809 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/build-single-app.md +830 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/complete-system-development-guide.md +123 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/create-app.md +182 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/environments.md +63 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/flow-actors-and-permissions.md +142 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/gotchas.md +108 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/match-rules.md +114 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/public-surface-sync.md +75 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/single-app-development-guide.md +58 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/solution-playbooks.md +52 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/tool-selection.md +107 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-flow.md +7 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-layout.md +7 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-schema.md +7 -0
- package/skills/qingflow-cli/reference/builder/reference/legacy-playbooks/update-views.md +7 -0
- package/skills/qingflow-cli/reference/builder/workflow/01-overview.md +45 -0
- package/skills/qingflow-cli/reference/builder/workflow/02-update-mode.md +53 -0
- package/skills/qingflow-cli/reference/builder/workflow/03-flow-patterns.md +57 -0
- package/skills/qingflow-cli/reference/builder/workflow/04-stage1-business-modeling.md +131 -0
- package/skills/qingflow-cli/reference/builder/workflow/05-stage2-members-roles.md +29 -0
- package/skills/qingflow-cli/reference/builder/workflow/06-stage3-build-spec.md +165 -0
- package/skills/qingflow-cli/reference/builder/workflow/07-stage4-validate-spec.md +33 -0
- package/skills/qingflow-cli/reference/builder/workflow/08-stage5-apply-verify.md +51 -0
- package/skills/qingflow-cli/reference/builder/workflow/09-stage6-summary.md +88 -0
- package/skills/qingflow-cli/reference/builder/workflow/10-node-config-reference.md +93 -0
- package/skills/qingflow-cli/reference/builder/workflow/11-troubleshooting.md +15 -0
- package/skills/qingflow-cli/reference/builder/workflow/README.md +88 -0
- package/skills/qingflow-cli/reference/builder/workflow/workflow-schema.json +1754 -0
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_ADMIN_CHEATSHEET.md → core/QINGFLOW_CLI_ADMIN_CHEATSHEET.md} +3 -3
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md → core/QINGFLOW_CLI_DATA_RETRIEVAL_WORKFLOW.md} +6 -6
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_EXPLORATION_REPORT.md → core/QINGFLOW_CLI_EXPLORATION_REPORT.md} +2 -2
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_FIELD_DATA_TYPES.md → core/QINGFLOW_CLI_FIELD_DATA_TYPES.md} +11 -11
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_MEMBER_CHEATSHEET.md → core/QINGFLOW_CLI_MEMBER_CHEATSHEET.md} +4 -4
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md → core/QINGFLOW_CLI_ONE_SHOT_CHEATSHEET.md} +4 -4
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md → record/QINGFLOW_CLI_RECORD_CREATE_WORKFLOW.md} +3 -3
- package/skills/qingflow-cli/reference/record/QINGFLOW_CLI_RECORD_DELETE_WORKFLOW.md +31 -0
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md → record/QINGFLOW_CLI_RECORD_IMPORT_WORKFLOW.md} +4 -4
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md → record/QINGFLOW_CLI_RECORD_UPDATE_WORKFLOW.md} +7 -7
- package/skills/qingflow-cli/reference/record/analysis/README.md +130 -0
- package/skills/qingflow-cli/reference/record/analysis/analysis-gotchas.md +91 -0
- package/skills/qingflow-cli/reference/record/analysis/analysis-patterns.md +112 -0
- package/skills/qingflow-cli/reference/record/analysis/business-context.md +74 -0
- package/skills/qingflow-cli/reference/record/analysis/confidence-reporting.md +69 -0
- package/skills/qingflow-cli/reference/record/analysis/data-access-playbook.md +106 -0
- package/skills/qingflow-cli/reference/record/analysis/pandas-recipes.md +172 -0
- package/skills/qingflow-cli/reference/record/analysis/report-format.md +76 -0
- package/skills/qingflow-cli/reference/record/insert/README.md +75 -0
- package/skills/qingflow-cli/reference/{QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md → task/QINGFLOW_CLI_TASK_CONTEXT_WORKFLOW.md} +5 -5
- package/skills/qingflow-cli/reference/task/ops/README.md +131 -0
- package/skills/qingflow-cli/reference/task/ops/environments.md +43 -0
- package/skills/qingflow-cli/reference/task/ops/workflow-usage.md +26 -0
- package/skills/qingflow-cli/scripts/validate_system_build_summary.py +124 -0
- package/skills/qingflow-cli/scripts/workflow/diff_flow_spec.py +275 -0
- package/skills/qingflow-cli/scripts/workflow/validate_flow_spec.py +605 -0
- package/skills/qingflow-mcp-setup/SKILL.md +115 -0
- package/skills/qingflow-mcp-setup/agents/openai.yaml +4 -0
- package/skills/qingflow-mcp-setup/references/claude-desktop.md +34 -0
- package/skills/qingflow-mcp-setup/references/environments.md +62 -0
- package/skills/qingflow-mcp-setup/references/generic-stdio.md +32 -0
- package/skills/qingflow-mcp-setup/scripts/check_local_server.sh +38 -0
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/__main__.py +6 -2
- package/src/qingflow_mcp/builder_facade/models.py +282 -102
- package/src/qingflow_mcp/builder_facade/service.py +4166 -929
- package/src/qingflow_mcp/cli/commands/builder.py +316 -298
- package/src/qingflow_mcp/cli/commands/chart.py +1 -1
- package/src/qingflow_mcp/cli/commands/common.py +12 -3
- package/src/qingflow_mcp/cli/commands/exports.py +2 -2
- package/src/qingflow_mcp/cli/commands/imports.py +3 -3
- package/src/qingflow_mcp/cli/commands/portal.py +2 -2
- package/src/qingflow_mcp/cli/commands/record.py +101 -27
- package/src/qingflow_mcp/cli/commands/task.py +28 -47
- package/src/qingflow_mcp/cli/commands/view.py +1 -1
- package/src/qingflow_mcp/cli/context.py +0 -3
- package/src/qingflow_mcp/cli/formatters.py +784 -16
- package/src/qingflow_mcp/cli/main.py +117 -33
- package/src/qingflow_mcp/errors.py +43 -2
- package/src/qingflow_mcp/public_surface.py +26 -17
- package/src/qingflow_mcp/response_trim.py +81 -17
- package/src/qingflow_mcp/server.py +14 -12
- package/src/qingflow_mcp/server_app_builder.py +65 -21
- package/src/qingflow_mcp/server_app_user.py +22 -16
- package/src/qingflow_mcp/session_store.py +11 -7
- package/src/qingflow_mcp/solution/compiler/__init__.py +3 -1
- package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
- package/src/qingflow_mcp/solution/executor.py +245 -18
- package/src/qingflow_mcp/tools/ai_builder_tools.py +1780 -406
- package/src/qingflow_mcp/tools/app_tools.py +184 -43
- package/src/qingflow_mcp/tools/approval_tools.py +197 -35
- package/src/qingflow_mcp/tools/auth_tools.py +92 -16
- package/src/qingflow_mcp/tools/code_block_tools.py +298 -40
- package/src/qingflow_mcp/tools/custom_button_tools.py +64 -10
- package/src/qingflow_mcp/tools/directory_tools.py +236 -72
- package/src/qingflow_mcp/tools/export_tools.py +244 -34
- package/src/qingflow_mcp/tools/feedback_tools.py +9 -0
- package/src/qingflow_mcp/tools/file_tools.py +9 -3
- package/src/qingflow_mcp/tools/import_tools.py +336 -49
- package/src/qingflow_mcp/tools/navigation_tools.py +91 -12
- package/src/qingflow_mcp/tools/package_tools.py +118 -6
- package/src/qingflow_mcp/tools/portal_tools.py +39 -3
- package/src/qingflow_mcp/tools/qingbi_report_tools.py +116 -7
- package/src/qingflow_mcp/tools/record_tools.py +1141 -356
- package/src/qingflow_mcp/tools/resource_read_tools.py +188 -39
- package/src/qingflow_mcp/tools/role_tools.py +80 -9
- package/src/qingflow_mcp/tools/solution_tools.py +59 -45
- package/src/qingflow_mcp/tools/task_context_tools.py +662 -158
- package/src/qingflow_mcp/tools/task_tools.py +113 -29
- package/src/qingflow_mcp/tools/view_tools.py +106 -3
- package/src/qingflow_mcp/tools/workflow_tools.py +48 -4
- package/src/qingflow_mcp/tools/workspace_tools.py +71 -3
- /package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_MATCH_RULES.md → builder/reference/match-rules.md} +0 -0
- /package/skills/qingflow-cli/reference/{QINGFLOW_CLI_BUILDER_WORKSPACE_ICONS.md → builder/reference/workspace-icons.md} +0 -0
- /package/skills/qingflow-cli/reference/{charts_remove.example.json → examples/charts/charts_remove.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{charts_reorder.example.json → examples/charts/charts_reorder.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{charts_upsert_bar.example.json → examples/charts/charts_upsert_bar.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{charts_upsert_dashboard_starter.example.json → examples/charts/charts_upsert_dashboard_starter.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{charts_upsert_minimal.example.json → examples/charts/charts_upsert_minimal.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{portal_sections_all_types.example.json → examples/portal/portal_sections_all_types.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{portal_sections_five_types.example.json → examples/portal/portal_sections_five_types.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{portal_sections_standard_workbench.example.json → examples/portal/portal_sections_standard_workbench.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{_batch_schema_complex.json → examples/schema/_batch_schema_complex.json} +0 -0
- /package/skills/qingflow-cli/reference/{_batch_schema_scalar.json → examples/schema/_batch_schema_scalar.json} +0 -0
- /package/skills/qingflow-cli/reference/{schema_add_fields_minimal.example.json → examples/schema/schema_add_fields_minimal.example.json} +0 -0
- /package/skills/qingflow-cli/reference/{schema_apply_add_fields_all_types.json → examples/schema/schema_apply_add_fields_all_types.json} +0 -0
- /package/skills/qingflow-cli/reference/{views_upsert_table_minimal.example.json → examples/views/views_upsert_table_minimal.example.json} +0 -0
|
@@ -71,17 +71,36 @@ class PublicExternalVisibilityMode(str, Enum):
|
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
FIELD_TYPE_ALIASES: dict[str, PublicFieldType] = {
|
|
74
|
+
"multiline": PublicFieldType.long_text,
|
|
75
|
+
"multiline_text": PublicFieldType.long_text,
|
|
76
|
+
"multi_line": PublicFieldType.long_text,
|
|
77
|
+
"multi_line_text": PublicFieldType.long_text,
|
|
74
78
|
"textarea": PublicFieldType.long_text,
|
|
79
|
+
"longtext": PublicFieldType.long_text,
|
|
80
|
+
"long-text": PublicFieldType.long_text,
|
|
75
81
|
"amount": PublicFieldType.amount,
|
|
76
82
|
"currency": PublicFieldType.amount,
|
|
77
83
|
"mobile": PublicFieldType.phone,
|
|
78
84
|
"user": PublicFieldType.member,
|
|
79
85
|
"users": PublicFieldType.member,
|
|
80
86
|
"select": PublicFieldType.single_select,
|
|
87
|
+
"single_choice": PublicFieldType.single_select,
|
|
88
|
+
"single-choice": PublicFieldType.single_select,
|
|
89
|
+
"single choice": PublicFieldType.single_select,
|
|
90
|
+
"choice": PublicFieldType.single_select,
|
|
91
|
+
"dropdown": PublicFieldType.single_select,
|
|
81
92
|
"radio": PublicFieldType.single_select,
|
|
82
93
|
"checkbox": PublicFieldType.multi_select,
|
|
83
94
|
"multi_select": PublicFieldType.multi_select,
|
|
84
95
|
"multi-select": PublicFieldType.multi_select,
|
|
96
|
+
"multi select": PublicFieldType.multi_select,
|
|
97
|
+
"multiselect": PublicFieldType.multi_select,
|
|
98
|
+
"multi_choice": PublicFieldType.multi_select,
|
|
99
|
+
"multi-choice": PublicFieldType.multi_select,
|
|
100
|
+
"multi choice": PublicFieldType.multi_select,
|
|
101
|
+
"multiple_choice": PublicFieldType.multi_select,
|
|
102
|
+
"multiple-choice": PublicFieldType.multi_select,
|
|
103
|
+
"multiple choice": PublicFieldType.multi_select,
|
|
85
104
|
"departments": PublicFieldType.department,
|
|
86
105
|
"qlinker": PublicFieldType.q_linker,
|
|
87
106
|
"q_linker": PublicFieldType.q_linker,
|
|
@@ -184,6 +203,10 @@ class LayoutPreset(str, Enum):
|
|
|
184
203
|
single_section = "single_section"
|
|
185
204
|
|
|
186
205
|
|
|
206
|
+
class FlowPreset(str, Enum):
|
|
207
|
+
basic_approval = "basic_approval"
|
|
208
|
+
basic_fill_then_approve = "basic_fill_then_approve"
|
|
209
|
+
|
|
187
210
|
|
|
188
211
|
class ViewsPreset(str, Enum):
|
|
189
212
|
default_table = "default_table"
|
|
@@ -1256,11 +1279,30 @@ class CustomButtonFieldMappingPatch(StrictModel):
|
|
|
1256
1279
|
|
|
1257
1280
|
|
|
1258
1281
|
class FieldMatchMappingPatch(StrictModel):
|
|
1259
|
-
target_field: Any = Field(
|
|
1282
|
+
target_field: Any = Field(
|
|
1283
|
+
validation_alias=AliasChoices("target_field", "targetField", "target", "field", "field_name", "fieldName")
|
|
1284
|
+
)
|
|
1260
1285
|
source_field: Any | None = Field(default=None, validation_alias=AliasChoices("source_field", "sourceField", "source"))
|
|
1261
1286
|
value: Any | None = Field(default=None, validation_alias=AliasChoices("value", "static_value", "staticValue"))
|
|
1262
1287
|
operator: str = Field(default="eq", validation_alias=AliasChoices("operator", "op", "judge_type", "judgeType"))
|
|
1263
1288
|
|
|
1289
|
+
@model_validator(mode="before")
|
|
1290
|
+
@classmethod
|
|
1291
|
+
def normalize_semantic_aliases(cls, value: Any) -> Any:
|
|
1292
|
+
if not isinstance(value, dict):
|
|
1293
|
+
return value
|
|
1294
|
+
payload = dict(value)
|
|
1295
|
+
has_static_value = any(key in payload for key in ("value", "static_value", "staticValue"))
|
|
1296
|
+
if "values" in payload:
|
|
1297
|
+
values = payload.pop("values")
|
|
1298
|
+
if has_static_value:
|
|
1299
|
+
return payload
|
|
1300
|
+
if isinstance(values, list) and len(values) == 1:
|
|
1301
|
+
payload["value"] = values[0]
|
|
1302
|
+
else:
|
|
1303
|
+
payload["value"] = values
|
|
1304
|
+
return payload
|
|
1305
|
+
|
|
1264
1306
|
@model_validator(mode="after")
|
|
1265
1307
|
def validate_shape(self) -> "FieldMatchMappingPatch":
|
|
1266
1308
|
has_source = self.source_field is not None and str(self.source_field).strip() != ""
|
|
@@ -1607,6 +1649,7 @@ class AssociatedResourcesApplyRequest(StrictModel):
|
|
|
1607
1649
|
if not isinstance(value, dict):
|
|
1608
1650
|
return value
|
|
1609
1651
|
payload = dict(value)
|
|
1652
|
+
default_target_app_key = str(payload.get("app_key", payload.get("appKey", "")) or "").strip()
|
|
1610
1653
|
if "upsertResources" in payload and "upsert_resources" not in payload:
|
|
1611
1654
|
payload["upsert_resources"] = payload.pop("upsertResources")
|
|
1612
1655
|
if "patchResources" in payload and "patch_resources" not in payload:
|
|
@@ -1619,6 +1662,16 @@ class AssociatedResourcesApplyRequest(StrictModel):
|
|
|
1619
1662
|
payload["reorder_associated_item_ids"] = payload.pop("reorderAssociatedItemIds")
|
|
1620
1663
|
if "viewConfigs" in payload and "view_configs" not in payload:
|
|
1621
1664
|
payload["view_configs"] = payload.pop("viewConfigs")
|
|
1665
|
+
if default_target_app_key and isinstance(payload.get("upsert_resources"), list):
|
|
1666
|
+
normalized_resources = []
|
|
1667
|
+
for item in payload["upsert_resources"]:
|
|
1668
|
+
if isinstance(item, dict) and not any(
|
|
1669
|
+
str(item.get(key) or "").strip()
|
|
1670
|
+
for key in ("target_app_key", "targetAppKey", "app_key", "appKey")
|
|
1671
|
+
):
|
|
1672
|
+
item = {**item, "target_app_key": default_target_app_key}
|
|
1673
|
+
normalized_resources.append(item)
|
|
1674
|
+
payload["upsert_resources"] = normalized_resources
|
|
1622
1675
|
return payload
|
|
1623
1676
|
|
|
1624
1677
|
@model_validator(mode="after")
|
|
@@ -1763,68 +1816,63 @@ class ChartFilterRulePatch(StrictModel):
|
|
|
1763
1816
|
return self
|
|
1764
1817
|
|
|
1765
1818
|
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
if value is None:
|
|
1771
|
-
return []
|
|
1772
|
-
if isinstance(value, (list, tuple)):
|
|
1773
|
-
return [str(item).strip() for item in value if item is not None and str(item).strip()]
|
|
1774
|
-
raw = str(value).strip()
|
|
1775
|
-
return [raw] if raw else []
|
|
1819
|
+
class ChartMetricPatch(StrictModel):
|
|
1820
|
+
op: str = "count"
|
|
1821
|
+
field_name: str | None = Field(default=None, validation_alias=AliasChoices("field", "field_name", "fieldName", "name"))
|
|
1822
|
+
alias: str | None = None
|
|
1776
1823
|
|
|
1824
|
+
@model_validator(mode="before")
|
|
1825
|
+
@classmethod
|
|
1826
|
+
def normalize_metric(cls, value: Any) -> Any:
|
|
1827
|
+
if isinstance(value, str):
|
|
1828
|
+
raw = value.strip()
|
|
1829
|
+
match = re.fullmatch(r"([A-Za-z_][A-Za-z0-9_]*)\s*\(\s*(.*?)\s*\)", raw)
|
|
1830
|
+
if match:
|
|
1831
|
+
op = match.group(1).strip().lower()
|
|
1832
|
+
field = match.group(2).strip()
|
|
1833
|
+
payload: dict[str, Any] = {"op": op}
|
|
1834
|
+
if field and field != "*":
|
|
1835
|
+
payload["field_name"] = field
|
|
1836
|
+
return payload
|
|
1837
|
+
if raw:
|
|
1838
|
+
return {"op": "sum", "field_name": raw}
|
|
1839
|
+
return {"op": "count"}
|
|
1840
|
+
if not isinstance(value, dict):
|
|
1841
|
+
return value
|
|
1842
|
+
payload = dict(value)
|
|
1843
|
+
if "operation" in payload and "op" not in payload:
|
|
1844
|
+
payload["op"] = payload.pop("operation")
|
|
1845
|
+
if "agg" in payload and "op" not in payload:
|
|
1846
|
+
payload["op"] = payload.pop("agg")
|
|
1847
|
+
if "aggregate" in payload and "op" not in payload:
|
|
1848
|
+
payload["op"] = payload.pop("aggregate")
|
|
1849
|
+
if "aggregation" in payload and "op" not in payload:
|
|
1850
|
+
payload["op"] = payload.pop("aggregation")
|
|
1851
|
+
return payload
|
|
1777
1852
|
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
def _apply_semantic_chart_metric_aliases(payload: dict[str, Any]) -> None:
|
|
1802
|
-
raw_metrics: list[Any] = []
|
|
1803
|
-
if "metric" in payload:
|
|
1804
|
-
raw_metrics.append(payload.pop("metric"))
|
|
1805
|
-
if "metrics" in payload:
|
|
1806
|
-
metrics = payload.pop("metrics")
|
|
1807
|
-
raw_metrics.extend(metrics if isinstance(metrics, list) else [metrics])
|
|
1808
|
-
if not raw_metrics:
|
|
1809
|
-
return
|
|
1810
|
-
selectors: list[str] = []
|
|
1811
|
-
aggregates: list[str] = []
|
|
1812
|
-
for metric in raw_metrics:
|
|
1813
|
-
metric_selectors, aggregate = _parse_chart_metric_item(metric)
|
|
1814
|
-
selectors.extend(metric_selectors)
|
|
1815
|
-
if aggregate:
|
|
1816
|
-
aggregates.append(aggregate)
|
|
1817
|
-
if selectors and "indicator_field_ids" not in payload:
|
|
1818
|
-
payload["indicator_field_ids"] = selectors
|
|
1819
|
-
if aggregates:
|
|
1820
|
-
normalized_aggregate = aggregates[0]
|
|
1821
|
-
config = payload.get("config")
|
|
1822
|
-
if not isinstance(config, dict):
|
|
1823
|
-
config = {}
|
|
1824
|
-
else:
|
|
1825
|
-
config = dict(config)
|
|
1826
|
-
config.setdefault("aggregate", normalized_aggregate)
|
|
1827
|
-
payload["config"] = config
|
|
1853
|
+
@model_validator(mode="after")
|
|
1854
|
+
def validate_metric(self) -> "ChartMetricPatch":
|
|
1855
|
+
normalized_op = str(self.op or "count").strip().lower()
|
|
1856
|
+
op_aliases = {
|
|
1857
|
+
"average": "avg",
|
|
1858
|
+
"mean": "avg",
|
|
1859
|
+
"total": "sum",
|
|
1860
|
+
"cnt": "count",
|
|
1861
|
+
"count_all": "count",
|
|
1862
|
+
}
|
|
1863
|
+
self.op = op_aliases.get(normalized_op, normalized_op)
|
|
1864
|
+
if self.field_name is not None:
|
|
1865
|
+
field_name = str(self.field_name).strip()
|
|
1866
|
+
self.field_name = field_name or None
|
|
1867
|
+
if self.alias is not None:
|
|
1868
|
+
alias = str(self.alias).strip()
|
|
1869
|
+
self.alias = alias or None
|
|
1870
|
+
supported = {"count", "sum", "avg", "max", "min"}
|
|
1871
|
+
if self.op not in supported:
|
|
1872
|
+
raise ValueError(f"chart metric op must be one of {sorted(supported)}")
|
|
1873
|
+
if self.op != "count" and not self.field_name:
|
|
1874
|
+
raise ValueError(f"chart metric op '{self.op}' requires field")
|
|
1875
|
+
return self
|
|
1828
1876
|
|
|
1829
1877
|
|
|
1830
1878
|
class ChartUpsertPatch(StrictModel):
|
|
@@ -1834,6 +1882,17 @@ class ChartUpsertPatch(StrictModel):
|
|
|
1834
1882
|
dimension_field_ids: list[str] = Field(default_factory=list)
|
|
1835
1883
|
indicator_field_ids: list[str] = Field(default_factory=list)
|
|
1836
1884
|
filters: list[ChartFilterRulePatch] = Field(default_factory=list)
|
|
1885
|
+
group_by: list[str] = Field(default_factory=list)
|
|
1886
|
+
rows: list[str] = Field(default_factory=list)
|
|
1887
|
+
columns: list[str] = Field(default_factory=list)
|
|
1888
|
+
metric: ChartMetricPatch | None = None
|
|
1889
|
+
metrics: list[ChartMetricPatch] = Field(default_factory=list)
|
|
1890
|
+
x_metric: ChartMetricPatch | None = None
|
|
1891
|
+
y_metric: ChartMetricPatch | None = None
|
|
1892
|
+
left_metric: ChartMetricPatch | None = None
|
|
1893
|
+
right_metric: ChartMetricPatch | None = None
|
|
1894
|
+
value_metric: ChartMetricPatch | None = None
|
|
1895
|
+
target_metric: ChartMetricPatch | None = None
|
|
1837
1896
|
question_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
1838
1897
|
user_config: list[dict[str, Any]] = Field(default_factory=list)
|
|
1839
1898
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -1851,19 +1910,74 @@ class ChartUpsertPatch(StrictModel):
|
|
|
1851
1910
|
payload["chart_type"] = payload.pop("type")
|
|
1852
1911
|
if "dimension_fields" in payload and "dimension_field_ids" not in payload:
|
|
1853
1912
|
payload["dimension_field_ids"] = payload.pop("dimension_fields")
|
|
1913
|
+
if "dimensions" in payload and "dimension_field_ids" not in payload and "group_by" not in payload:
|
|
1914
|
+
payload["group_by"] = payload.pop("dimensions")
|
|
1915
|
+
if "groupBy" in payload and "group_by" not in payload:
|
|
1916
|
+
payload["group_by"] = payload.pop("groupBy")
|
|
1917
|
+
if "where" in payload and "filters" not in payload:
|
|
1918
|
+
payload["filters"] = payload.pop("where")
|
|
1919
|
+
if "filter_rules" in payload and "filters" not in payload:
|
|
1920
|
+
payload["filters"] = payload.pop("filter_rules")
|
|
1921
|
+
if "filterRules" in payload and "filters" not in payload:
|
|
1922
|
+
payload["filters"] = payload.pop("filterRules")
|
|
1854
1923
|
if "indicator_fields" in payload and "indicator_field_ids" not in payload:
|
|
1855
1924
|
payload["indicator_field_ids"] = payload.pop("indicator_fields")
|
|
1856
1925
|
if "metric_field_ids" in payload and "indicator_field_ids" not in payload:
|
|
1857
1926
|
payload["indicator_field_ids"] = payload.pop("metric_field_ids")
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1927
|
+
metric_slots: list[Any] = []
|
|
1928
|
+
generic_metric_keys = ("metric", "metrics")
|
|
1929
|
+
axis_metric_keys = (
|
|
1930
|
+
"x_metric",
|
|
1931
|
+
"xMetric",
|
|
1932
|
+
"y_metric",
|
|
1933
|
+
"yMetric",
|
|
1934
|
+
"left_metric",
|
|
1935
|
+
"leftMetric",
|
|
1936
|
+
"right_metric",
|
|
1937
|
+
"rightMetric",
|
|
1938
|
+
"value_metric",
|
|
1939
|
+
"valueMetric",
|
|
1940
|
+
"target_metric",
|
|
1941
|
+
"targetMetric",
|
|
1942
|
+
)
|
|
1943
|
+
|
|
1944
|
+
def has_metric_value(key: str) -> bool:
|
|
1945
|
+
entry = payload.get(key)
|
|
1946
|
+
return entry is not None and entry != "" and entry != []
|
|
1947
|
+
|
|
1948
|
+
if any(has_metric_value(key) for key in generic_metric_keys) and any(has_metric_value(key) for key in axis_metric_keys):
|
|
1949
|
+
raise ValueError(
|
|
1950
|
+
"chart metric input is ambiguous: use either metric/metrics or axis-specific "
|
|
1951
|
+
"x_metric/y_metric, left_metric/right_metric, value_metric/target_metric, not both"
|
|
1952
|
+
)
|
|
1953
|
+
if "metric" in payload and "metrics" not in payload:
|
|
1954
|
+
payload["metrics"] = [payload["metric"]]
|
|
1955
|
+
for key in ("x_metric", "xMetric", "left_metric", "leftMetric", "value_metric", "valueMetric"):
|
|
1956
|
+
if key in payload:
|
|
1957
|
+
slot_value = payload.get(key)
|
|
1958
|
+
if slot_value is not None:
|
|
1959
|
+
metric_slots.append(slot_value)
|
|
1960
|
+
canonical = {
|
|
1961
|
+
"xMetric": "x_metric",
|
|
1962
|
+
"leftMetric": "left_metric",
|
|
1963
|
+
"valueMetric": "value_metric",
|
|
1964
|
+
}.get(key)
|
|
1965
|
+
if canonical and canonical not in payload:
|
|
1966
|
+
payload[canonical] = payload.pop(key)
|
|
1967
|
+
for key in ("y_metric", "yMetric", "right_metric", "rightMetric", "target_metric", "targetMetric"):
|
|
1968
|
+
if key in payload:
|
|
1969
|
+
slot_value = payload.get(key)
|
|
1970
|
+
if slot_value is not None:
|
|
1971
|
+
metric_slots.append(slot_value)
|
|
1972
|
+
canonical = {
|
|
1973
|
+
"yMetric": "y_metric",
|
|
1974
|
+
"rightMetric": "right_metric",
|
|
1975
|
+
"targetMetric": "target_metric",
|
|
1976
|
+
}.get(key)
|
|
1977
|
+
if canonical and canonical not in payload:
|
|
1978
|
+
payload[canonical] = payload.pop(key)
|
|
1979
|
+
if metric_slots and "metrics" not in payload:
|
|
1980
|
+
payload["metrics"] = metric_slots
|
|
1867
1981
|
raw_type = payload.get("chart_type")
|
|
1868
1982
|
if isinstance(raw_type, str):
|
|
1869
1983
|
normalized = raw_type.strip().lower()
|
|
@@ -1890,8 +2004,31 @@ class ChartUpsertPatch(StrictModel):
|
|
|
1890
2004
|
payload["dimension_field_ids"] = [str(item) for item in payload["dimension_field_ids"] if item is not None and str(item).strip()]
|
|
1891
2005
|
if isinstance(payload.get("indicator_field_ids"), list):
|
|
1892
2006
|
payload["indicator_field_ids"] = [str(item) for item in payload["indicator_field_ids"] if item is not None and str(item).strip()]
|
|
2007
|
+
for key in ("group_by", "rows", "columns"):
|
|
2008
|
+
if isinstance(payload.get(key), str):
|
|
2009
|
+
payload[key] = [payload[key]]
|
|
2010
|
+
if isinstance(payload.get(key), list):
|
|
2011
|
+
payload[key] = [str(item) for item in payload[key] if item is not None and str(item).strip()]
|
|
1893
2012
|
return payload
|
|
1894
2013
|
|
|
2014
|
+
@model_validator(mode="after")
|
|
2015
|
+
def apply_semantic_chart_fields(self) -> "ChartUpsertPatch":
|
|
2016
|
+
if self.group_by and not self.dimension_field_ids:
|
|
2017
|
+
self.dimension_field_ids = list(self.group_by)
|
|
2018
|
+
if self.rows and not self.dimension_field_ids:
|
|
2019
|
+
self.dimension_field_ids = list(self.rows)
|
|
2020
|
+
semantic_metrics: list[ChartMetricPatch] = []
|
|
2021
|
+
if self.metrics:
|
|
2022
|
+
semantic_metrics.extend(self.metrics)
|
|
2023
|
+
for metric in (self.x_metric, self.y_metric, self.left_metric, self.right_metric, self.value_metric, self.target_metric):
|
|
2024
|
+
if metric is not None and metric not in semantic_metrics:
|
|
2025
|
+
semantic_metrics.append(metric)
|
|
2026
|
+
if semantic_metrics and not self.metrics:
|
|
2027
|
+
self.metrics = semantic_metrics
|
|
2028
|
+
if self.metrics and not self.metric:
|
|
2029
|
+
self.metric = self.metrics[0]
|
|
2030
|
+
return self
|
|
2031
|
+
|
|
1895
2032
|
|
|
1896
2033
|
class ChartPartialPatch(StrictModel):
|
|
1897
2034
|
chart_id: str | None = None
|
|
@@ -2000,35 +2137,14 @@ class PortalComponentPositionPatch(StrictModel):
|
|
|
2000
2137
|
|
|
2001
2138
|
|
|
2002
2139
|
class PortalChartRefPatch(StrictModel):
|
|
2003
|
-
app_key: str
|
|
2140
|
+
app_key: str
|
|
2004
2141
|
chart_id: str | None = None
|
|
2005
|
-
chart_key: str | None = None
|
|
2006
2142
|
chart_name: str | None = None
|
|
2007
2143
|
|
|
2008
|
-
@model_validator(mode="before")
|
|
2009
|
-
@classmethod
|
|
2010
|
-
def normalize_aliases(cls, value: Any) -> Any:
|
|
2011
|
-
if not isinstance(value, dict):
|
|
2012
|
-
return value
|
|
2013
|
-
payload = dict(value)
|
|
2014
|
-
if "chartKey" in payload and "chart_key" not in payload:
|
|
2015
|
-
payload["chart_key"] = payload.pop("chartKey")
|
|
2016
|
-
if "chartId" in payload and "chart_id" not in payload:
|
|
2017
|
-
payload["chart_id"] = payload.pop("chartId")
|
|
2018
|
-
if "biChartId" in payload and "chart_id" not in payload:
|
|
2019
|
-
payload["chart_id"] = payload.pop("biChartId")
|
|
2020
|
-
if "chartName" in payload and "chart_name" not in payload:
|
|
2021
|
-
payload["chart_name"] = payload.pop("chartName")
|
|
2022
|
-
if "appKey" in payload and "app_key" not in payload:
|
|
2023
|
-
payload["app_key"] = payload.pop("appKey")
|
|
2024
|
-
return payload
|
|
2025
|
-
|
|
2026
2144
|
@model_validator(mode="after")
|
|
2027
2145
|
def validate_target(self) -> "PortalChartRefPatch":
|
|
2028
|
-
if not (self.chart_id or self.
|
|
2029
|
-
raise ValueError("chart_ref requires chart_id
|
|
2030
|
-
if self.chart_name and not self.app_key and not (self.chart_id or self.chart_key):
|
|
2031
|
-
raise ValueError("chart_ref with chart_name requires app_key unless chart_id/chart_key is also provided")
|
|
2146
|
+
if not (self.chart_id or self.chart_name):
|
|
2147
|
+
raise ValueError("chart_ref requires chart_id or chart_name")
|
|
2032
2148
|
return self
|
|
2033
2149
|
|
|
2034
2150
|
|
|
@@ -2054,7 +2170,7 @@ class PortalViewRefPatch(StrictModel):
|
|
|
2054
2170
|
class PortalSectionPatch(StrictModel):
|
|
2055
2171
|
title: str
|
|
2056
2172
|
source_type: str = Field(validation_alias=AliasChoices("source_type", "sourceType"))
|
|
2057
|
-
role: str | None = None
|
|
2173
|
+
role: str | None = Field(default=None, validation_alias=AliasChoices("role", "zone", "section_role", "sectionRole"))
|
|
2058
2174
|
position: PortalComponentPositionPatch | None = None
|
|
2059
2175
|
dash_style_config: dict[str, Any] | None = Field(default=None, validation_alias=AliasChoices("dash_style_config", "dashStyleConfigBO"))
|
|
2060
2176
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -2072,6 +2188,9 @@ class PortalSectionPatch(StrictModel):
|
|
|
2072
2188
|
raw_type = payload.get("source_type", payload.get("sourceType"))
|
|
2073
2189
|
if isinstance(raw_type, str):
|
|
2074
2190
|
payload["source_type"] = raw_type.strip().lower()
|
|
2191
|
+
raw_role = payload.get("role", payload.get("zone", payload.get("section_role", payload.get("sectionRole"))))
|
|
2192
|
+
if isinstance(raw_role, str):
|
|
2193
|
+
payload["role"] = raw_role.strip().lower()
|
|
2075
2194
|
if "chartRef" in payload and "chart_ref" not in payload:
|
|
2076
2195
|
payload["chart_ref"] = payload.pop("chartRef")
|
|
2077
2196
|
if "viewRef" in payload and "view_ref" not in payload:
|
|
@@ -2085,13 +2204,6 @@ class PortalSectionPatch(StrictModel):
|
|
|
2085
2204
|
supported = {"chart", "view", "grid", "filter", "text", "link"}
|
|
2086
2205
|
if self.source_type not in supported:
|
|
2087
2206
|
raise ValueError(f"unsupported portal source_type '{self.source_type}'")
|
|
2088
|
-
if self.role is not None:
|
|
2089
|
-
normalized_role = str(self.role or "").strip().lower()
|
|
2090
|
-
if normalized_role not in {"metric"}:
|
|
2091
|
-
raise ValueError(f"unsupported portal section role '{self.role}'")
|
|
2092
|
-
self.role = normalized_role
|
|
2093
|
-
if self.source_type != "chart":
|
|
2094
|
-
raise ValueError("portal section role is only supported for chart sections")
|
|
2095
2207
|
if self.source_type == "chart" and self.chart_ref is None:
|
|
2096
2208
|
raise ValueError("chart section requires chart_ref")
|
|
2097
2209
|
if self.source_type == "view" and self.view_ref is None:
|
|
@@ -2230,6 +2342,7 @@ AppReadSummaryResponse = AppGetResponse
|
|
|
2230
2342
|
AppFieldsReadResponse = AppGetFieldsResponse
|
|
2231
2343
|
AppLayoutReadResponse = AppGetLayoutResponse
|
|
2232
2344
|
AppViewsReadResponse = AppGetViewsResponse
|
|
2345
|
+
AppFlowReadResponse = AppGetFlowResponse
|
|
2233
2346
|
AppChartsReadResponse = AppGetChartsResponse
|
|
2234
2347
|
|
|
2235
2348
|
|
|
@@ -2283,6 +2396,9 @@ class ChartGetResponse(StrictModel):
|
|
|
2283
2396
|
chart_id: str
|
|
2284
2397
|
base: dict[str, Any] = Field(default_factory=dict)
|
|
2285
2398
|
visibility: dict[str, Any] = Field(default_factory=dict)
|
|
2399
|
+
filters: list[list[dict[str, Any]]] = Field(default_factory=list)
|
|
2400
|
+
group_by: list[str] = Field(default_factory=list)
|
|
2401
|
+
metrics: list[dict[str, Any]] = Field(default_factory=list)
|
|
2286
2402
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
2287
2403
|
|
|
2288
2404
|
|
|
@@ -2316,6 +2432,40 @@ class LayoutPlanRequest(StrictModel):
|
|
|
2316
2432
|
return payload
|
|
2317
2433
|
|
|
2318
2434
|
|
|
2435
|
+
class FlowPlanRequest(StrictModel):
|
|
2436
|
+
app_key: str
|
|
2437
|
+
mode: str = "replace"
|
|
2438
|
+
nodes: list[FlowNodePatch] = Field(default_factory=list)
|
|
2439
|
+
transitions: list[FlowTransitionPatch] = Field(default_factory=list)
|
|
2440
|
+
preset: FlowPreset | None = None
|
|
2441
|
+
|
|
2442
|
+
@model_validator(mode="before")
|
|
2443
|
+
@classmethod
|
|
2444
|
+
def normalize_mode_alias(cls, value: Any) -> Any:
|
|
2445
|
+
if not isinstance(value, dict):
|
|
2446
|
+
return value
|
|
2447
|
+
payload = dict(value)
|
|
2448
|
+
if str(payload.get("mode") or "").strip().lower() == "overwrite":
|
|
2449
|
+
payload["mode"] = "replace"
|
|
2450
|
+
raw_preset = payload.get("preset")
|
|
2451
|
+
if raw_preset is None and isinstance(payload.get("base_preset"), str):
|
|
2452
|
+
raw_preset = payload["base_preset"]
|
|
2453
|
+
payload["preset"] = raw_preset
|
|
2454
|
+
if isinstance(raw_preset, str):
|
|
2455
|
+
normalized_preset = raw_preset.strip().lower()
|
|
2456
|
+
preset_aliases = {
|
|
2457
|
+
"default_approval": FlowPreset.basic_approval.value,
|
|
2458
|
+
"approval": FlowPreset.basic_approval.value,
|
|
2459
|
+
"basic approval": FlowPreset.basic_approval.value,
|
|
2460
|
+
"default_fill_then_approve": FlowPreset.basic_fill_then_approve.value,
|
|
2461
|
+
"default-fill-then-approve": FlowPreset.basic_fill_then_approve.value,
|
|
2462
|
+
"fill_then_approve": FlowPreset.basic_fill_then_approve.value,
|
|
2463
|
+
}
|
|
2464
|
+
if normalized_preset in preset_aliases:
|
|
2465
|
+
payload["preset"] = preset_aliases[normalized_preset]
|
|
2466
|
+
return payload
|
|
2467
|
+
|
|
2468
|
+
|
|
2319
2469
|
class ViewsPlanRequest(StrictModel):
|
|
2320
2470
|
app_key: str
|
|
2321
2471
|
upsert_views: list[ViewUpsertPatch] = Field(default_factory=list)
|
|
@@ -2345,6 +2495,8 @@ def _normalize_field_payload(value: Any) -> Any:
|
|
|
2345
2495
|
payload = dict(value)
|
|
2346
2496
|
if "fields" in payload and "subfields" not in payload:
|
|
2347
2497
|
payload["subfields"] = payload.pop("fields")
|
|
2498
|
+
if "options" in payload:
|
|
2499
|
+
payload["options"] = _normalize_field_options(payload.get("options"))
|
|
2348
2500
|
raw_type = payload.get("type")
|
|
2349
2501
|
if isinstance(raw_type, int):
|
|
2350
2502
|
normalized_from_id = FIELD_TYPE_ID_ALIASES.get(raw_type)
|
|
@@ -2377,6 +2529,34 @@ def _normalize_field_payload(value: Any) -> Any:
|
|
|
2377
2529
|
return payload
|
|
2378
2530
|
|
|
2379
2531
|
|
|
2532
|
+
def _normalize_field_options(value: Any) -> Any:
|
|
2533
|
+
if not isinstance(value, list):
|
|
2534
|
+
return value
|
|
2535
|
+
normalized: list[str] = []
|
|
2536
|
+
for item in value:
|
|
2537
|
+
if isinstance(item, str):
|
|
2538
|
+
normalized.append(item)
|
|
2539
|
+
continue
|
|
2540
|
+
if isinstance(item, (int, float, bool)):
|
|
2541
|
+
normalized.append(str(item))
|
|
2542
|
+
continue
|
|
2543
|
+
if isinstance(item, dict):
|
|
2544
|
+
label = (
|
|
2545
|
+
item.get("label")
|
|
2546
|
+
or item.get("name")
|
|
2547
|
+
or item.get("title")
|
|
2548
|
+
or item.get("value")
|
|
2549
|
+
or item.get("text")
|
|
2550
|
+
or item.get("optValue")
|
|
2551
|
+
or item.get("optName")
|
|
2552
|
+
)
|
|
2553
|
+
if label is not None:
|
|
2554
|
+
normalized.append(str(label))
|
|
2555
|
+
continue
|
|
2556
|
+
normalized.append(str(item))
|
|
2557
|
+
return normalized
|
|
2558
|
+
|
|
2559
|
+
|
|
2380
2560
|
def _slugify_title(title: str) -> str:
|
|
2381
2561
|
normalized = "".join(ch.lower() if ch.isalnum() else "_" for ch in str(title or ""))
|
|
2382
2562
|
collapsed = "_".join(part for part in normalized.split("_") if part)
|