@josephyan/qingflow-app-builder-mcp 0.2.0-beta.1010 → 0.2.0-beta.1012

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-builder-mcp@0.2.0-beta.1010
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.1012
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.1010 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.1012 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.1010",
3
+ "version": "0.2.0-beta.1012",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution 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.0b1010"
7
+ version = "0.2.0b1012"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -5,7 +5,7 @@ from pathlib import Path
5
5
 
6
6
  __all__ = ["__version__"]
7
7
 
8
- _FALLBACK_VERSION = "0.2.0b1010"
8
+ _FALLBACK_VERSION = "0.2.0b1012"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import argparse
4
4
 
5
5
  from ..context import CliContext
6
+ from .common import load_list_arg
6
7
 
7
8
 
8
9
  def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
@@ -12,6 +13,12 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
12
13
  start = export_subparsers.add_parser("start", help="启动导出")
13
14
  start.add_argument("--app-key", required=True)
14
15
  start.add_argument("--view-id", default="system:all")
16
+ start.add_argument("--column", dest="columns", action="append", type=int, default=[], help="只导出这些 field_id;不传时导出当前视图全部字段")
17
+ start.add_argument("--columns-file", help="JSON/YAML list,内容与 --column 语义一致")
18
+ start.add_argument("--where-file", help="JSON/YAML list,内容与 record list 的 where DSL 一致;内部先查命中 record_id 再走原生导出")
19
+ start.add_argument("--record-id", dest="record_ids", action="append", default=[], help="只导出这些 record_id;不传时导出当前视图全部数据")
20
+ start.add_argument("--record-ids-file", help="JSON/YAML list,内容与 --record-id 语义一致")
21
+ start.add_argument("--include-workflow-log", action=argparse.BooleanOptionalAction, default=False, help="是否同时导出流程日志")
15
22
  start.set_defaults(handler=_handle_start, format_hint="export_start")
16
23
 
17
24
  status = export_subparsers.add_parser("status", help="查询导出状态")
@@ -26,16 +33,44 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
26
33
  direct = export_subparsers.add_parser("direct", help="直接导出并下载")
27
34
  direct.add_argument("--app-key", required=True)
28
35
  direct.add_argument("--view-id", default="system:all")
36
+ direct.add_argument("--column", dest="columns", action="append", type=int, default=[], help="只导出这些 field_id;不传时导出当前视图全部字段")
37
+ direct.add_argument("--columns-file", help="JSON/YAML list,内容与 --column 语义一致")
38
+ direct.add_argument("--where-file", help="JSON/YAML list,内容与 record list 的 where DSL 一致;内部先查命中 record_id 再走原生导出")
39
+ direct.add_argument("--record-id", dest="record_ids", action="append", default=[], help="只导出这些 record_id;不传时导出当前视图全部数据")
40
+ direct.add_argument("--record-ids-file", help="JSON/YAML list,内容与 --record-id 语义一致")
41
+ direct.add_argument("--include-workflow-log", action=argparse.BooleanOptionalAction, default=False, help="是否同时导出流程日志")
29
42
  direct.add_argument("--download-to-path")
30
43
  direct.add_argument("--wait-timeout-seconds", type=float)
31
44
  direct.set_defaults(handler=_handle_direct, format_hint="export_direct")
32
45
 
33
46
 
47
+ def _columns(args: argparse.Namespace) -> list[int | dict]:
48
+ columns: list[int | dict] = list(args.columns or [])
49
+ if args.columns_file:
50
+ columns.extend(load_list_arg(args.columns_file, option_name="--columns-file"))
51
+ return columns
52
+
53
+
54
+ def _record_ids(args: argparse.Namespace) -> list[str | int]:
55
+ record_ids: list[str | int] = list(args.record_ids or [])
56
+ if args.record_ids_file:
57
+ record_ids.extend(load_list_arg(args.record_ids_file, option_name="--record-ids-file"))
58
+ return record_ids
59
+
60
+
61
+ def _where(args: argparse.Namespace) -> list[dict]:
62
+ return load_list_arg(args.where_file, option_name="--where-file") if args.where_file else []
63
+
64
+
34
65
  def _handle_start(args: argparse.Namespace, context: CliContext) -> dict:
35
66
  return context.exports.record_export_start(
36
67
  profile=args.profile,
37
68
  app_key=args.app_key,
38
69
  view_id=args.view_id,
70
+ columns=_columns(args),
71
+ where=_where(args),
72
+ record_ids=_record_ids(args),
73
+ include_workflow_log=args.include_workflow_log,
39
74
  )
40
75
 
