@josephyan/qingflow-app-user-mcp 0.2.0-beta.35 → 0.2.0-beta.37

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-app-user-mcp@0.2.0-beta.35
6
+ npm install @josephyan/qingflow-app-user-mcp@0.2.0-beta.37
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.35 qingflow-app-user-mcp
12
+ npx -y -p @josephyan/qingflow-app-user-mcp@0.2.0-beta.37 qingflow-app-user-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-user-mcp",
3
- "version": "0.2.0-beta.35",
3
+ "version": "0.2.0-beta.37",
4
4
  "description": "Operational end-user MCP for Qingflow records, tasks, comments, and directory workflows.",
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.0b35"
7
+ version = "0.2.0b37"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -31,6 +31,7 @@ Route to exactly one of these specialized paths:
31
31
  ## Routing Rules
32
32
 
33
33
  - If the user does not know the target `app_key`, discover apps first with `app_list` or `app_search`, then route to the specialized skill
34
+ - If the app is known but the available data range is unclear, call `app_get` first and inspect `accessible_views`
34
35
  - If the task is about browsing, reading, creating, updating, deleting, attachments, relations, subtable writes, or member/department-field candidate lookup, switch to `$qingflow-record-crud`
35
36
  - If the task is about todo discovery, task context, approval actions, rollback or transfer, associated report review, or workflow log review, switch to `$qingflow-task-ops`
36
37
  - If the task is about grouped distributions, ratios, rankings, trends, insights, or any final statistical conclusion, switch to `$qingflow-record-analysis`
@@ -9,13 +9,15 @@ metadata:
9
9
 
10
10
  This skill is for final statistical conclusions only.
11
11
  Assumes MCP is connected, authenticated, and on the correct workspace.
12
- Analysis tasks must start with `record_schema_get`. Read top-level `fields` and `suggested_*`, then build field_id-based DSLs only.
12
+ Analysis tasks must start with `app_get`, then `record_schema_get(schema_mode="browse", view_id=...)`. Read top-level `fields` and `suggested_*`, then build field_id-based DSLs only.
13
+ Analysis tasks must start with `record_schema_get`.
14
+ If `app_get.accessible_views` marks a view with `analysis_supported=false`, do not use that view for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
13
15
 
14
- ## Step 1: `record_schema_get` → Step 2: build DSL → Step 3: `record_analyze`
16
+ ## Step 1: `app_get` → Step 2: `record_schema_get(schema_mode="browse", view_id=...)` → Step 3: build DSL → Step 4: `record_analyze`
15
17
 
16
- This is the ONLY execution order. Never skip step 1. Never call `record_analyze` without a schema.
18
+ This is the ONLY execution order. Never skip `app_get` when the browse range is unclear. Never call `record_analyze` without a browse schema.
17
19
 
18
- Core tools: `record_schema_get`, `record_analyze`. Use `record_list`/`record_get` only for post-analysis samples; task/comment work stays in [$qingflow-task-ops](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-task-ops/SKILL.md).
20
+ Core tools: `app_get`, `record_schema_get`, `record_analyze`. Use `record_list`/`record_get` only for post-analysis samples; task/comment work stays in [$qingflow-task-ops](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-task-ops/SKILL.md).
19
21
 
20
22
  ---
21
23
 
@@ -105,7 +107,8 @@ Top-level arguments:
105
107
  - `dimensions`: `[]` = whole-table summary; `[{...}]` = grouped.
106
108
  - `strict_full`: `true` for final conclusions. `false` allows partial results.
107
109
  - `limit`: limits returned rows only, not scan scope.
108
- - `view_key`/`view_name`: optional scope narrowing.
110
+ - `view_id`: the canonical browse selector. Prefer choosing it from `app_get.accessible_views`.
111
+ - Prefer `view_id` entries where `analysis_supported=true`. If a view is `boardView` or `ganttView`, switch to a system or table-style custom view before calling `record_analyze`.
109
112
  - `bucket` in dimensions: only for `suggested_time_fields`. Values: `day`/`week`/`month`/`quarter`/`year`/`null`.
110
113
 
111
114
  ---
@@ -16,9 +16,9 @@ Assumes MCP is connected, authenticated, and on the correct workspace.
16
16
 
17
17
  Use exactly one of these default paths:
18
18
 
19
- 1. Browse records: `record_schema_get -> record_list`
20
- 2. Read one record: `record_schema_get -> record_get`
21
- 3. Write records: `record_schema_get -> record_write`
19
+ 1. Browse records: `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list`
20
+ 2. Read one record: `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_get`
21
+ 3. Write records: `record_schema_get(schema_mode="applicant") -> record_write`
22
22
 
