@josephyan/qingflow-cli 0.2.0-beta.55

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.
Files changed (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,648 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from typing import Any
5
+
6
+ from mcp.server.fastmcp import FastMCP
7
+
8
+ from ..config import DEFAULT_PROFILE
9
+ from ..errors import QingflowApiError, raise_tool_error
10
+ from .base import ToolBase
11
+
12
+
13
+ ALLOWED_DIRECTORY_SEARCH_SCOPES = {"MEMBER", "DEPT"}
14
+
15
+
16
+ class DirectoryTools(ToolBase):
17
+ def register(self, mcp: FastMCP) -> None:
18
+ @mcp.tool()
19
+ def directory_search(
20
+ profile: str = DEFAULT_PROFILE,
21
+ query: str = "",
22
+ scopes: list[str] | None = None,
23
+ page_num: int = 1,
24
+ page_size: int = 20,
25
+ ) -> dict[str, Any]:
26
+ return self.directory_search(
27
+ profile=profile,
28
+ query=query,
29
+ scopes=scopes,
30
+ page_num=page_num,
31
+ page_size=page_size,
32
+ )
33
+
34
+ @mcp.tool()
35
+ def directory_list_internal_users(
36
+ profile: str = DEFAULT_PROFILE,
37
+ keyword: str | None = None,
38
+ department_id: int | None = None,
39
+ role_id: int | None = None,
40
+ page_num: int = 1,
41
+ page_size: int = 20,
42
+ include_disabled: bool = False,
43
+ ) -> dict[str, Any]:
44
+ return self.directory_list_internal_users(
45
+ profile=profile,
46
+ keyword=keyword,
47
+ dept_id=department_id,
48
+ role_id=role_id,
49
+ page_num=page_num,
50
+ page_size=page_size,
51
+ contain_disable=include_disabled,
52
+ )
53
+
54
+ @mcp.tool()
55
+ def directory_list_all_internal_users(
56
+ profile: str = DEFAULT_PROFILE,
57
+ keyword: str | None = None,
58
+ department_id: int | None = None,
59
+ role_id: int | None = None,
60
+ page_size: int = 200,
61
+ include_disabled: bool = False,
62
+ max_pages: int = 100,
63
+ ) -> dict[str, Any]:
64
+ return self.directory_list_all_internal_users(
65
+ profile=profile,
66
+ keyword=keyword,
67
+ dept_id=department_id,
68
+ role_id=role_id,
69
+ page_size=page_size,
70
+ contain_disable=include_disabled,
71
+ max_pages=max_pages,
72
+ )
73
+
74
+ @mcp.tool()
75
+ def directory_list_internal_departments(
76
+ profile: str = DEFAULT_PROFILE,
77
+ keyword: str = "",
78
+ page_num: int = 1,
79
+ page_size: int = 20,
80
+ ) -> dict[str, Any]:
81
+ return self.directory_list_internal_departments(
82
+ profile=profile,
83
+ keyword=keyword,
84
+ page_num=page_num,
85
+ page_size=page_size,
86
+ )
87
+
88
+ @mcp.tool()
89
+ def directory_list_all_departments(
90
+ profile: str = DEFAULT_PROFILE,
91
+ parent_department_id: int | None = None,
92
+ max_depth: int = 20,
93
+ max_items: int = 2000,
94
+ ) -> dict[str, Any]:
95
+ return self.directory_list_all_departments(
96
+ profile=profile,
97
+ parent_dept_id=parent_department_id,
98
+ max_depth=max_depth,
99
+ max_items=max_items,
100
+ )
101
+
102
+ @mcp.tool()
103
+ def directory_list_sub_departments(
104
+ profile: str = DEFAULT_PROFILE,
105
+ parent_department_id: int | None = None,
106
+ ) -> dict[str, Any]:
107
+ return self.directory_list_sub_departments(profile=profile, parent_dept_id=parent_department_id)
108
+
109
+ @mcp.tool()
110
+ def directory_list_external_members(
111
+ profile: str = DEFAULT_PROFILE,
112
+ keyword: str | None = None,
113
+ page_num: int = 1,
114
+ page_size: int = 20,
115
+ simple: bool = False,
116
+ ) -> dict[str, Any]:
117
+ return self.directory_list_external_members(
118
+ profile=profile,
119
+ keyword=keyword,
120
+ page_num=page_num,
121
+ page_size=page_size,
122
+ simple=simple,
123
+ )
124
+
125
+ def directory_search(
126
+ self,
127
+ *,
128
+ profile: str,
129
+ query: str,
130
+ scopes: list[str] | None,
131
+ page_num: int,
132
+ page_size: int,
133
+ ) -> dict[str, Any]:
134
+ normalized_scopes = scopes or ["MEMBER", "DEPT"]
135
+ invalid_scopes = [scope for scope in normalized_scopes if scope not in ALLOWED_DIRECTORY_SEARCH_SCOPES]
136
+ if invalid_scopes:
137
+ raise_tool_error(QingflowApiError.not_supported(f"directory_search only supports internal scopes {sorted(ALLOWED_DIRECTORY_SEARCH_SCOPES)}; got {invalid_scopes}"))
138
+ if not query:
139
+ raise_tool_error(QingflowApiError.config_error("query is required"))
140
+
141
+ def runner(session_profile, context):
142
+ result = self.backend.request(
143
+ "POST",
144
+ context,
145
+ "/member/search",
146
+ json_body={
147
+ "dimensions": normalized_scopes,
148
+ "searchKey": query,
149
+ "pageNum": page_num,
150
+ "pageSize": page_size,
151
+ },
152
+ )
153
+ return {
154
+ "profile": profile,
155
+ "ws_id": session_profile.selected_ws_id,
156
+ "request_route": self._request_route_payload(context),
157
+ "result": result,
158
+ }
159
+
160
+ raw = self._run(profile, runner)
161
+ items = [item for item in _directory_items(raw.get("result")) if isinstance(item, dict)]
162
+ return self._public_directory_response(
163
+ raw,
164
+ items=items,
165
+ pagination={
166
+ "page": page_num,
167
+ "page_size": page_size,
168
+ "returned_items": len(items),
169
+ "reported_total": _coerce_int(_payload_value(raw.get("result"), "total")),
170
+ "page_amount": _coerce_int(_payload_value(raw.get("result"), "pageAmount")),
171
+ },
172
+ selection={"query": query, "scopes": normalized_scopes},
173
+ )
174
+
175
+ def directory_list_internal_users(
176
+ self,
177
+ *,
178
+ profile: str,
179
+ keyword: str | None,
180
+ dept_id: int | None,
181
+ role_id: int | None,
182
+ page_num: int,
183
+ page_size: int,
184
+ contain_disable: bool,
185
+ ) -> dict[str, Any]:
186
+ def runner(session_profile, context):
187
+ params: dict[str, Any] = {
188
+ "pageNum": page_num,
189
+ "pageSize": page_size,
190
+ "containDisable": contain_disable,
191
+ }
192
+ if keyword:
193
+ params["keyword"] = keyword
194
+ if dept_id is not None:
195
+ params["deptId"] = dept_id
196
+ if role_id is not None:
197
+ params["roleId"] = role_id
198
+ result = self.backend.request("GET", context, "/contact", params=params)
199
+ return {
200
+ "profile": profile,
201
+ "ws_id": session_profile.selected_ws_id,
202
+ "request_route": self._request_route_payload(context),
203
+ "result": result,
204
+ }
205
+
206
+ raw = self._run(profile, runner)
207
+ items = [item for item in _directory_items(raw.get("result")) if isinstance(item, dict)]
208
+ return self._public_directory_response(
209
+ raw,
210
+ items=items,
211
+ pagination={
212
+ "page": page_num,
213
+ "page_size": page_size,
214
+ "returned_items": len(items),
215
+ "reported_total": _coerce_int(_payload_value(raw.get("result"), "total")),
216
+ "page_amount": _coerce_int(_payload_value(raw.get("result"), "pageAmount")),
217
+ },
218
+ selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
219
+ )
220
+
221
+ def directory_list_all_internal_users(
222
+ self,
223
+ *,
224
+ profile: str,
225
+ keyword: str | None,
226
+ dept_id: int | None,
227
+ role_id: int | None,
228
+ page_size: int,
229
+ contain_disable: bool,
230
+ max_pages: int,
231
+ ) -> dict[str, Any]:
232
+ if page_size <= 0:
233
+ raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
234
+ if max_pages <= 0:
235
+ raise_tool_error(QingflowApiError.config_error("max_pages must be positive"))
236
+
237
+ def runner(session_profile, context):
238
+ current_page = 1
239
+ fetched_pages = 0
240
+ has_more = False
241
+ reported_total: int | None = None
242
+ seen_keys: set[str] = set()
243
+ items: list[dict[str, Any]] = []
244
+ while fetched_pages < max_pages:
245
+ params: dict[str, Any] = {
246
+ "pageNum": current_page,
247
+ "pageSize": page_size,
248
+ "containDisable": contain_disable,
249
+ }
250
+ if keyword:
251
+ params["keyword"] = keyword
252
+ if dept_id is not None:
253
+ params["deptId"] = dept_id
254
+ if role_id is not None:
255
+ params["roleId"] = role_id
256
+ result = self.backend.request("GET", context, "/contact", params=params)
257
+ page_items = _directory_items(result)
258
+ if reported_total is None:
259
+ reported_total = _coerce_int(_payload_value(result, "total"))
260
+ for item in page_items:
261
+ if not isinstance(item, dict):
262
+ continue
263
+ member_key = _directory_member_key(item)
264
+ if member_key in seen_keys:
265
+ continue
266
+ seen_keys.add(member_key)
267
+ items.append(dict(item))
268
+ fetched_pages += 1
269
+ has_more = _directory_has_more(result, current_page=current_page, page_size=page_size, returned_items=len(page_items))
270
+ if not has_more:
271
+ break
272
+ current_page += 1
273
+ return {
274
+ "profile": profile,
275
+ "ws_id": session_profile.selected_ws_id,
276
+ "request_route": self._request_route_payload(context),
277
+ "items": items,
278
+ "pagination": {
279
+ "page_size": page_size,
280
+ "fetched_pages": fetched_pages,
281
+ "returned_items": len(items),
282
+ "reported_total": reported_total,
283
+ "is_complete": not has_more,
284
+ "has_more": has_more,
285
+ "next_page_num": current_page + 1 if has_more else None,
286
+ "max_pages": max_pages,
287
+ },
288
+ }
289
+
290
+ raw = self._run(profile, runner)
291
+ return self._public_directory_response(
292
+ raw,
293
+ items=[item for item in raw.get("items", []) if isinstance(item, dict)],
294
+ pagination=raw.get("pagination", {}),
295
+ selection={"keyword": keyword, "department_id": dept_id, "role_id": role_id, "include_disabled": contain_disable},
296
+ )
297
+
298
+ def directory_list_internal_departments(
299
+ self,
300
+ *,
301
+ profile: str,
302
+ keyword: str,
303
+ page_num: int,
304
+ page_size: int,
305
+ ) -> dict[str, Any]:
306
+ if page_num <= 0:
307
+ raise_tool_error(QingflowApiError.config_error("page_num must be positive"))
308
+ if page_size <= 0:
309
+ raise_tool_error(QingflowApiError.config_error("page_size must be positive"))
310
+ normalized_keyword = keyword.strip()
311
+
312
+ if not normalized_keyword:
313
+ def runner(session_profile, context):
314
+ fetch_limit = max((page_num + 1) * page_size + 1, page_size + 1)
315
+ items, truncated, deepest_depth = self._walk_department_tree(
316
+ context,
317
+ parent_dept_id=None,
318
+ max_depth=20,
319
+ max_items=fetch_limit,
320
+ )
321
+ start = (page_num - 1) * page_size
322
+ page_items = items[start : start + page_size]
323
+ reported_total = None if truncated else len(items)
324
+ page_amount = None if truncated else ((len(items) + page_size - 1) // page_size if items else 0)
325
+ if truncated and page_items:
326
+ page_amount = max(page_num + 1, (start + len(page_items) + page_size - 1) // page_size)
327
+ return {
328
+ "profile": profile,
329
+ "ws_id": session_profile.selected_ws_id,
330
+ "request_route": self._request_route_payload(context),
331
+ "items": page_items,
332
+ "pagination": {
333
+ "page": page_num,
334
+ "page_size": page_size,
335
+ "returned_items": len(page_items),
336
+ "reported_total": reported_total,
337
+ "page_amount": page_amount,
338
+ "depth_scanned": deepest_depth + 1 if page_items else 0,
339
+ },
340
+ }
341
+
342
+ raw = self._run(profile, runner)
343
+ items = [item for item in raw.get("items", []) if isinstance(item, dict)]
344
+ return self._public_directory_response(
345
+ raw,
346
+ items=items,
347
+ pagination=raw.get("pagination", {}),
348
+ selection={"keyword": None},
349
+ )
350
+
351
+ def runner(session_profile, context):
352
+ result = self.backend.request(
353
+ "GET",
354
+ context,
355
+ "/contact/deptByPage",
356
+ params={"keyword": normalized_keyword, "pageNum": page_num, "pageSize": page_size},
357
+ )
358
+ return {
359
+ "profile": profile,
360
+ "ws_id": session_profile.selected_ws_id,
361
+ "request_route": self._request_route_payload(context),
362
+ "page": result,
363
+ }
364
+
365
+ raw = self._run(profile, runner)
366
+ items = [item for item in _directory_items(raw.get("page")) if isinstance(item, dict)]
367
+ return self._public_directory_response(
368
+ raw,
369
+ items=items,
370
+ pagination={
371
+ "page": page_num,
372
+ "page_size": page_size,
373
+ "returned_items": len(items),
374
+ "reported_total": _coerce_int(_payload_value(raw.get("page"), "total")),
375
+ "page_amount": _coerce_int(_payload_value(raw.get("page"), "pageAmount")),
376
+ },
377
+ selection={"keyword": normalized_keyword},
378
+ )
379
+
380
+ def directory_list_all_departments(
381
+ self,
382
+ *,
383
+ profile: str,
384
+ parent_dept_id: int | None,
385
+ max_depth: int,
386
+ max_items: int,
387
+ ) -> dict[str, Any]:
388
+ if max_depth < 0:
389
+ raise_tool_error(QingflowApiError.config_error("max_depth must be non-negative"))
390
+ if max_items <= 0:
391
+ raise_tool_error(QingflowApiError.config_error("max_items must be positive"))
392
+
393
+ def runner(session_profile, context):
394
+ items, truncated, deepest_depth = self._walk_department_tree(
395
+ context,
396
+ parent_dept_id=parent_dept_id,
397
+ max_depth=max_depth,
398
+ max_items=max_items,
399
+ )
400
+ if not items and parent_dept_id is None:
401
+ items, truncated, deepest_depth = self._walk_department_tree(
402
+ context,
403
+ parent_dept_id=0,
404
+ max_depth=max_depth,
405
+ max_items=max_items,
406
+ )
407
+ return {
408
+ "profile": profile,
409
+ "ws_id": session_profile.selected_ws_id,
410
+ "request_route": self._request_route_payload(context),
411
+ "items": items,
412
+ "pagination": {
413
+ "root_parent_department_id": parent_dept_id,
414
+ "returned_items": len(items),
415
+ "fetched_pages": deepest_depth + 1 if items else 0,
416
+ "is_complete": not truncated,
417
+ "has_more": truncated,
418
+ "max_items": max_items,
419
+ },
420
+ }
421
+
422
+ raw = self._run(profile, runner)
423
+ return self._public_directory_response(
424
+ raw,
425
+ items=[item for item in raw.get("items", []) if isinstance(item, dict)],
426
+ pagination=raw.get("pagination", {}),
427
+ selection={"parent_department_id": parent_dept_id, "max_depth": max_depth, "max_items": max_items},
428
+ )
429
+
430
+ def directory_list_sub_departments(self, *, profile: str, parent_dept_id: int | None) -> dict[str, Any]:
431
+ def runner(session_profile, context):
432
+ params: dict[str, Any] = {}
433
+ if parent_dept_id is not None:
434
+ params["parentDeptId"] = parent_dept_id
435
+ result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
436
+ return {
437
+ "profile": profile,
438
+ "ws_id": session_profile.selected_ws_id,
439
+ "request_route": self._request_route_payload(context),
440
+ "items": result,
441
+ }
442
+
443
+ raw = self._run(profile, runner)
444
+ items = [item for item in raw.get("items", []) if isinstance(item, dict)]
445
+ return self._public_directory_response(
446
+ raw,
447
+ items=items,
448
+ pagination={"returned_items": len(items)},
449
+ selection={"parent_department_id": parent_dept_id},
450
+ )
451
+
452
+ def directory_list_external_members(
453
+ self,
454
+ *,
455
+ profile: str,
456
+ keyword: str | None,
457
+ page_num: int,
458
+ page_size: int,
459
+ simple: bool,
460
+ ) -> dict[str, Any]:
461
+ def runner(session_profile, context):
462
+ if simple:
463
+ result = self.backend.request(
464
+ "POST",
465
+ context,
466
+ "/external/member/simple/pageList",
467
+ json_body={"keyword": keyword, "pageNum": page_num, "pageSize": page_size},
468
+ )
469
+ else:
470
+ params: dict[str, Any] = {"pageNum": page_num, "pageSize": page_size}
471
+ if keyword:
472
+ params["keyword"] = keyword
473
+ result = self.backend.request("GET", context, "/external/member/pageList", params=params)
474
+ return {
475
+ "profile": profile,
476
+ "ws_id": session_profile.selected_ws_id,
477
+ "request_route": self._request_route_payload(context),
478
+ "page": result,
479
+ "simple": simple,
480
+ }
481
+
482
+ raw = self._run(profile, runner)
483
+ items = [item for item in _directory_items(raw.get("page")) if isinstance(item, dict)]
484
+ return self._public_directory_response(
485
+ raw,
486
+ items=items,
487
+ pagination={
488
+ "page": page_num,
489
+ "page_size": page_size,
490
+ "returned_items": len(items),
491
+ "reported_total": _coerce_int(_payload_value(raw.get("page"), "total")),
492
+ "page_amount": _coerce_int(_payload_value(raw.get("page"), "pageAmount")),
493
+ },
494
+ selection={"keyword": keyword, "simple": simple},
495
+ )
496
+
497
+ def _request_route_payload(self, context) -> dict[str, Any]: # type: ignore[no-untyped-def]
498
+ describe_route = getattr(self.backend, "describe_route", None)
499
+ if callable(describe_route):
500
+ payload = describe_route(context)
501
+ if isinstance(payload, dict):
502
+ return payload
503
+ return {
504
+ "base_url": getattr(context, "base_url", None),
505
+ "qf_version": getattr(context, "qf_version", None),
506
+ "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
507
+ }
508
+
509
+ def _public_directory_response(
510
+ self,
511
+ raw: dict[str, Any],
512
+ *,
513
+ items: list[dict[str, Any]],
514
+ pagination: dict[str, Any],
515
+ selection: dict[str, Any],
516
+ ) -> dict[str, Any]:
517
+ response = dict(raw)
518
+ response["ok"] = bool(raw.get("ok", True))
519
+ response["warnings"] = []
520
+ response["output_profile"] = "normal"
521
+ response["data"] = {
522
+ "items": items,
523
+ "pagination": pagination,
524
+ "selection": selection,
525
+ }
526
+ return response
527
+
528
+ def _walk_department_tree(
529
+ self,
530
+ context, # type: ignore[no-untyped-def]
531
+ *,
532
+ parent_dept_id: int | None,
533
+ max_depth: int,
534
+ max_items: int,
535
+ ) -> tuple[list[dict[str, Any]], bool, int]:
536
+ queue: deque[tuple[int | None, int]] = deque([(parent_dept_id, 0)])
537
+ seen_ids: set[int] = set()
538
+ requested_parents: set[int | None] = set()
539
+ items: list[dict[str, Any]] = []
540
+ truncated = False
541
+ deepest_depth = 0
542
+ while queue:
543
+ current_parent, depth = queue.popleft()
544
+ if current_parent in requested_parents:
545
+ continue
546
+ requested_parents.add(current_parent)
547
+ params: dict[str, Any] = {}
548
+ if current_parent is not None:
549
+ params["parentDeptId"] = current_parent
550
+ result = self.backend.request("GET", context, "/contact/subDeptList", params=params)
551
+ page_items = _directory_items(result)
552
+ for item in page_items:
553
+ if not isinstance(item, dict):
554
+ continue
555
+ dept_id = _department_id(item)
556
+ if dept_id is None or dept_id in seen_ids:
557
+ continue
558
+ seen_ids.add(dept_id)
559
+ entry = dict(item)
560
+ if current_parent is not None and "parentDeptId" not in entry:
561
+ entry["parentDeptId"] = current_parent
562
+ entry.setdefault("depth", depth)
563
+ items.append(entry)
564
+ deepest_depth = max(deepest_depth, depth)
565
+ if len(items) >= max_items:
566
+ truncated = True
567
+ return items, truncated, deepest_depth
568
+ if depth < max_depth:
569
+ queue.append((dept_id, depth + 1))
570
+ else:
571
+ truncated = True
572
+ return items, truncated, deepest_depth
573
+
574
+
575
+ def _payload_value(payload: Any, key: str) -> Any:
576
+ if isinstance(payload, dict):
577
+ if key in payload:
578
+ return payload.get(key)
579
+ for container_key in ("data", "result", "page"):
580
+ nested = payload.get(container_key)
581
+ if isinstance(nested, dict) and key in nested:
582
+ return nested.get(key)
583
+ return None
584
+
585
+
586
+ def _directory_items(payload: Any) -> list[Any]:
587
+ if isinstance(payload, list):
588
+ return payload
589
+ if not isinstance(payload, dict):
590
+ return []
591
+ for key in ("list", "items", "rows"):
592
+ value = payload.get(key)
593
+ if isinstance(value, list):
594
+ return value
595
+ for key in ("data", "result", "page"):
596
+ nested = payload.get(key)
597
+ if isinstance(nested, list):
598
+ return nested
599
+ if isinstance(nested, dict):
600
+ for nested_key in ("list", "items", "rows", "result"):
601
+ value = nested.get(nested_key)
602
+ if isinstance(value, list):
603
+ return value
604
+ return []
605
+
606
+
607
+ def _directory_has_more(payload: Any, *, current_page: int, page_size: int, returned_items: int) -> bool:
608
+ page_amount = _coerce_int(_payload_value(payload, "pageAmount"))
609
+ if page_amount is not None:
610
+ return current_page < page_amount
611
+ total = _coerce_int(_payload_value(payload, "total"))
612
+ if total is not None:
613
+ return current_page * page_size < total
614
+ return returned_items >= page_size and returned_items > 0
615
+
616
+
617
+ def _directory_member_key(item: dict[str, Any]) -> str:
618
+ for key in ("uid", "id", "userId"):
619
+ value = item.get(key)
620
+ if value is not None:
621
+ return f"{key}:{value}"
622
+ for key in ("email", "nickName", "name"):
623
+ value = item.get(key)
624
+ if isinstance(value, str) and value:
625
+ return f"{key}:{value}"
626
+ return repr(sorted(item.items()))
627
+
628
+
629
+ def _department_id(item: dict[str, Any]) -> int | None:
630
+ return _coerce_int(item.get("deptId", item.get("id")))
631
+
632
+
633
+ def _coerce_int(value: Any) -> int | None:
634
+ if isinstance(value, bool) or value is None:
635
+ return None
636
+ if isinstance(value, int):
637
+ return value
638
+ if isinstance(value, float):
639
+ return int(value)
640
+ if isinstance(value, str):
641
+ text = value.strip()
642
+ if not text:
643
+ return None
644
+ try:
645
+ return int(text)
646
+ except ValueError:
647
+ return None
648
+ return None