41
76
 
@@ -59,6 +94,10 @@ def _handle_direct(args: argparse.Namespace, context: CliContext) -> dict:
59
94
  profile=args.profile,
60
95
  app_key=args.app_key,
61
96
  view_id=args.view_id,
97
+ columns=_columns(args),
98
+ where=_where(args),
99
+ record_ids=_record_ids(args),
100
+ include_workflow_log=args.include_workflow_log,
62
101
  download_to_path=args.download_to_path,
63
102
  wait_timeout_seconds=args.wait_timeout_seconds,
64
103
  )
@@ -426,6 +426,21 @@ def _format_export_common(result: dict[str, Any], *, title: str | None = None) -
426
426
  f"Rows: {result.get('num') if result.get('num') is not None else '-'}",
427
427
  ]
428
428
  )
429
+ row_scope = result.get("row_scope")
430
+ if row_scope not in (None, ""):
431
+ lines.append(f"Row Scope: {row_scope}")
432
+ selected_record_count = result.get("selected_record_count")
433
+ if selected_record_count not in (None, ""):
434
+ lines.append(f"Selected Rows: {selected_record_count}")
435
+ field_scope = result.get("field_scope")
436
+ if field_scope not in (None, ""):
437
+ lines.append(f"Field Scope: {field_scope}")
438
+ selected_field_count = result.get("selected_field_count")
439
+ if selected_field_count not in (None, ""):
440
+ lines.append(f"Selected Fields: {selected_field_count}")
441
+ include_workflow_log = result.get("include_workflow_log")
442
+ if include_workflow_log not in (None, ""):
443
+ lines.append(f"Include Workflow Log: {include_workflow_log}")
429
444
  file_names = result.get("file_names") if isinstance(result.get("file_names"), list) else []
430
445
  file_urls = result.get("file_urls") if isinstance(result.get("file_urls"), list) else []
431
446
  if file_names or file_urls:
@@ -155,6 +155,17 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
155
155
 
156
156
  - `record_export_direct` is the one-shot export path that starts the export, waits for completion, downloads locally, and still returns remote download links.
157
157
  - Export v1 supports record views only and follows the same public `view_id` semantics as `record_list` (`system:*` and `custom:*`).
158
+ - `record_export_start` / `record_export_direct` support frontend-like row selection:
159
+ - omit `record_ids` to export all rows in the selected view
160
+ - pass `record_ids` to export selected rows only
161
+ - `record_export_start` / `record_export_direct` also support internal query selection:
162
+ - pass `where` to resolve matching `record_id` values first
163
+ - then run native export as selected rows
164
+ - `where` and `record_ids` are mutually exclusive
165
+ - `record_export_start` / `record_export_direct` also support frontend-like column selection:
166
+ - omit `columns` to export all current-view fields
167
+ - pass `columns` to export only selected fields, preserving the provided order
168
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
158
169
 
159
170
  ## Task Workflow Path
160
171
 
@@ -150,6 +150,17 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
150
150
 
151
151
  - `record_export_direct` is the one-shot path that starts export, waits, downloads locally, and still returns remote download links.
152
152
  - Export v1 supports record views only and follows the same public `view_id` semantics as `record_list`.
153
+ - `record_export_start` / `record_export_direct` support frontend-like row selection:
154
+ - omit `record_ids` to export all rows in the selected view
155
+ - pass `record_ids` to export selected rows only
156
+ - `record_export_start` / `record_export_direct` also support internal query selection:
157
+ - pass `where` to resolve matching `record_id` values first
158
+ - then run native export as selected rows
159
+ - `where` and `record_ids` are mutually exclusive
160
+ - `record_export_start` / `record_export_direct` also support frontend-like column selection:
161
+ - omit `columns` to export all current-view fields
162
+ - pass `columns` to export only selected fields, preserving the provided order
163
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
153
164
 
154
165
  ## Task Workflow Path
155
166
 
@@ -233,8 +244,20 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
233
244
  profile: str = DEFAULT_PROFILE,
234
245
  app_key: str = "",
235
246
  view_id: str = "system:all",
247
+ columns: list[dict | int] | None = None,
248
+ where: list[dict] | None = None,
249
+ record_ids: list[str | int] | None = None,
250
+ include_workflow_log: bool = False,
236
251
  ) -> dict:
237
- return exports.record_export_start(profile=profile, app_key=app_key, view_id=view_id)
252
+ return exports.record_export_start(
253
+ profile=profile,
254
+ app_key=app_key,
255
+ view_id=view_id,
256
+ columns=columns or [],
257
+ where=where or [],
258
+ record_ids=record_ids or [],
259
+ include_workflow_log=include_workflow_log,
260
+ )
238
261
 