23
23
  ## Core Tools
24
24
 
@@ -27,12 +27,15 @@ Use exactly one of these default paths:
27
27
  - `record_get`
28
28
  - `record_write`
29
29
 
30
- `record_schema_get` only exposes the current user's applicant-node visible fields. Read top-level `fields` and `suggested_*`; if a field is missing, treat it as unavailable in the current permission scope.
30
+ `record_schema_get(schema_mode="applicant")` exposes the current user's applicant-node visible write/create fields.
31
+ `record_schema_get(schema_mode="browse", view_id=...)` exposes browse-schema fields for the selected accessible view.
32
+ Read top-level `fields` and `suggested_*`; if a field is missing, treat it as unavailable in the current permission scope.
31
33
 
32
34
  ## Supporting Tools
33
35
 
34
36
  - `app_list`
35
37
  - `app_search`
38
+ - `app_get`
36
39
  - `record_member_candidates`
37
40
  - `record_department_candidates`
38
41
  - `directory_search`
@@ -50,7 +53,9 @@ Use `record_member_candidates` / `record_department_candidates` as the default l
50
53
  2. Ensure workspace is selected
51
54
  3. Confirm target app and whether the task is browse / detail / write / analysis
52
55
  4. If `app_key` is unknown, use `app_list` or `app_search` first
53
- 5. Run `record_schema_get` before any non-trivial record read or write
56
+ 5. If browse/read range is unclear, run `app_get` and choose from `accessible_views`
57
+ 6. Run `record_schema_get(schema_mode="browse", view_id=...)` before browse/read
58
+ 7. Run `record_schema_get(schema_mode="applicant")` before write
54
59
  6. If the request is analysis-like, switch to [$qingflow-record-analysis](/Users/yanqidong/Documents/qingflow-next/.codex/skills/qingflow-record-analysis/SKILL.md)
55
60
  7. If the request is write-like, decide `insert / update / delete` before building any payload
56
61
  8. If fields are still ambiguous after `record_schema_get`, ask the user to confirm from a short candidate list instead of guessing
@@ -60,6 +65,7 @@ Use `record_member_candidates` / `record_department_candidates` as the default l
60
65
  ## Record Read Rules
61
66
 
62
67
  - Use `record_list` for browse/export/sample inspection only
68
+ - Prefer choosing a `view_id` from `app_get.accessible_views`
63
69
  - For `columns`, use `[{ "field_id": 12 }]`
64
70
  - For `where`, use `{ "field_id": 12, "op": "eq", "value": "进行中" }`
65
71
  - For `order_by`, use `{ "field_id": 18, "direction": "desc" }`
@@ -73,7 +79,7 @@ Use `record_write` as the only default write tool.
73
79
 
74
80
  ### Write workflow
75
81
 
76
- 1. Run `record_schema_get`
82
+ 1. Run `record_schema_get(schema_mode="applicant")`
77
83
  2. Decide whether the task is `insert`, `update`, or `delete`
78
84
  3. For relation fields, read `target_app_key / target_app_name` from schema first
79
85
  4. For member fields with unknown ids, run `record_member_candidates`
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b35"
5
+ __version__ = "0.2.0b37"
@@ -13,6 +13,7 @@ DEFAULT_RECORD_LIST_TYPE = 8
13
13
  ATTACHMENT_QUESTION_TYPE = 13
14
14
  DEFAULT_BASE_URL = "https://qingflow.com/api"
15
15
  DEFAULT_FEEDBACK_APP_KEY = "e0d017kju002"
16
+ DEFAULT_FEEDBACK_QSOURCE_TOKEN = "mcp-feedback-7755d14748fc"
16
17
 
17
18
 
18
19
  def get_mcp_home() -> Path:
@@ -142,7 +143,7 @@ def get_feedback_qsource_token() -> str | None:
142
143
  value = get_config_value(
143
144
  "feedback.qsource_token",
144
145
  env_var="QINGFLOW_MCP_FEEDBACK_QSOURCE_TOKEN",
145
- default=None,
146
+ default=DEFAULT_FEEDBACK_QSOURCE_TOKEN,
146
147
  )
147
148
  if value is None:
148
149
  return None
@@ -20,6 +20,18 @@ RECORD_LIST_TYPE_LABELS: dict[int, str] = {
20
20
  16: "我发起的-已结束",
21
21
  }
22
22
 
