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

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
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-cli@0.2.0-beta.69
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.71
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.69 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.71 qingflow
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-cli",
3
- "version": "0.2.0-beta.69",
3
+ "version": "0.2.0-beta.71",
4
4
  "description": "Human-friendly Qingflow command line interface for auth, record operations, import, tasks, and stable builder flows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b69"
7
+ version = "0.2.0b71"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -4,7 +4,6 @@ from dataclasses import dataclass
4
4
  from threading import Event
5
5
  from time import sleep
6
6
  from typing import Any
7
- from urllib.parse import urlsplit, urlunsplit
8
7
  from uuid import uuid4
9
8
 
10
9
  import httpx
@@ -1382,6 +1382,11 @@ class AppChartsReadResponse(StrictModel):
1382
1382
  chart_count: int = 0
1383
1383
 
1384
1384
 
1385
+ class PortalListResponse(StrictModel):
1386
+ items: list[dict[str, Any]] = Field(default_factory=list)
1387
+ total: int = 0
1388
+
1389
+
1385
1390
  class PortalReadSummaryResponse(StrictModel):
1386
1391
  dash_key: str
1387
1392
  being_draft: bool = True
@@ -1395,6 +1400,35 @@ class PortalReadSummaryResponse(StrictModel):
1395
1400
  sections: list[dict[str, Any]] = Field(default_factory=list)
1396
1401
 
1397
1402
 
1403
+ class PortalGetResponse(StrictModel):
1404
+ dash_key: str
1405
+ being_draft: bool = True
1406
+ dash_name: str | None = None
1407
+ package_tag_ids: list[int] = Field(default_factory=list)
1408
+ dash_icon: str | None = None
1409
+ hide_copyright: bool | None = None
1410
+ auth: dict[str, Any] = Field(default_factory=dict)
1411
+ config: dict[str, Any] = Field(default_factory=dict)
1412
+ dash_global_config: dict[str, Any] = Field(default_factory=dict)
1413
+ component_count: int = 0
1414
+ components: list[dict[str, Any]] = Field(default_factory=list)
1415
+
1416
+
1417
+ class ViewGetResponse(StrictModel):
1418
+ viewgraph_key: str
1419
+ base_info: dict[str, Any] = Field(default_factory=dict)
1420
+ config: dict[str, Any] = Field(default_factory=dict)
1421
+ questions: list[dict[str, Any]] = Field(default_factory=list)
1422
+ associations: list[dict[str, Any]] = Field(default_factory=list)
1423
+
1424
+
1425
+ class ChartGetResponse(StrictModel):
1426
+ chart_id: str
1427
+ base: dict[str, Any] = Field(default_factory=dict)
1428
+ config: dict[str, Any] = Field(default_factory=dict)
1429
+ data: dict[str, Any] = Field(default_factory=dict)
1430
+
1431
+
1398
1432
  class SchemaPlanRequest(StrictModel):
1399
1433
  app_key: str = ""
1400
1434
  package_tag_id: int | None = None
@@ -34,8 +34,8 @@ from .models import (
34
34
  AppChartsReadResponse,
35
35
  AppFieldsReadResponse,
36
36
  AppFlowReadResponse,
37
+ ChartGetResponse,
37
38
  AppLayoutReadResponse,
38
- PortalReadSummaryResponse,
39
39
  AppReadSummaryResponse,
40
40
  AppViewsReadResponse,
41
41
  ChartApplyRequest,
@@ -53,6 +53,9 @@ from .models import (
53
53
  LayoutSectionPatch,
54
54
  LayoutPreset,
55
55
  PortalApplyRequest,
56
+ PortalGetResponse,
57
+ PortalListResponse,
58
+ PortalReadSummaryResponse,
56
59
  PortalSectionPatch,
57
60
  PublicFieldType,
58
61
  PublicRelationMode,
@@ -65,6 +68,7 @@ from .models import (
65
68
  ViewButtonBindingPatch,
66
69
  ViewUpsertPatch,
67
70
  ViewFilterOperator,
71
+ ViewGetResponse,
68
72
  ViewsPlanRequest,
69
73
  ViewsPreset,
70
74
  FlowPreset,
@@ -114,6 +118,18 @@ FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
114
118
  FieldType.relation.value: 25,
115
119
  }
116
120
 
121
+ INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
122
+ FieldType.text.value,
123
+ FieldType.long_text.value,
124
+ FieldType.number.value,
125
+ FieldType.amount.value,
126
+ FieldType.date.value,
127
+ FieldType.datetime.value,
128
+ FieldType.single_select.value,
129
+ FieldType.multi_select.value,
130
+ FieldType.boolean.value,
131
+ )
132
+
117
133
  MATCH_TYPE_ACCURACY = 1
118
134
  JUDGE_EQUAL = 0
119
135
  JUDGE_UNEQUAL = 1
@@ -2261,6 +2277,38 @@ class AiBuilderFacade:
2261
2277
  **response.model_dump(mode="json"),
2262
2278
  }