239
262
  @server.tool()
240
263
  def record_export_status_get(
@@ -260,6 +283,10 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
260
283
  profile: str = DEFAULT_PROFILE,
261
284
  app_key: str = "",
262
285
  view_id: str = "system:all",
286
+ columns: list[dict | int] | None = None,
287
+ where: list[dict] | None = None,
288
+ record_ids: list[str | int] | None = None,
289
+ include_workflow_log: bool = False,
263
290
  download_to_path: str | None = None,
264
291
  wait_timeout_seconds: float | None = None,
265
292
  ) -> dict:
@@ -267,6 +294,10 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
267
294
  profile=profile,
268
295
  app_key=app_key,
269
296
  view_id=view_id,
297
+ columns=columns or [],
298
+ where=where or [],
299
+ record_ids=record_ids or [],
300
+ include_workflow_log=include_workflow_log,
270
301
  download_to_path=download_to_path,
271
302
  wait_timeout_seconds=wait_timeout_seconds,
272
303
  )
@@ -11,12 +11,18 @@ from uuid import uuid4
11
11
 
12
12
  from mcp.server.fastmcp import FastMCP
13
13
 
14
- from ..config import DEFAULT_PROFILE
14
+ from ..config import DEFAULT_PROFILE, DEFAULT_RECORD_LIST_TYPE
15
15
  from ..errors import QingflowApiError
16
16
  from ..export_store import ExportJobStore
17
17
  from ..json_types import JSONObject
18
18
  from .base import ToolBase, tool_cn_name
19
- from .record_tools import AccessibleViewRoute, LAYOUT_ONLY_QUE_TYPES, RecordTools
19
+ from .record_tools import (
20
+ AccessibleViewRoute,
21
+ DEFAULT_LIST_PAGE_SIZE,
22
+ LAYOUT_ONLY_QUE_TYPES,
23
+ RecordTools,
24
+ _normalize_public_column_selectors,
25
+ )
20
26
 
21
27
 
22
28
  EXPORT_STATUS_BY_PROCESS_STATUS = {
@@ -28,6 +34,8 @@ EXPORT_STATUS_BY_PROCESS_STATUS = {
28
34
  }
29
35
  DEFAULT_EXPORT_TIMEOUT_SECONDS = 60.0
30
36
  EXPORT_POLL_INTERVAL_SECONDS = 1.5
37
+ EXPORT_ROWS_LIMIT = 20_000
38
+ EXPORT_CELL_LIMIT = 4_000_000
31
39
  _SAFE_FILE_CHARS = re.compile(r"[^0-9A-Za-z._-]+")
32
40
  LOCAL_TIMEZONE = datetime.now().astimezone().tzinfo or timezone.utc
33
41
 
@@ -50,11 +58,19 @@ class ExportTools(ToolBase):
50
58
  profile: str = DEFAULT_PROFILE,
51
59
  app_key: str = "",
52
60
  view_id: str = "system:all",
61
+ columns: list[JSONObject | int] | None = None,
62
+ where: list[JSONObject] | None = None,
63
+ record_ids: list[str | int] | None = None,
64
+ include_workflow_log: bool = False,
53
65
  ) -> dict[str, Any]:
54
66
  return self.record_export_start(
55
67
  profile=profile,
56
68
  app_key=app_key,
57
69
  view_id=view_id,
70
+ columns=columns or [],
71
+ where=where or [],
72
+ record_ids=record_ids or [],
73
+ include_workflow_log=include_workflow_log,
58
74
  )
59
75
 
60
76
  @mcp.tool(description="Get asynchronous record export status by opaque export_handle.")
@@ -84,6 +100,10 @@ class ExportTools(ToolBase):
84
100
  profile: str = DEFAULT_PROFILE,
85
101
  app_key: str = "",
86
102
  view_id: str = "system:all",
103
+ columns: list[JSONObject | int] | None = None,
104
+ where: list[JSONObject] | None = None,
105
+ record_ids: list[str | int] | None = None,
106
+ include_workflow_log: bool = False,
87
107
  download_to_path: str | None = None,
88
108
  wait_timeout_seconds: float | None = None,
89
109
  ) -> dict[str, Any]:
@@ -91,6 +111,10 @@ class ExportTools(ToolBase):
91
111
  profile=profile,
92
112
  app_key=app_key,
93
113
  view_id=view_id,