23
+ SYSTEM_VIEW_DEFINITIONS: tuple[tuple[str, int, str], ...] = (
24
+ ("system:all", 8, "全部数据"),
25
+ ("system:initiated", 14, "我发起的"),
26
+ ("system:todo", 1, "待办"),
27
+ ("system:done", 2, "已办"),
28
+ ("system:cc", 12, "抄送我的"),
29
+ )
30
+
31
+ SYSTEM_VIEW_ID_TO_LIST_TYPE: dict[str, int] = {view_id: list_type for view_id, list_type, _ in SYSTEM_VIEW_DEFINITIONS}
32
+ SYSTEM_VIEW_ID_TO_NAME: dict[str, str] = {view_id: name for view_id, _, name in SYSTEM_VIEW_DEFINITIONS}
33
+ SYSTEM_LIST_TYPE_TO_VIEW_ID: dict[int, str] = {list_type: view_id for view_id, list_type, _ in SYSTEM_VIEW_DEFINITIONS}
34
+
23
35
  TASK_TYPE_LABELS: dict[int, str] = {
24
36
  1: "待办",
25
37
  2: "我发起的",
@@ -40,6 +52,18 @@ def get_record_list_type_label(list_type: int | None) -> str | None:
40
52
  return RECORD_LIST_TYPE_LABELS.get(list_type)
41
53
 
42
54
 
55
+ def get_system_view_id(list_type: int | None) -> str | None:
56
+ if list_type is None:
57
+ return None
58
+ return SYSTEM_LIST_TYPE_TO_VIEW_ID.get(list_type)
59
+
60
+
61
+ def get_system_view_name(view_id: str | None) -> str | None:
62
+ if view_id is None:
63
+ return None
64
+ return SYSTEM_VIEW_ID_TO_NAME.get(view_id)
65
+
66
+
43
67
  def get_task_type_label(type_value: int | None) -> str | None:
44
68
  if type_value is None:
45
69
  return None
@@ -47,17 +47,21 @@ All resource tools operate with the logged-in user's Qingflow permissions.
47
47
  ## App Discovery
48
48
 
49
49
  If `app_key` is unknown, use `app_list` or `app_search` first.
50
+ If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
51
+ If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
50
52
 
51
53
  ## Schema-First Rule
52
54
 
53
- Always call `record_schema_get` before `record_list`, `record_get`, `record_write`, or `record_analyze`.
55
+ Call `record_schema_get(schema_mode="applicant")` before `record_write`.
56
+ Call `app_get` first when the data range is unclear, then use `record_schema_get(schema_mode="browse", view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
54
57
 
55
58
  - All `field_id` values must come from the schema response.
56
59
  - Never guess field names or ids.
57
60
 
58
61
  ## Schema Scope
59
62
 
60
- `record_schema_get` returns the current user's applicant-node visible fields only.
63
+ `record_schema_get(schema_mode="applicant")` returns the current user's applicant-node visible fields for write/create.
64
+ `record_schema_get(schema_mode="browse", view_id=...)` returns browse-schema fields for the selected accessible view.
61
65
 
62
66
  - Hidden fields are omitted.
63
67
  - Missing fields mean the field is not visible in the current permission scope.
@@ -65,7 +69,9 @@ Always call `record_schema_get` before `record_list`, `record_get`, `record_writ
65
69
 
66
70
  ## Analytics Path
67
71
 
68
- `record_schema_get -> record_analyze`
72
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_analyze`
73
+
74
+ Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
69
75
 
70
76
  Use this DSL shape:
71
77
 
@@ -86,7 +92,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
86
92
 
87
93
  ## Record CRUD Path
88
94
 
89
- `record_schema_get -> record_list / record_get / record_write`
95
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
96
+ `record_schema_get(schema_mode="applicant") -> record_write`
90
97
 
91
98
  - Use `columns` as `[{{field_id}}]`
92
99
  - Use `where` items as `{{field_id, op, value}}`
@@ -27,6 +27,8 @@ def build_user_server() -> FastMCP:
27
27
  ## App Discovery
28
28
 
29
29
  If `app_key` is unknown, use `app_list` or `app_search` first.
30
+ If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
31
+ If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
30
32
 
31
33
  ## Shared Helper
32
34
 
@@ -38,14 +40,16 @@ If `app_key` is unknown, use `app_list` or `app_search` first.
38
40
 
39
41
  ## Schema-First Rule
40
42
 
41
- Always call `record_schema_get` before `record_list`, `record_get`, `record_write`, or `record_analyze`.
43
+ Call `record_schema_get(schema_mode="applicant")` before `record_write`.
44
+ Call `app_get` first when the data range is unclear, then use `record_schema_get(schema_mode="browse", view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
42
45
 
43
46
  - All `field_id` values must come from the schema response.
44
47
  - Never guess field names or ids.
45
48
 
46
49
  ## Schema Scope
47
50
 
48
- `record_schema_get` returns the current user's applicant-node visible fields only.
51
+ `record_schema_get(schema_mode="applicant")` returns the current user's applicant-node visible fields for write/create.
52
+ `record_schema_get(schema_mode="browse", view_id=...)` returns browse-schema fields for the selected accessible view.
49
53
 
50
54
  - Hidden fields are omitted.
51
55
  - Missing fields mean the field is not visible in the current permission scope.
@@ -53,7 +57,9 @@ Always call `record_schema_get` before `record_list`, `record_get`, `record_writ
53
57
 
54
58
  ## Analytics Path
55
59
 
56
- `record_schema_get -> record_analyze`
60
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_analyze`
61
+
62
+ Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
57
63
 
58
64
  Use this DSL shape:
59
65
 
@@ -74,7 +80,8 @@ Analysis answers must include concrete numbers. When applicable, include percent
74
80
 
75
81
  ## Record CRUD Path
76
82
 
77
- `record_schema_get -> record_list / record_get / record_write`
83
+ `app_get -> record_schema_get(schema_mode="browse", view_id=...) -> record_list / record_get`
84
+ `record_schema_get(schema_mode="applicant") -> record_write`
78
85
 
79
86
  - Use `columns` as `[{{field_id}}]`
80
87
  - Use `where` items as `{{field_id, op, value}}`
@@ -201,6 +208,10 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
201
208
  def app_search(profile: str = DEFAULT_PROFILE, keyword: str = "", page_num: int = 1, page_size: int = 50) -> dict:
202
209
  return apps.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
203
210
 
211
+ @server.tool()
212
+ def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
213
+ return apps.app_get(profile=profile, app_key=app_key)
214
+
204
215
  @server.tool()
205
216
  def file_get_upload_info(
206
217
  profile: str = DEFAULT_PROFILE,
@@ -9,7 +9,7 @@ from ..backend_client import BackendRequestContext
9
9
  from ..config import DEFAULT_PROFILE
10
10
  from ..errors import QingflowApiError, raise_tool_error
11
11
  from ..json_types import JSONObject
12
- from ..list_type_labels import get_app_publish_status_label
12
+ from ..list_type_labels import SYSTEM_VIEW_DEFINITIONS, get_app_publish_status_label
13
13
  from .base import ToolBase
14
14
 
15
15
 
@@ -23,6 +23,10 @@ class AppTools(ToolBase):
23
23
  def app_search(profile: str = DEFAULT_PROFILE, keyword: str = "", page_num: int = 1, page_size: int = 50) -> JSONObject:
24
24
  return self.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
25
25
 
26
+ @mcp.tool()
27
+ def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> JSONObject:
28
+ return self.app_get(profile=profile, app_key=app_key)
29
+
26
30
  @mcp.tool()
27
31
  def app_get_base(profile: str = DEFAULT_PROFILE, app_key: str = "", include_raw: bool = False) -> JSONObject:
28
32
  return self.app_get_base(profile=profile, app_key=app_key, include_raw=include_raw)
@@ -132,6 +136,50 @@ class AppTools(ToolBase):
132
136
 
133
137
  return self._run(profile, runner)
134
138
 
139
+ def app_get(self, *, profile: str, app_key: str) -> JSONObject:
140
+ self._require_app_key(app_key)
141
+
142
+ def runner(session_profile, context):
143
+ warnings: list[JSONObject] = []
144
+ app_name = app_key
145
+
146
+ try:
147
+ base_info = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
148
+ if isinstance(base_info, dict):
149
+ app_name = (
150
+ str(base_info.get("formTitle") or base_info.get("title") or base_info.get("appName") or app_key).strip()
151
+ or app_key
152
+ )
153
+ except QingflowApiError as exc:
154
+ if exc.backend_code not in {40002, 40027}:
155
+ raise
156
+ warnings.append(
157
+ {
158
+ "code": "APP_BASE_INFO_UNAVAILABLE",
159
+ "message": f"app_get could not load base info for {app_key}; using app_key as app_name.",
160
+ }
161
+ )
162
+
163
+ can_create = self._probe_create_access(context, app_key)
164
+ accessible_views = self._resolve_accessible_system_views(context, app_key)
165
+ accessible_views.extend(self._resolve_accessible_custom_views(context, app_key))
166
+
167
+ return {
168
+ "profile": profile,
169
+ "ws_id": session_profile.selected_ws_id,
170
+ "ok": True,
171
+ "request_route": {"base_url": context.base_url, "qf_version": context.qf_version},
172
+ "warnings": warnings,
173
+ "data": {
174
+ "app_key": app_key,
175
+ "app_name": app_name,
176
+ "can_create": can_create,
177
+ "accessible_views": accessible_views,
178
+ },
179
+ }
180
+
181
+ return self._run(profile, runner)
182
+
135
183
  def app_get_base(self, *, profile: str, app_key: str, include_raw: bool = False) -> JSONObject:
136
184
  self._require_app_key(app_key)
137
185
 
@@ -313,6 +361,67 @@ class AppTools(ToolBase):
313
361
  if not app_key:
314
362
  raise_tool_error(QingflowApiError.config_error("app_key is required"))
315
363
 
364
+ def _probe_create_access(self, context: BackendRequestContext, app_key: str) -> bool:
365
+ try:
366
+ self.backend.request(
367
+ "GET",
368
+ context,
369
+ f"/app/{app_key}/form",
370
+ params={"type": 2, "beingApply": True},
371
+ )
372
+ return True
373
+ except QingflowApiError as exc:
374
+ if exc.backend_code in {40002, 40027}:
375
+ return False
376
+ raise
377
+
378
+ def _probe_list_type_access(self, context: BackendRequestContext, app_key: str, list_type: int) -> bool:
379
+ try:
380
+ self.backend.request(
381
+ "POST",
382
+ context,
383
+ f"/app/{app_key}/apply/filter",
384
+ json_body={"type": list_type, "pageNum": 1, "pageSize": 1},
385
+ )
386
+ return True
387
+ except QingflowApiError as exc:
388
+ if exc.backend_code in {40002, 40027}:
389
+ return False
390
+ raise
391
+
392
+ def _resolve_accessible_system_views(self, context: BackendRequestContext, app_key: str) -> list[JSONObject]:
393
+ items: list[JSONObject] = []
394
+ for view_id, list_type, name in SYSTEM_VIEW_DEFINITIONS:
395
+ if not self._probe_list_type_access(context, app_key, list_type):
396
+ continue
397
+ items.append({"view_id": view_id, "name": name, "kind": "system", "analysis_supported": True})
398
+ return items
399
+
400
+ def _resolve_accessible_custom_views(self, context: BackendRequestContext, app_key: str) -> list[JSONObject]:
401
+ try:
402
+ payload = self.backend.request("GET", context, f"/app/{app_key}/view/viewList")
403
+ except QingflowApiError as exc:
404
+ if exc.backend_code in {40002, 40027}:
405
+ return []
406
+ raise
407
+
408
+ items: list[JSONObject] = []
409
+ for item in _normalize_view_list(payload):
410
+ view_key = str(item.get("viewKey") or "").strip()
411
+ if not view_key:
412
+ continue
413
+ normalized: JSONObject = {
414
+ "view_id": f"custom:{view_key}",
415
+ "name": str(item.get("viewName") or view_key).strip() or view_key,
416
+ "kind": "custom",
417
+ }
418
+ view_type = str(item.get("viewType") or item.get("viewgraphType") or "").strip()
419
+ if view_type:
420
+ normalized["view_type"] = view_type
421
+ normalized["analysis_supported"] = _analysis_supported_for_view_type(view_type or None)
422
+ items.append(normalized)
423
+ return items
424
+
316
425
  def _compact_base_info(self, result: dict[str, Any]) -> JSONObject:
317
426
  publish_status = result.get("appPublishStatus")
318
427
  auth = result.get("auth") if isinstance(result.get("auth"), dict) else {}
@@ -562,6 +671,29 @@ def _normalize_form_type(value: int | str) -> int:
562
671
  raise_tool_error(QingflowApiError.config_error("form_type must be a positive integer or one of: default, form, schema, new, draft, edit"))
563
672
 
564
673
 
674
+ def _normalize_view_list(payload: Any) -> list[JSONObject]:
675
+ if not isinstance(payload, list):
676
+ return []
677
+ flattened: list[JSONObject] = []
678
+ for group in payload:
679
+ if not isinstance(group, dict):
680
+ continue
681
+ view_list = group.get("viewList")
682
+ if not isinstance(view_list, list):
683
+ continue
684
+ for item in view_list:
685
+ if isinstance(item, dict) and item.get("viewKey"):
686
+ flattened.append(item)
687
+ return flattened
688
+
689
+
690
+ def _analysis_supported_for_view_type(view_type: str | None) -> bool:
691
+ normalized = str(view_type or "").strip().lower()
692
+ if not normalized:
693
+ return True
694
+ return normalized not in {"boardview", "ganttview"}
695
+
696
+
565
697
  def _coerce_positive_int(value: Any) -> int | None:
566
698
  try:
567
699
  number = int(value)