2263
2279
 
2280
+ def portal_list(self, *, profile: str) -> JSONObject:
2281
+ try:
2282
+ raw_items = self.portals.portal_list(profile=profile).get("items") or []
2283
+ except (QingflowApiError, RuntimeError) as error:
2284
+ api_error = _coerce_api_error(error)
2285
+ return _failed_from_api_error(
2286
+ "PORTAL_LIST_FAILED",
2287
+ api_error,
2288
+ normalized_args={},
2289
+ details={},
2290
+ suggested_next_call=None,
2291
+ )
2292
+ items = _normalize_portal_list_items(raw_items)
2293
+ response = PortalListResponse(items=items, total=len(items))
2294
+ return {
2295
+ "status": "success",
2296
+ "error_code": None,
2297
+ "recoverable": False,
2298
+ "message": "list accessible portals",
2299
+ "normalized_args": {},
2300
+ "missing_fields": [],
2301
+ "allowed_values": {},
2302
+ "details": {},
2303
+ "request_id": None,
2304
+ "suggested_next_call": None,
2305
+ "noop": False,
2306
+ "warnings": [],
2307
+ "verification": {"portal_list_loaded": True},
2308
+ "verified": True,
2309
+ **response.model_dump(mode="json"),
2310
+ }
2311
+
2264
2312
  def _load_chart_list_for_builder(self, *, profile: str, app_key: str) -> tuple[list[dict[str, Any]], str]:
2265
2313
  try:
2266
2314
  sorted_items = self.charts.qingbi_report_list_sorted(profile=profile, app_key=app_key, page_num=1, page_size=500).get("items") or []
@@ -2271,6 +2319,57 @@ class AiBuilderFacade:
2271
2319
  fallback_items = self.charts.qingbi_report_list(profile=profile, app_key=app_key).get("items") or []
2272
2320
  return list(fallback_items) if isinstance(fallback_items, list) else [], "fallback"
2273
2321
 
2322
+ def portal_get(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
2323
+ try:
2324
+ result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
2325
+ except (QingflowApiError, RuntimeError) as error:
2326
+ api_error = _coerce_api_error(error)
2327
+ return _failed_from_api_error(
2328
+ "PORTAL_GET_FAILED",
2329
+ api_error,
2330
+ normalized_args={"dash_key": dash_key, "being_draft": being_draft},
2331
+ details={"dash_key": dash_key, "being_draft": being_draft},
2332
+ suggested_next_call={"tool_name": "portal_get", "arguments": {"profile": profile, "dash_key": dash_key, "being_draft": being_draft}},
2333
+ )
2334
+ response = PortalGetResponse(
2335
+ dash_key=dash_key,
2336
+ being_draft=being_draft,
2337
+ dash_name=str(result.get("dashName") or "").strip() or None,
2338
+ package_tag_ids=[
2339
+ tag_id
2340
+ for tag_id in (
2341
+ _coerce_positive_int((item or {}).get("tagId"))
2342
+ for item in (result.get("tags") or [])
2343
+ if isinstance(item, dict)
2344
+ )
2345
+ if tag_id is not None
2346
+ ],
2347
+ dash_icon=str(result.get("dashIcon") or "").strip() or None,
2348
+ hide_copyright=bool(result.get("hideCopyright")) if "hideCopyright" in result else None,
2349
+ auth=deepcopy(result.get("auth")) if isinstance(result.get("auth"), dict) else {},
2350
+ config=deepcopy(result.get("config")) if isinstance(result.get("config"), dict) else {},
2351
+ dash_global_config=deepcopy(result.get("dashGlobalConfig")) if isinstance(result.get("dashGlobalConfig"), dict) else {},
2352
+ component_count=len(result.get("components") or []) if isinstance(result.get("components"), list) else 0,
2353
+ components=_normalize_portal_components(result.get("components")),
2354
+ )
2355
+ return {
2356
+ "status": "success",
2357
+ "error_code": None,
2358
+ "recoverable": False,
2359
+ "message": "read portal detail",
2360
+ "normalized_args": {"dash_key": dash_key, "being_draft": being_draft},
2361
+ "missing_fields": [],
2362
+ "allowed_values": {},
2363
+ "details": {},
2364
+ "request_id": None,
2365
+ "suggested_next_call": None,
2366
+ "noop": False,
2367
+ "warnings": [],
2368
+ "verification": {"portal_exists": True, "being_draft": being_draft},
2369
+ "verified": True,
2370
+ **response.model_dump(mode="json"),
2371
+ }
2372
+
2274
2373
  def portal_read_summary(self, *, profile: str, dash_key: str, being_draft: bool = True) -> JSONObject:
2275
2374
  try:
2276
2375
  result = self.portals.portal_get(profile=profile, dash_key=dash_key, being_draft=being_draft).get("result") or {}
@@ -2321,6 +2420,186 @@ class AiBuilderFacade:
2321
2420
  **response.model_dump(mode="json"),
2322
2421
  }