114
+ columns=columns or [],
115
+ where=where or [],
116
+ record_ids=record_ids or [],
117
+ include_workflow_log=include_workflow_log,
94
118
  download_to_path=download_to_path,
95
119
  wait_timeout_seconds=wait_timeout_seconds,
96
120
  )
@@ -102,9 +126,16 @@ class ExportTools(ToolBase):
102
126
  profile: str = DEFAULT_PROFILE,
103
127
  app_key: str,
104
128
  view_id: str = "system:all",
129
+ columns: list[JSONObject | int] | None = None,
130
+ where: list[JSONObject] | None = None,
131
+ record_ids: list[str | int] | None = None,
132
+ include_workflow_log: bool = False,
105
133
  ) -> dict[str, Any]:
106
134
  normalized_app_key = str(app_key or "").strip()
107
135
  normalized_view_id = str(view_id or "").strip() or "system:all"
136
+ normalized_columns = _normalize_export_columns(columns or [])
137
+ normalized_where = self._record_tools._normalize_record_list_where(where or [])
138
+ normalized_record_ids = _normalize_export_record_ids(record_ids or [])
108
139
  if not normalized_app_key:
109
140
  return self._failed_export_result(
110
141
  error_code="EXPORT_START_FAILED",
@@ -128,8 +159,33 @@ class ExportTools(ToolBase):
128
159
  context=context,
129
160
  app_key=normalized_app_key,
130
161
  resolved_view=resolved_view,
162
+ column_selectors=normalized_columns,
163
+ )
164
+ effective_record_ids, row_scope = self._resolve_export_record_scope(
165
+ profile=profile,
166
+ context=context,
167
+ app_key=normalized_app_key,
168
+ resolved_view=resolved_view,
169
+ selected_record_ids=normalized_record_ids,
170
+ where_filters=normalized_where,
171
+ select_columns=cast(list[JSONObject], export_config.get("questionExportConfigList") or []),
172
+ )
173
+ result_amount = self._estimate_export_result_amount(
174
+ context,
175
+ app_key=normalized_app_key,
176
+ resolved_view=resolved_view,
177
+ selected_record_ids=effective_record_ids,
178
+ )
179
+ self._validate_export_limits(
180
+ result_amount=result_amount,
181
+ selected_field_count=len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
182
+ include_workflow_log=include_workflow_log,
183
+ )
184
+ filter_bean = self._build_export_filter_bean(
185
+ resolved_view,
186
+ selected_record_ids=effective_record_ids,
187
+ include_workflow_log=include_workflow_log,
131
188
  )
132
- filter_bean = self._build_export_filter_bean(resolved_view)
133
189
  started_at = _utc_now().replace(microsecond=0).isoformat()
134
190
  socket_result = self.backend.start_socket_record_export(
135
191
  context,
@@ -138,7 +194,9 @@ class ExportTools(ToolBase):
138
194
  view_key=resolved_view.view_selection.view_key if resolved_view.view_selection is not None else None,
139
195
  filter_bean=filter_bean,
140
196
  export_config=export_config,
197
+ result_amount=result_amount,
141
198
  )
199
+ column_scope = "selected" if normalized_columns else "all"
142
200
  export_handle = uuid4().hex
143
201
  self._job_store.put(
144
202
  export_handle,
@@ -150,6 +208,11 @@ class ExportTools(ToolBase):
150
208
  "backend_export_id": str(socket_result.get("backend_export_id") or ""),
151
209
  "started_at": started_at,
152
210
  "uid": session_profile.uid,
211
+ "row_scope": row_scope,
212
+ "selected_record_count": len(effective_record_ids),
213
+ "field_scope": column_scope,
214
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
215
+ "include_workflow_log": bool(include_workflow_log),
153
216
  },
154
217
  )
155
218
  warnings = [
@@ -163,6 +226,11 @@ class ExportTools(ToolBase):
163
226
  "app_key": normalized_app_key,
164
227
  "view_id": resolved_view.view_id,
165
228
  "export_handle": export_handle,
229
+ "row_scope": row_scope,
230
+ "selected_record_count": len(effective_record_ids),
231
+ "field_scope": column_scope,
232
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
233
+ "include_workflow_log": bool(include_workflow_log),
166
234
  "file_urls": [],
167
235
  "file_names": [],
168
236
  "downloaded_files": [],
@@ -170,6 +238,9 @@ class ExportTools(ToolBase):
170
238
  "verification": {
171
239
  "export_acknowledged": bool(socket_result.get("backend_export_id")),
172
240
  "view_route_supported": True,
241
+ "row_selection_applied": bool(effective_record_ids),
242
+ "query_filter_applied": bool(normalized_where),
243
+ "field_selection_applied": bool(normalized_columns),
173
244
  },
174
245
  "request_route": self.backend.describe_route(context),
175
246
  }
@@ -253,6 +324,11 @@ class ExportTools(ToolBase):
253
324
  "export_handle": normalized_handle,
254
325
  "app_key": str(local_job.get("app_key") or ""),
255
326
  "view_id": str(local_job.get("view_id") or ""),
327
+ "row_scope": str(local_job.get("row_scope") or "all"),
328
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
329
+ "field_scope": str(local_job.get("field_scope") or "all"),
330
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
331
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
256
332
  "file_urls": snapshot.get("file_urls") or [],
257
333
  "file_names": snapshot.get("file_names") or [],
258
334
  "downloaded_files": [],
@@ -270,6 +346,11 @@ class ExportTools(ToolBase):
270
346
  "export_handle": normalized_handle,
271
347
  "app_key": str(local_job.get("app_key") or ""),
272
348
  "view_id": str(local_job.get("view_id") or ""),
349
+ "row_scope": str(local_job.get("row_scope") or "all"),
350
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
351
+ "field_scope": str(local_job.get("field_scope") or "all"),
352
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
353
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
273
354
  "file_urls": snapshot.get("file_urls") or [],
274
355
  "file_names": snapshot.get("file_names") or [],
275
356
  "downloaded_files": [],
@@ -308,6 +389,11 @@ class ExportTools(ToolBase):
308
389
  "export_handle": normalized_handle,
309
390
  "app_key": str(local_job.get("app_key") or ""),
310
391
  "view_id": str(local_job.get("view_id") or ""),
392
+ "row_scope": str(local_job.get("row_scope") or "all"),
393
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
394
+ "field_scope": str(local_job.get("field_scope") or "all"),
395
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
396
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
311
397
  "num": snapshot.get("num"),
312
398
  "process_status": snapshot.get("process_status"),
313
399
  "error_code": snapshot.get("error_code"),
@@ -336,6 +422,10 @@ class ExportTools(ToolBase):
336
422
  profile: str = DEFAULT_PROFILE,
337
423
  app_key: str,
338
424
  view_id: str = "system:all",
425
+ columns: list[JSONObject | int] | None = None,
426
+ where: list[JSONObject] | None = None,
427
+ record_ids: list[str | int] | None = None,
428
+ include_workflow_log: bool = False,
339
429
  download_to_path: str | None = None,
340
430
  wait_timeout_seconds: float | None = None,
341
431
  ) -> dict[str, Any]:
@@ -354,6 +444,10 @@ class ExportTools(ToolBase):
354
444
  profile=profile,
355
445
  app_key=normalized_app_key,
356
446
  view_id=normalized_view_id,
447
+ columns=columns or [],
448
+ where=where or [],
449
+ record_ids=record_ids or [],
450
+ include_workflow_log=include_workflow_log,
357
451
  )
358
452
  if not bool(start_result.get("ok")):
359
453
  return start_result
@@ -395,6 +489,11 @@ class ExportTools(ToolBase):
395
489
  "export_handle": export_handle,
396
490
  "app_key": str(local_job.get("app_key") or ""),
397
491
  "view_id": str(local_job.get("view_id") or ""),
492
+ "row_scope": str(local_job.get("row_scope") or "all"),
493
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
494
+ "field_scope": str(local_job.get("field_scope") or "all"),
495
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
496
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
398
497
  "num": snapshot.get("num"),
399
498
  "process_status": snapshot.get("process_status"),
400
499
  "audit_record_status": snapshot.get("audit_record_status"),
@@ -417,6 +516,11 @@ class ExportTools(ToolBase):
417
516
  "export_handle": export_handle,
418
517
  "app_key": str(local_job.get("app_key") or ""),
419
518
  "view_id": str(local_job.get("view_id") or ""),
519
+ "row_scope": str(local_job.get("row_scope") or "all"),
520
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
521
+ "field_scope": str(local_job.get("field_scope") or "all"),
522
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
523
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
420
524
  "file_urls": snapshot.get("file_urls") or [],
421
525
  "file_names": snapshot.get("file_names") or [],
422
526
  "downloaded_files": [],
@@ -444,6 +548,11 @@ class ExportTools(ToolBase):
444
548
  "export_handle": str(start_result.get("export_handle") or ""),
445
549
  "app_key": normalized_app_key,
446
550
  "view_id": str(start_result.get("view_id") or normalized_view_id),