2323
2422
 
2423
+ def view_get(self, *, profile: str, viewgraph_key: str) -> JSONObject:
2424
+ try:
2425
+ config = self.views.view_get_config(profile=profile, viewgraph_key=viewgraph_key).get("result") or {}
2426
+ except (QingflowApiError, RuntimeError) as error:
2427
+ api_error = _coerce_api_error(error)
2428
+ return _failed_from_api_error(
2429
+ "VIEW_GET_FAILED",
2430
+ api_error,
2431
+ normalized_args={"viewgraph_key": viewgraph_key},
2432
+ details={"viewgraph_key": viewgraph_key},
2433
+ suggested_next_call={"tool_name": "view_get", "arguments": {"profile": profile, "viewgraph_key": viewgraph_key}},
2434
+ )
2435
+
2436
+ warnings: list[dict[str, Any]] = []
2437
+ verification = {
2438
+ "view_exists": True,
2439
+ "base_info_verified": True,
2440
+ "questions_verified": True,
2441
+ "associations_verified": True,
2442
+ }
2443
+
2444
+ base_info: dict[str, Any] = {}
2445
+ try:
2446
+ base_info_payload = self.views.view_get_base_info(profile=profile, viewgraph_key=viewgraph_key, passcode=None).get("result") or {}
2447
+ if isinstance(base_info_payload, dict):
2448
+ base_info = deepcopy(base_info_payload)
2449
+ except (QingflowApiError, RuntimeError):
2450
+ verification["base_info_verified"] = False
2451
+ warnings.append(_warning("VIEW_BASE_INFO_UNAVAILABLE", "view base info readback is unavailable"))
2452
+
2453
+ questions: list[dict[str, Any]] = []
2454
+ try:
2455
+ questions_payload = self.views.view_list_questions(profile=profile, viewgraph_key=viewgraph_key).get("result") or []
2456
+ if isinstance(questions_payload, list):
2457
+ questions = [deepcopy(item) for item in questions_payload if isinstance(item, dict)]
2458
+ except (QingflowApiError, RuntimeError):
2459
+ verification["questions_verified"] = False
2460
+ warnings.append(_warning("VIEW_QUESTIONS_UNAVAILABLE", "view question list readback is unavailable"))
2461
+
2462
+ associations: list[dict[str, Any]] = []
2463
+ try:
2464
+ associations_payload = self.views.view_list_associations(profile=profile, viewgraph_key=viewgraph_key).get("result") or []
2465
+ if isinstance(associations_payload, list):
2466
+ associations = [deepcopy(item) for item in associations_payload if isinstance(item, dict)]
2467
+ except (QingflowApiError, RuntimeError):
2468
+ verification["associations_verified"] = False
2469
+ warnings.append(_warning("VIEW_ASSOCIATIONS_UNAVAILABLE", "view association list readback is unavailable"))
2470
+
2471
+ response = ViewGetResponse(
2472
+ viewgraph_key=viewgraph_key,
2473
+ base_info=base_info,
2474
+ config=deepcopy(config) if isinstance(config, dict) else {},
2475
+ questions=questions,
2476
+ associations=associations,
2477
+ )
2478
+ return {
2479
+ "status": "success",
2480
+ "error_code": None,
2481
+ "recoverable": False,
2482
+ "message": "read view detail",
2483
+ "normalized_args": {"viewgraph_key": viewgraph_key},
2484
+ "missing_fields": [],
2485
+ "allowed_values": {},
2486
+ "details": {},
2487
+ "request_id": None,
2488
+ "suggested_next_call": None,
2489
+ "noop": False,
2490
+ "warnings": warnings,
2491
+ "verification": verification,
2492
+ "verified": all(bool(value) for value in verification.values()),
2493
+ **response.model_dump(mode="json"),
2494
+ }
2495
+
2496
+ def chart_get(
2497
+ self,
2498
+ *,
2499
+ profile: str,
2500
+ chart_id: str,
2501
+ data_payload: dict[str, Any] | None = None,
2502
+ page_num: int | None = None,
2503
+ page_size: int | None = None,
2504
+ page_num_y: int | None = None,
2505
+ page_size_y: int | None = None,
2506
+ ) -> JSONObject:
2507
+ normalized_payload = deepcopy(data_payload) if isinstance(data_payload, dict) else {}
2508
+ warnings: list[dict[str, Any]] = []
2509
+ verification = {
2510
+ "chart_exists": True,
2511
+ "chart_data_loaded": True,
2512
+ "chart_config_loaded": True,
2513
+ }
2514
+ try:
2515
+ base = self.charts.qingbi_report_get_base(profile=profile, chart_id=chart_id).get("result") or {}
2516
+ data = self.charts.qingbi_report_get_data(
2517
+ profile=profile,
2518
+ chart_id=chart_id,
2519
+ payload=normalized_payload,
2520
+ page_num=page_num,
2521
+ page_size=page_size,
2522
+ page_num_y=page_num_y,
2523
+ page_size_y=page_size_y,
2524
+ ).get("result") or {}
2525
+ except (QingflowApiError, RuntimeError) as error:
2526
+ api_error = _coerce_api_error(error)
2527
+ return _failed_from_api_error(
2528
+ "CHART_GET_FAILED",
2529
+ api_error,
2530
+ normalized_args={
2531
+ "chart_id": chart_id,
2532
+ "data_payload": normalized_payload,
2533
+ "page_num": page_num,
2534
+ "page_size": page_size,
2535
+ "page_num_y": page_num_y,
2536
+ "page_size_y": page_size_y,
2537
+ },
2538
+ details={"chart_id": chart_id},
2539
+ suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": chart_id}},
2540
+ )
2541
+
2542
+ try:
2543
+ config = self.charts.qingbi_report_get_config(profile=profile, chart_id=chart_id).get("result") or {}
2544
+ except (QingflowApiError, RuntimeError) as error:
2545
+ config_from_data = data.get("config") if isinstance(data, dict) else None
2546
+ if isinstance(config_from_data, dict):
2547
+ config = deepcopy(config_from_data)
2548
+ verification["chart_config_loaded"] = True
2549
+ warnings.append(
2550
+ _warning(
2551
+ "CHART_CONFIG_FALLBACK_FROM_DATA",
2552
+ "chart config endpoint is unavailable for this chart id; using config embedded in chart data instead",
2553
+ )
2554
+ )
2555
+ else:
2556
+ api_error = _coerce_api_error(error)
2557
+ return _failed_from_api_error(
2558
+ "CHART_GET_FAILED",
2559
+ api_error,
2560
+ normalized_args={
2561
+ "chart_id": chart_id,
2562
+ "data_payload": normalized_payload,
2563
+ "page_num": page_num,
2564
+ "page_size": page_size,
2565
+ "page_num_y": page_num_y,
2566
+ "page_size_y": page_size_y,
2567
+ },
2568
+ details={"chart_id": chart_id},
2569
+ suggested_next_call={"tool_name": "chart_get", "arguments": {"profile": profile, "chart_id": chart_id}},
2570
+ )
2571
+
2572
+ response = ChartGetResponse(
2573
+ chart_id=chart_id,
2574
+ base=deepcopy(base) if isinstance(base, dict) else {},
2575
+ config=deepcopy(config) if isinstance(config, dict) else {},
2576
+ data=deepcopy(data) if isinstance(data, dict) else {"value": data},
2577
+ )
2578
+ return {
2579
+ "status": "success",
2580
+ "error_code": None,
2581
+ "recoverable": False,
2582
+ "message": "read chart detail",
2583
+ "normalized_args": {
2584
+ "chart_id": chart_id,
2585
+ "data_payload": normalized_payload,
2586
+ "page_num": page_num,
2587
+ "page_size": page_size,
2588
+ "page_num_y": page_num_y,
2589
+ "page_size_y": page_size_y,
2590
+ },
2591
+ "missing_fields": [],
2592
+ "allowed_values": {},
2593
+ "details": {},
2594
+ "request_id": None,
2595
+ "suggested_next_call": None,
2596
+ "noop": False,
2597
+ "warnings": warnings,
2598
+ "verification": verification,
2599
+ "verified": True,
2600
+ **response.model_dump(mode="json"),
2601
+ }
2602
+
2324
2603
  def app_schema_plan(self, *, profile: str, request: SchemaPlanRequest) -> JSONObject:
2325
2604
  normalized_args = request.model_dump(mode="json")
2326
2605
  target = self._preview_target_app(
@@ -8842,8 +9121,9 @@ def _compile_code_block_binding_fields(
8842
9121
  selector_payload=target_payload,
8843
9122
  location=f"code_block_binding.outputs[{output_index}].target_field",
8844
9123
  )
8845
- if str(target_field.get("type") or "") in {FieldType.code_block.value, FieldType.subtable.value, FieldType.relation.value}:
8846
- raise ValueError(f"code_block output target field '{target_field.get('name')}' uses an unsupported field type")
9124
+ target_type = str(target_field.get("type") or "")
9125
+ if not _integration_output_target_type_supported(target_type):
9126
+ raise ValueError(_integration_output_target_type_error("code_block", str(target_field.get("name") or ""), target_type))
8847
9127
  target_que_ref = _coerce_positive_int(target_field.get("que_id"))
8848
9128
  if target_que_ref is None:
8849
9129
  target_que_ref = _coerce_any_int(target_field.get("que_temp_id"))
@@ -9040,18 +9320,17 @@ def _overlay_code_block_binding_fields(*, target_fields: list[dict[str, Any]], s
9040
9320
  field["code_block_binding"] = binding
9041
9321
 
9042
9322
 
9043
- def _q_linker_target_type_supported(field_type: str) -> bool:
9044
- return field_type in {
9045
- FieldType.text.value,
9046
- FieldType.long_text.value,
9047
- FieldType.number.value,
9048
- FieldType.amount.value,
9049
- FieldType.date.value,
9050
- FieldType.datetime.value,
9051
- FieldType.single_select.value,
9052
- FieldType.multi_select.value,
9053
- FieldType.boolean.value,
9054
- }
9323
+ def _integration_output_target_type_supported(field_type: str) -> bool:
9324
+ return field_type in INTEGRATION_OUTPUT_TARGET_FIELD_TYPES
9325
+
9326
+
9327
+ def _integration_output_target_type_error(binding_kind: str, field_name: str, field_type: str) -> str:
9328
+ allowed = ", ".join(INTEGRATION_OUTPUT_TARGET_FIELD_TYPES)
9329
+ rendered_type = field_type or "unknown"
9330
+ return (
9331
+ f"{binding_kind} output target field '{field_name}' uses unsupported field type '{rendered_type}'. "
9332
+ f"Allowed target field types: {allowed}"
9333
+ )
9055
9334
 
9056
9335
 
9057
9336
  def _compile_q_linker_binding_fields(
@@ -9215,8 +9494,8 @@ def _compile_q_linker_binding_fields(
9215
9494
  location=f"q_linker_binding.outputs[{output_index}].target_field",
9216
9495
  )
9217
9496
  target_type = str(target_field.get("type") or "")
9218
- if not _q_linker_target_type_supported(target_type):
9219
- raise ValueError(f"q_linker output target field '{target_field.get('name')}' uses an unsupported field type")
9497
+ if not _integration_output_target_type_supported(target_type):
9498
+ raise ValueError(_integration_output_target_type_error("q_linker", str(target_field.get("name") or ""), target_type))
9220
9499
  target_ref = _coerce_positive_int(target_field.get("que_id"))
9221
9500
  if target_ref is None:
9222
9501
  target_ref = _coerce_any_int(target_field.get("que_temp_id"))
@@ -10986,6 +11265,38 @@ def _summarize_charts(result: Any) -> list[dict[str, Any]]:
10986
11265
  return items
10987
11266
 
10988
11267
 
11268
+ def _normalize_portal_list_items(raw_items: Any) -> list[dict[str, Any]]:
11269
+ if not isinstance(raw_items, list):
11270
+ return []
11271
+ items: list[dict[str, Any]] = []
11272
+ for item in raw_items:
11273
+ if not isinstance(item, dict):
11274
+ continue
11275
+ dash_key = str(item.get("dashKey") or "").strip()
11276
+ dash_name = str(item.get("dashName") or "").strip()
11277
+ dash_icon = str(item.get("dashIcon") or "").strip() or None
11278
+ package_tag_ids = [
11279
+ tag_id
11280
+ for tag_id in (
11281
+ _coerce_positive_int((tag or {}).get("tagId"))
11282
+ for tag in (item.get("tags") or [])
11283
+ if isinstance(tag, dict)
11284
+ )
11285
+ if tag_id is not None
11286
+ ]
11287
+ if not any((dash_key, dash_name, dash_icon, package_tag_ids)):
11288
+ continue
11289
+ items.append(
11290
+ {
11291
+ "dash_key": dash_key or None,
11292
+ "dash_name": dash_name or None,
11293
+ "dash_icon": dash_icon,
11294
+ "package_tag_ids": package_tag_ids,
11295
+ }
11296
+ )
11297
+ return items
11298
+
11299
+
10989
11300
  def _summarize_portal_sections(components: Any) -> list[dict[str, Any]]:
10990
11301
  if not isinstance(components, list):
10991
11302
  return []
@@ -11020,6 +11331,50 @@ def _summarize_portal_sections(components: Any) -> list[dict[str, Any]]:
11020
11331
  return items
11021
11332
 
11022
11333
 
11334
+ def _normalize_portal_components(components: Any) -> list[dict[str, Any]]:
11335
+ if not isinstance(components, list):
11336
+ return []
11337
+ items: list[dict[str, Any]] = []
11338
+ config_key_map = {
11339
+ "grid": "gridConfig",
11340
+ "link": "linkConfig",
11341
+ "text": "textConfig",
11342
+ "filter": "filterConfig",
11343
+ "chart": "chartConfig",
11344
+ "view": "viewgraphConfig",
11345
+ }
11346
+ for index, component in enumerate(components):
11347
+ if not isinstance(component, dict):
11348
+ continue
11349
+ source_type = _normalize_portal_component_source_type(component.get("type"))
11350
+ title = _extract_portal_component_title(component, source_type=source_type)
11351
+ summary: dict[str, Any] = {
11352
+ "order": index,
11353
+ "source_type": source_type,
11354
+ "title": title,
11355
+ }
11356
+ position = component.get("position")
11357
+ if isinstance(position, dict):
11358
+ summary["position"] = deepcopy(position)
11359
+ config_key = config_key_map.get(source_type, "")
11360
+ config = component.get(config_key) if isinstance(component.get(config_key), dict) else {}
11361
+ if source_type == "chart":
11362
+ summary["chart_ref"] = {
11363
+ "chart_id": str(config.get("biChartId") or "").strip() or None,
11364
+ "chart_name": str(config.get("chartComponentTitle") or title or "").strip() or None,
11365
+ }
11366
+ elif source_type == "view":
11367
+ summary["view_ref"] = {
11368
+ "app_key": str(config.get("appKey") or "").strip() or None,
11369
+ "view_key": str(config.get("viewgraphKey") or "").strip() or None,
11370
+ "view_name": str(config.get("viewgraphName") or title or "").strip() or None,
11371
+ }
11372
+ elif source_type in {"grid", "link", "text", "filter"} and config:
11373
+ summary["config"] = deepcopy(config)
11374
+ items.append(summary)
11375
+ return items
11376
+
11377
+
11023
11378
  def _normalize_portal_component_source_type(value: Any) -> str:
11024
11379
  raw = str(value or "").strip()
11025
11380
  mapping = {