551
+ "row_scope": start_result.get("row_scope") or ("selected" if record_ids else "all"),
552
+ "selected_record_count": start_result.get("selected_record_count") or 0,
553
+ "field_scope": start_result.get("field_scope") or ("selected" if columns else "all"),
554
+ "selected_field_count": start_result.get("selected_field_count"),
555
+ "include_workflow_log": start_result.get("include_workflow_log"),
447
556
  "file_urls": (last_snapshot or {}).get("file_urls") or [],
448
557
  "file_names": (last_snapshot or {}).get("file_names") or [],
449
558
  "downloaded_files": [],
@@ -461,15 +570,158 @@ class ExportTools(ToolBase):
461
570
  extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
462
571
  )
463
572
 
464
- def _build_export_filter_bean(self, resolved_view: AccessibleViewRoute) -> JSONObject:
573
+ def _resolve_export_record_scope(
574
+ self,
575
+ *,
576
+ profile: str,
577
+ context,
578
+ app_key: str,
579
+ resolved_view: AccessibleViewRoute,
580
+ selected_record_ids: list[int],
581
+ where_filters: list[JSONObject],
582
+ select_columns: list[JSONObject],
583
+ ) -> tuple[list[int], str]:
584
+ if selected_record_ids and where_filters:
585
+ raise QingflowApiError(
586
+ category="config",
587
+ message="record export does not allow record_ids and where at the same time",
588
+ details={
589
+ "error_code": "EXPORT_ROW_SCOPE_CONFLICT",
590
+ "fix_hint": "Use record_ids for explicit selected rows or where for internal query selection, but not both.",
591
+ },
592
+ )
593
+ if selected_record_ids:
594
+ return selected_record_ids, "selected"
595
+ if not where_filters:
596
+ return [], "all"
597
+ resolved_ids = self._collect_record_ids_from_where(
598
+ profile=profile,
599
+ context=context,
600
+ app_key=app_key,
601
+ resolved_view=resolved_view,
602
+ where_filters=where_filters,
603
+ select_columns=select_columns,
604
+ )
605
+ if not resolved_ids:
606
+ raise QingflowApiError.config_error(
607
+ "record export query did not match any records",
608
+ details={
609
+ "error_code": "EXPORT_NO_MATCHED_RECORDS",
610
+ "view_id": resolved_view.view_id,
611
+ },
612
+ )
613
+ return resolved_ids, "queried"
614
+
615
+ def _collect_record_ids_from_where(
616
+ self,
617
+ *,
618
+ profile: str,
619
+ context,
620
+ app_key: str,
621
+ resolved_view: AccessibleViewRoute,
622
+ where_filters: list[JSONObject],
623
+ select_columns: list[JSONObject],
624
+ ) -> list[int]:
625
+ browse_scope = self._record_tools._build_browse_write_scope(
626
+ profile,
627
+ context,
628
+ app_key,
629
+ resolved_view,
630
+ force_refresh=False,
631
+ )
632
+ index = browse_scope["index"]
633
+ match_rules = self._record_tools._resolve_match_rules(context, where_filters, index)
634
+ dept_member_cache: dict[int, set[int]] = {}
635
+ current_page = 1
636
+ selected_ids: list[int] = []
637
+ seen: set[int] = set()
638
+ primary_field_ids = [
639
+ _coerce_int(item.get("queId"))
640
+ for item in select_columns
641
+ if isinstance(item, dict)
642
+ ]
643
+ primary_search_que_ids = [item for item in primary_field_ids if item is not None][:1] or None
644
+
645
+ while True:
646
+ page = self._record_tools._search_page(
647
+ context,
648
+ app_key=app_key,
649
+ view_selection=resolved_view.view_selection,
650
+ page_num=current_page,
651
+ page_size=DEFAULT_LIST_PAGE_SIZE,
652
+ query_key=None,
653
+ match_rules=match_rules,
654
+ sorts=[],
655
+ search_que_ids=primary_search_que_ids,
656
+ list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
657
+ )
658
+ raw_rows = page.get("list")
659
+ items = raw_rows if isinstance(raw_rows, list) else []
660
+ for item in items:
661
+ if not isinstance(item, dict):
662
+ continue
663
+ answers = item.get("answers")
664
+ answer_list = answers if isinstance(answers, list) else []
665
+ if not self._record_tools._matches_view_selection(
666
+ context,
667
+ answer_list,
668
+ view_selection=resolved_view.view_selection,
669
+ dept_member_cache=dept_member_cache,
670
+ ):
671
+ continue
672
+ record_id = _coerce_int(item.get("applyId"))
673
+ if record_id is None:
674
+ record_id = _coerce_int(item.get("apply_id"))
675
+ if record_id is None:
676
+ record_id = _coerce_int(item.get("id"))
677
+ if record_id is None:
678
+ record_id = _coerce_int(item.get("record_id"))
679
+ if record_id is None or record_id in seen or record_id <= 0:
680
+ continue
681
+ seen.add(record_id)
682
+ selected_ids.append(record_id)
683
+ if len(selected_ids) > EXPORT_ROWS_LIMIT:
684
+ raise QingflowApiError.config_error(
685
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
686
+ details={
687
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
688
+ "result_amount": len(selected_ids),
689
+ "row_limit": EXPORT_ROWS_LIMIT,
690
+ },
691
+ )
692
+ if current_page == 1:
693
+ reported_total = _effective_total(page, page_size=DEFAULT_LIST_PAGE_SIZE)
694
+ if reported_total > EXPORT_ROWS_LIMIT:
695
+ raise QingflowApiError.config_error(
696
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
697
+ details={
698
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
699
+ "result_amount": reported_total,
700
+ "row_limit": EXPORT_ROWS_LIMIT,
701
+ },
702
+ )
703
+ if not _page_has_more(page, current_page=current_page, page_size=DEFAULT_LIST_PAGE_SIZE, returned_rows=len(items)):
704
+ break
705
+ current_page += 1
706
+ return selected_ids
707
+
708
+ def _build_export_filter_bean(
709
+ self,
710
+ resolved_view: AccessibleViewRoute,
711
+ *,
712
+ selected_record_ids: list[int],
713
+ include_workflow_log: bool,
714
+ ) -> JSONObject:
465
715
  filter_payload: JSONObject = {}
466
716
  if resolved_view.kind == "system" and resolved_view.list_type is not None:
467
717
  filter_payload["type"] = resolved_view.list_type
718
+ if selected_record_ids:
719
+ filter_payload["applyIds"] = selected_record_ids
468
720
  return {
469
721
  "filter": filter_payload,
470
722
  # Backend export code later auto-unboxes this field to primitive boolean.
471
- # Sending explicit false avoids a null -> NPE -> 41100 failure path.
472
- "auditRecordStatus": False,
723
+ # Always send an explicit boolean to avoid a null -> NPE -> 41100 failure path.
724
+ "auditRecordStatus": bool(include_workflow_log),
473
725
  }
474
726
 
475
727
  def _build_export_config(
@@ -479,6 +731,7 @@ class ExportTools(ToolBase):
479
731
  context,
480
732
  app_key: str,
481
733
  resolved_view: AccessibleViewRoute,
734
+ column_selectors: list[int],
482
735
  ) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
483
736
  browse_scope = self._record_tools._build_browse_write_scope(
484
737
  profile,
@@ -507,9 +760,32 @@ class ExportTools(ToolBase):
507
760
  for field in cast(Any, index).by_id.values()
508
761
  if field.que_type not in LAYOUT_ONLY_QUE_TYPES
509
762
  ]
763
+ exportable_by_id = {field.que_id: field for field in ordered_visible_fields}
764
+ selected_fields: list[Any]
765
+ if column_selectors:
766
+ selected_fields = []
767
+ seen: set[int] = set()
768
+ for selector in column_selectors:
769
+ field = self._record_tools._resolve_field_selector(selector, index, location="export_columns")
770
+ if field.que_id in seen:
771
+ continue
772
+ exportable_field = exportable_by_id.get(field.que_id)
773
+ if exportable_field is None:
774
+ raise QingflowApiError.config_error(
775
+ f"field '{field.que_title}' is not exportable in the selected view",
776
+ details={
777
+ "error_code": "EXPORT_FIELD_NOT_VISIBLE",
778
+ "field_id": field.que_id,
779
+ "view_id": resolved_view.view_id,
780
+ },
781
+ )
782
+ selected_fields.append(exportable_field)
783
+ seen.add(field.que_id)
784
+ else:
785
+ selected_fields = ordered_visible_fields
510
786
  question_export_config_list: list[JSONObject] = []
511
787
  warnings: list[JSONObject] = []
512
- for field in ordered_visible_fields:
788
+ for field in selected_fields:
513
789
  if field.que_type is None:
514
790
  warnings.append(
515
791
  {
@@ -533,6 +809,61 @@ class ExportTools(ToolBase):
533
809
  )
534
810
  return {"questionExportConfigList": question_export_config_list}, warnings
535
811
 
812
+ def _estimate_export_result_amount(
813
+ self,
814
+ context,
815
+ *,
816
+ app_key: str,
817
+ resolved_view: AccessibleViewRoute,
818
+ selected_record_ids: list[int],
819
+ ) -> int:
820
+ if selected_record_ids:
821
+ return len(selected_record_ids)
822
+ page = self._record_tools._search_page(
823
+ context,
824
+ app_key=app_key,
825
+ view_selection=resolved_view.view_selection,
826
+ page_num=1,
827
+ page_size=1,
828
+ query_key=None,
829
+ match_rules=[],
830
+ sorts=[],
831
+ search_que_ids=None,
832
+ list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
833
+ )
834
+ result_amount = _effective_total(page, page_size=1)
835
+ if result_amount < 0:
836
+ result_amount = 0
837
+ return result_amount
838
+
839
+ def _validate_export_limits(
840
+ self,
841
+ *,
842
+ result_amount: int,
843
+ selected_field_count: int,
844
+ include_workflow_log: bool,
845
+ ) -> None:
846
+ if result_amount > EXPORT_ROWS_LIMIT:
847
+ raise QingflowApiError.config_error(
848
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
849
+ details={
850
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
851
+ "result_amount": result_amount,
852
+ "row_limit": EXPORT_ROWS_LIMIT,
853
+ },
854
+ )
855
+ estimated_cells = max(result_amount, 0) * max(selected_field_count, 0)
856
+ if estimated_cells > EXPORT_CELL_LIMIT:
857
+ raise QingflowApiError.config_error(
858
+ f"record export exceeds the native cell limit of {EXPORT_CELL_LIMIT}",
859
+ details={
860
+ "error_code": "EXPORT_CELLS_LIMIT_EXCEEDED",
861
+ "estimated_cells": estimated_cells,
862
+ "cell_limit": EXPORT_CELL_LIMIT,
863
+ "include_workflow_log": include_workflow_log,
864
+ },
865
+ )
866
+
536
867
  def _resolve_export_snapshot(
537
868
  self,
538
869
  context,
@@ -651,6 +982,11 @@ class ExportTools(ToolBase):
651
982
  "export_handle": export_handle,
652
983
  "app_key": str(local_job.get("app_key") or ""),
653
984
  "view_id": str(local_job.get("view_id") or ""),
985
+ "row_scope": str(local_job.get("row_scope") or "all"),
986
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
987
+ "field_scope": str(local_job.get("field_scope") or "all"),
988
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
989
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
654
990
  "num": snapshot.get("num"),
655
991
  "process_status": snapshot.get("process_status"),
656
992
  "error_code": snapshot.get("error_code"),
@@ -884,6 +1220,47 @@ def _coerce_int(value: Any) -> int | None:
884
1220
  return None
885
1221
 
886
1222
 
1223
+ def _normalize_export_columns(columns: list[JSONObject | int]) -> list[int]:
1224
+ normalized: list[int] = []
1225
+ for field_id in _normalize_public_column_selectors(columns):
1226
+ if field_id not in normalized:
1227
+ normalized.append(field_id)
1228
+ return normalized
1229
+
1230
+
1231
+ def _normalize_export_record_ids(record_ids: list[str | int]) -> list[int]:
1232
+ normalized: list[int] = []
1233
+ seen: set[int] = set()
1234
+ for item in record_ids:
1235
+ record_id = _coerce_int(item)
1236
+ if record_id is None or record_id <= 0 or record_id in seen:
1237
+ continue
1238
+ normalized.append(record_id)
1239
+ seen.add(record_id)
1240
+ return normalized
1241
+
1242
+
1243
+ def _effective_total(page: JSONObject, *, page_size: int) -> int:
1244
+ rows = page.get("list")
1245
+ returned_rows = len(rows) if isinstance(rows, list) else 0
1246
+ reported = _coerce_int(page.get("total"))
1247
+ if reported is None:
1248
+ reported = _coerce_int(page.get("count"))
1249
+ if reported is not None:
1250
+ return max(reported, returned_rows)
1251
+ page_amount = _coerce_int(page.get("pageAmount"))
1252
+ if page_amount is not None:
1253
+ return page_amount * page_size
1254
+ return returned_rows
1255
+
1256
+
1257
+ def _page_has_more(page: JSONObject, *, current_page: int, page_size: int, returned_rows: int) -> bool:
1258
+ page_amount = _coerce_int(page.get("pageAmount"))
1259
+ if page_amount is not None:
1260
+ return current_page < page_amount
1261
+ return returned_rows >= page_size
1262
+
1263
+
887
1264
  def _resolve_download_targets(
888
1265
  destination_hint: str,
889
1266
  *,