@josephyan/qingflow-cli 0.2.0-beta.1010 → 0.2.0-beta.1011

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.1010
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.1011
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.1010 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.1011 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.1010",
3
+ "version": "0.2.0-beta.1011",
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.0b1010"
7
+ version = "0.2.0b1011"
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.0b1011"
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,11 @@ 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("--record-id", dest="record_ids", action="append", default=[], help="只导出这些 record_id;不传时导出当前视图全部数据")
19
+ start.add_argument("--record-ids-file", help="JSON/YAML list,内容与 --record-id 语义一致")
20
+ start.add_argument("--include-workflow-log", action=argparse.BooleanOptionalAction, default=False, help="是否同时导出流程日志")
15
21
  start.set_defaults(handler=_handle_start, format_hint="export_start")
16
22
 
17
23
  status = export_subparsers.add_parser("status", help="查询导出状态")
@@ -26,16 +32,38 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
26
32
  direct = export_subparsers.add_parser("direct", help="直接导出并下载")
27
33
  direct.add_argument("--app-key", required=True)
28
34
  direct.add_argument("--view-id", default="system:all")
35
+ direct.add_argument("--column", dest="columns", action="append", type=int, default=[], help="只导出这些 field_id;不传时导出当前视图全部字段")
36
+ direct.add_argument("--columns-file", help="JSON/YAML list,内容与 --column 语义一致")
37
+ direct.add_argument("--record-id", dest="record_ids", action="append", default=[], help="只导出这些 record_id;不传时导出当前视图全部数据")
38
+ direct.add_argument("--record-ids-file", help="JSON/YAML list,内容与 --record-id 语义一致")
39
+ direct.add_argument("--include-workflow-log", action=argparse.BooleanOptionalAction, default=False, help="是否同时导出流程日志")
29
40
  direct.add_argument("--download-to-path")
30
41
  direct.add_argument("--wait-timeout-seconds", type=float)
31
42
  direct.set_defaults(handler=_handle_direct, format_hint="export_direct")
32
43
 
33
44
 
45
+ def _columns(args: argparse.Namespace) -> list[int | dict]:
46
+ columns: list[int | dict] = list(args.columns or [])
47
+ if args.columns_file:
48
+ columns.extend(load_list_arg(args.columns_file, option_name="--columns-file"))
49
+ return columns
50
+
51
+
52
+ def _record_ids(args: argparse.Namespace) -> list[str | int]:
53
+ record_ids: list[str | int] = list(args.record_ids or [])
54
+ if args.record_ids_file:
55
+ record_ids.extend(load_list_arg(args.record_ids_file, option_name="--record-ids-file"))
56
+ return record_ids
57
+
58
+
34
59
  def _handle_start(args: argparse.Namespace, context: CliContext) -> dict:
35
60
  return context.exports.record_export_start(
36
61
  profile=args.profile,
37
62
  app_key=args.app_key,
38
63
  view_id=args.view_id,
64
+ columns=_columns(args),
65
+ record_ids=_record_ids(args),
66
+ include_workflow_log=args.include_workflow_log,
39
67
  )
40
68
 
41
69
 
@@ -59,6 +87,9 @@ def _handle_direct(args: argparse.Namespace, context: CliContext) -> dict:
59
87
  profile=args.profile,
60
88
  app_key=args.app_key,
61
89
  view_id=args.view_id,
90
+ columns=_columns(args),
91
+ record_ids=_record_ids(args),
92
+ include_workflow_log=args.include_workflow_log,
62
93
  download_to_path=args.download_to_path,
63
94
  wait_timeout_seconds=args.wait_timeout_seconds,
64
95
  )
@@ -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,13 @@ 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 frontend-like column selection:
162
+ - omit `columns` to export all current-view fields
163
+ - pass `columns` to export only selected fields, preserving the provided order
164
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
158
165
 
159
166
  ## Task Workflow Path
160
167
 
@@ -150,6 +150,13 @@ 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 frontend-like column selection:
157
+ - omit `columns` to export all current-view fields
158
+ - pass `columns` to export only selected fields, preserving the provided order
159
+ - `include_workflow_log=true` maps to the native workflow-log export switch.
153
160
 
154
161
  ## Task Workflow Path
155
162
 
@@ -233,8 +240,18 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
233
240
  profile: str = DEFAULT_PROFILE,
234
241
  app_key: str = "",
235
242
  view_id: str = "system:all",
243
+ columns: list[dict | int] | None = None,
244
+ record_ids: list[str | int] | None = None,
245
+ include_workflow_log: bool = False,
236
246
  ) -> dict:
237
- return exports.record_export_start(profile=profile, app_key=app_key, view_id=view_id)
247
+ return exports.record_export_start(
248
+ profile=profile,
249
+ app_key=app_key,
250
+ view_id=view_id,
251
+ columns=columns or [],
252
+ record_ids=record_ids or [],
253
+ include_workflow_log=include_workflow_log,
254
+ )
238
255
 
239
256
  @server.tool()
240
257
  def record_export_status_get(
@@ -260,6 +277,9 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
260
277
  profile: str = DEFAULT_PROFILE,
261
278
  app_key: str = "",
262
279
  view_id: str = "system:all",
280
+ columns: list[dict | int] | None = None,
281
+ record_ids: list[str | int] | None = None,
282
+ include_workflow_log: bool = False,
263
283
  download_to_path: str | None = None,
264
284
  wait_timeout_seconds: float | None = None,
265
285
  ) -> dict:
@@ -267,6 +287,9 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
267
287
  profile=profile,
268
288
  app_key=app_key,
269
289
  view_id=view_id,
290
+ columns=columns or [],
291
+ record_ids=record_ids or [],
292
+ include_workflow_log=include_workflow_log,
270
293
  download_to_path=download_to_path,
271
294
  wait_timeout_seconds=wait_timeout_seconds,
272
295
  )
@@ -11,12 +11,17 @@ 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
+ LAYOUT_ONLY_QUE_TYPES,
22
+ RecordTools,
23
+ _normalize_public_column_selectors,
24
+ )
20
25
 
21
26
 
22
27
  EXPORT_STATUS_BY_PROCESS_STATUS = {
@@ -28,6 +33,8 @@ EXPORT_STATUS_BY_PROCESS_STATUS = {
28
33
  }
29
34
  DEFAULT_EXPORT_TIMEOUT_SECONDS = 60.0
30
35
  EXPORT_POLL_INTERVAL_SECONDS = 1.5
36
+ EXPORT_ROWS_LIMIT = 20_000
37
+ EXPORT_CELL_LIMIT = 4_000_000
31
38
  _SAFE_FILE_CHARS = re.compile(r"[^0-9A-Za-z._-]+")
32
39
  LOCAL_TIMEZONE = datetime.now().astimezone().tzinfo or timezone.utc
33
40
 
@@ -50,11 +57,17 @@ class ExportTools(ToolBase):
50
57
  profile: str = DEFAULT_PROFILE,
51
58
  app_key: str = "",
52
59
  view_id: str = "system:all",
60
+ columns: list[JSONObject | int] | None = None,
61
+ record_ids: list[str | int] | None = None,
62
+ include_workflow_log: bool = False,
53
63
  ) -> dict[str, Any]:
54
64
  return self.record_export_start(
55
65
  profile=profile,
56
66
  app_key=app_key,
57
67
  view_id=view_id,
68
+ columns=columns or [],
69
+ record_ids=record_ids or [],
70
+ include_workflow_log=include_workflow_log,
58
71
  )
59
72
 
60
73
  @mcp.tool(description="Get asynchronous record export status by opaque export_handle.")
@@ -84,6 +97,9 @@ class ExportTools(ToolBase):
84
97
  profile: str = DEFAULT_PROFILE,
85
98
  app_key: str = "",
86
99
  view_id: str = "system:all",
100
+ columns: list[JSONObject | int] | None = None,
101
+ record_ids: list[str | int] | None = None,
102
+ include_workflow_log: bool = False,
87
103
  download_to_path: str | None = None,
88
104
  wait_timeout_seconds: float | None = None,
89
105
  ) -> dict[str, Any]:
@@ -91,6 +107,9 @@ class ExportTools(ToolBase):
91
107
  profile=profile,
92
108
  app_key=app_key,
93
109
  view_id=view_id,
110
+ columns=columns or [],
111
+ record_ids=record_ids or [],
112
+ include_workflow_log=include_workflow_log,
94
113
  download_to_path=download_to_path,
95
114
  wait_timeout_seconds=wait_timeout_seconds,
96
115
  )
@@ -102,9 +121,14 @@ class ExportTools(ToolBase):
102
121
  profile: str = DEFAULT_PROFILE,
103
122
  app_key: str,
104
123
  view_id: str = "system:all",
124
+ columns: list[JSONObject | int] | None = None,
125
+ record_ids: list[str | int] | None = None,
126
+ include_workflow_log: bool = False,
105
127
  ) -> dict[str, Any]:
106
128
  normalized_app_key = str(app_key or "").strip()
107
129
  normalized_view_id = str(view_id or "").strip() or "system:all"
130
+ normalized_columns = _normalize_export_columns(columns or [])
131
+ normalized_record_ids = _normalize_export_record_ids(record_ids or [])
108
132
  if not normalized_app_key:
109
133
  return self._failed_export_result(
110
134
  error_code="EXPORT_START_FAILED",
@@ -128,8 +152,24 @@ class ExportTools(ToolBase):
128
152
  context=context,
129
153
  app_key=normalized_app_key,
130
154
  resolved_view=resolved_view,
155
+ column_selectors=normalized_columns,
156
+ )
157
+ result_amount = self._estimate_export_result_amount(
158
+ context,
159
+ app_key=normalized_app_key,
160
+ resolved_view=resolved_view,
161
+ selected_record_ids=normalized_record_ids,
162
+ )
163
+ self._validate_export_limits(
164
+ result_amount=result_amount,
165
+ selected_field_count=len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
166
+ include_workflow_log=include_workflow_log,
167
+ )
168
+ filter_bean = self._build_export_filter_bean(
169
+ resolved_view,
170
+ selected_record_ids=normalized_record_ids,
171
+ include_workflow_log=include_workflow_log,
131
172
  )
132
- filter_bean = self._build_export_filter_bean(resolved_view)
133
173
  started_at = _utc_now().replace(microsecond=0).isoformat()
134
174
  socket_result = self.backend.start_socket_record_export(
135
175
  context,
@@ -138,7 +178,10 @@ class ExportTools(ToolBase):
138
178
  view_key=resolved_view.view_selection.view_key if resolved_view.view_selection is not None else None,
139
179
  filter_bean=filter_bean,
140
180
  export_config=export_config,
181
+ result_amount=result_amount,
141
182
  )
183
+ row_scope = "selected" if normalized_record_ids else "all"
184
+ column_scope = "selected" if normalized_columns else "all"
142
185
  export_handle = uuid4().hex
143
186
  self._job_store.put(
144
187
  export_handle,
@@ -150,6 +193,11 @@ class ExportTools(ToolBase):
150
193
  "backend_export_id": str(socket_result.get("backend_export_id") or ""),
151
194
  "started_at": started_at,
152
195
  "uid": session_profile.uid,
196
+ "row_scope": row_scope,
197
+ "selected_record_count": len(normalized_record_ids),
198
+ "field_scope": column_scope,
199
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
200
+ "include_workflow_log": bool(include_workflow_log),
153
201
  },
154
202
  )
155
203
  warnings = [
@@ -163,6 +211,11 @@ class ExportTools(ToolBase):
163
211
  "app_key": normalized_app_key,
164
212
  "view_id": resolved_view.view_id,
165
213
  "export_handle": export_handle,
214
+ "row_scope": row_scope,
215
+ "selected_record_count": len(normalized_record_ids),
216
+ "field_scope": column_scope,
217
+ "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
218
+ "include_workflow_log": bool(include_workflow_log),
166
219
  "file_urls": [],
167
220
  "file_names": [],
168
221
  "downloaded_files": [],
@@ -170,6 +223,8 @@ class ExportTools(ToolBase):
170
223
  "verification": {
171
224
  "export_acknowledged": bool(socket_result.get("backend_export_id")),
172
225
  "view_route_supported": True,
226
+ "row_selection_applied": bool(normalized_record_ids),
227
+ "field_selection_applied": bool(normalized_columns),
173
228
  },
174
229
  "request_route": self.backend.describe_route(context),
175
230
  }
@@ -253,6 +308,11 @@ class ExportTools(ToolBase):
253
308
  "export_handle": normalized_handle,
254
309
  "app_key": str(local_job.get("app_key") or ""),
255
310
  "view_id": str(local_job.get("view_id") or ""),
311
+ "row_scope": str(local_job.get("row_scope") or "all"),
312
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
313
+ "field_scope": str(local_job.get("field_scope") or "all"),
314
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
315
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
256
316
  "file_urls": snapshot.get("file_urls") or [],
257
317
  "file_names": snapshot.get("file_names") or [],
258
318
  "downloaded_files": [],
@@ -270,6 +330,11 @@ class ExportTools(ToolBase):
270
330
  "export_handle": normalized_handle,
271
331
  "app_key": str(local_job.get("app_key") or ""),
272
332
  "view_id": str(local_job.get("view_id") or ""),
333
+ "row_scope": str(local_job.get("row_scope") or "all"),
334
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
335
+ "field_scope": str(local_job.get("field_scope") or "all"),
336
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
337
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
273
338
  "file_urls": snapshot.get("file_urls") or [],
274
339
  "file_names": snapshot.get("file_names") or [],
275
340
  "downloaded_files": [],
@@ -308,6 +373,11 @@ class ExportTools(ToolBase):
308
373
  "export_handle": normalized_handle,
309
374
  "app_key": str(local_job.get("app_key") or ""),
310
375
  "view_id": str(local_job.get("view_id") or ""),
376
+ "row_scope": str(local_job.get("row_scope") or "all"),
377
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
378
+ "field_scope": str(local_job.get("field_scope") or "all"),
379
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
380
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
311
381
  "num": snapshot.get("num"),
312
382
  "process_status": snapshot.get("process_status"),
313
383
  "error_code": snapshot.get("error_code"),
@@ -336,6 +406,9 @@ class ExportTools(ToolBase):
336
406
  profile: str = DEFAULT_PROFILE,
337
407
  app_key: str,
338
408
  view_id: str = "system:all",
409
+ columns: list[JSONObject | int] | None = None,
410
+ record_ids: list[str | int] | None = None,
411
+ include_workflow_log: bool = False,
339
412
  download_to_path: str | None = None,
340
413
  wait_timeout_seconds: float | None = None,
341
414
  ) -> dict[str, Any]:
@@ -354,6 +427,9 @@ class ExportTools(ToolBase):
354
427
  profile=profile,
355
428
  app_key=normalized_app_key,
356
429
  view_id=normalized_view_id,
430
+ columns=columns or [],
431
+ record_ids=record_ids or [],
432
+ include_workflow_log=include_workflow_log,
357
433
  )
358
434
  if not bool(start_result.get("ok")):
359
435
  return start_result
@@ -395,6 +471,11 @@ class ExportTools(ToolBase):
395
471
  "export_handle": export_handle,
396
472
  "app_key": str(local_job.get("app_key") or ""),
397
473
  "view_id": str(local_job.get("view_id") or ""),
474
+ "row_scope": str(local_job.get("row_scope") or "all"),
475
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
476
+ "field_scope": str(local_job.get("field_scope") or "all"),
477
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
478
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
398
479
  "num": snapshot.get("num"),
399
480
  "process_status": snapshot.get("process_status"),
400
481
  "audit_record_status": snapshot.get("audit_record_status"),
@@ -417,6 +498,11 @@ class ExportTools(ToolBase):
417
498
  "export_handle": export_handle,
418
499
  "app_key": str(local_job.get("app_key") or ""),
419
500
  "view_id": str(local_job.get("view_id") or ""),
501
+ "row_scope": str(local_job.get("row_scope") or "all"),
502
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
503
+ "field_scope": str(local_job.get("field_scope") or "all"),
504
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
505
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
420
506
  "file_urls": snapshot.get("file_urls") or [],
421
507
  "file_names": snapshot.get("file_names") or [],
422
508
  "downloaded_files": [],
@@ -444,6 +530,11 @@ class ExportTools(ToolBase):
444
530
  "export_handle": str(start_result.get("export_handle") or ""),
445
531
  "app_key": normalized_app_key,
446
532
  "view_id": str(start_result.get("view_id") or normalized_view_id),
533
+ "row_scope": start_result.get("row_scope") or ("selected" if record_ids else "all"),
534
+ "selected_record_count": start_result.get("selected_record_count") or 0,
535
+ "field_scope": start_result.get("field_scope") or ("selected" if columns else "all"),
536
+ "selected_field_count": start_result.get("selected_field_count"),
537
+ "include_workflow_log": start_result.get("include_workflow_log"),
447
538
  "file_urls": (last_snapshot or {}).get("file_urls") or [],
448
539
  "file_names": (last_snapshot or {}).get("file_names") or [],
449
540
  "downloaded_files": [],
@@ -461,15 +552,23 @@ class ExportTools(ToolBase):
461
552
  extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
462
553
  )
463
554
 
464
- def _build_export_filter_bean(self, resolved_view: AccessibleViewRoute) -> JSONObject:
555
+ def _build_export_filter_bean(
556
+ self,
557
+ resolved_view: AccessibleViewRoute,
558
+ *,
559
+ selected_record_ids: list[int],
560
+ include_workflow_log: bool,
561
+ ) -> JSONObject:
465
562
  filter_payload: JSONObject = {}
466
563
  if resolved_view.kind == "system" and resolved_view.list_type is not None:
467
564
  filter_payload["type"] = resolved_view.list_type
565
+ if selected_record_ids:
566
+ filter_payload["applyIds"] = selected_record_ids
468
567
  return {
469
568
  "filter": filter_payload,
470
569
  # 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,
570
+ # Always send an explicit boolean to avoid a null -> NPE -> 41100 failure path.
571
+ "auditRecordStatus": bool(include_workflow_log),
473
572
  }
474
573
 
475
574
  def _build_export_config(
@@ -479,6 +578,7 @@ class ExportTools(ToolBase):
479
578
  context,
480
579
  app_key: str,
481
580
  resolved_view: AccessibleViewRoute,
581
+ column_selectors: list[int],
482
582
  ) -> tuple[JSONObject, list[JSONObject]]: # type: ignore[no-untyped-def]
483
583
  browse_scope = self._record_tools._build_browse_write_scope(
484
584
  profile,
@@ -507,9 +607,32 @@ class ExportTools(ToolBase):
507
607
  for field in cast(Any, index).by_id.values()
508
608
  if field.que_type not in LAYOUT_ONLY_QUE_TYPES
509
609
  ]
610
+ exportable_by_id = {field.que_id: field for field in ordered_visible_fields}
611
+ selected_fields: list[Any]
612
+ if column_selectors:
613
+ selected_fields = []
614
+ seen: set[int] = set()
615
+ for selector in column_selectors:
616
+ field = self._record_tools._resolve_field_selector(selector, index, location="export_columns")
617
+ if field.que_id in seen:
618
+ continue
619
+ exportable_field = exportable_by_id.get(field.que_id)
620
+ if exportable_field is None:
621
+ raise QingflowApiError.config_error(
622
+ f"field '{field.que_title}' is not exportable in the selected view",
623
+ details={
624
+ "error_code": "EXPORT_FIELD_NOT_VISIBLE",
625
+ "field_id": field.que_id,
626
+ "view_id": resolved_view.view_id,
627
+ },
628
+ )
629
+ selected_fields.append(exportable_field)
630
+ seen.add(field.que_id)
631
+ else:
632
+ selected_fields = ordered_visible_fields
510
633
  question_export_config_list: list[JSONObject] = []
511
634
  warnings: list[JSONObject] = []
512
- for field in ordered_visible_fields:
635
+ for field in selected_fields:
513
636
  if field.que_type is None:
514
637
  warnings.append(
515
638
  {
@@ -533,6 +656,61 @@ class ExportTools(ToolBase):
533
656
  )
534
657
  return {"questionExportConfigList": question_export_config_list}, warnings
535
658
 
659
+ def _estimate_export_result_amount(
660
+ self,
661
+ context,
662
+ *,
663
+ app_key: str,
664
+ resolved_view: AccessibleViewRoute,
665
+ selected_record_ids: list[int],
666
+ ) -> int:
667
+ if selected_record_ids:
668
+ return len(selected_record_ids)
669
+ page = self._record_tools._search_page(
670
+ context,
671
+ app_key=app_key,
672
+ view_selection=resolved_view.view_selection,
673
+ page_num=1,
674
+ page_size=1,
675
+ query_key=None,
676
+ match_rules=[],
677
+ sorts=[],
678
+ search_que_ids=None,
679
+ list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
680
+ )
681
+ result_amount = _effective_total(page, page_size=1)
682
+ if result_amount < 0:
683
+ result_amount = 0
684
+ return result_amount
685
+
686
+ def _validate_export_limits(
687
+ self,
688
+ *,
689
+ result_amount: int,
690
+ selected_field_count: int,
691
+ include_workflow_log: bool,
692
+ ) -> None:
693
+ if result_amount > EXPORT_ROWS_LIMIT:
694
+ raise QingflowApiError.config_error(
695
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
696
+ details={
697
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
698
+ "result_amount": result_amount,
699
+ "row_limit": EXPORT_ROWS_LIMIT,
700
+ },
701
+ )
702
+ estimated_cells = max(result_amount, 0) * max(selected_field_count, 0)
703
+ if estimated_cells > EXPORT_CELL_LIMIT:
704
+ raise QingflowApiError.config_error(
705
+ f"record export exceeds the native cell limit of {EXPORT_CELL_LIMIT}",
706
+ details={
707
+ "error_code": "EXPORT_CELLS_LIMIT_EXCEEDED",
708
+ "estimated_cells": estimated_cells,
709
+ "cell_limit": EXPORT_CELL_LIMIT,
710
+ "include_workflow_log": include_workflow_log,
711
+ },
712
+ )
713
+
536
714
  def _resolve_export_snapshot(
537
715
  self,
538
716
  context,
@@ -651,6 +829,11 @@ class ExportTools(ToolBase):
651
829
  "export_handle": export_handle,
652
830
  "app_key": str(local_job.get("app_key") or ""),
653
831
  "view_id": str(local_job.get("view_id") or ""),
832
+ "row_scope": str(local_job.get("row_scope") or "all"),
833
+ "selected_record_count": _coerce_int(local_job.get("selected_record_count")) or 0,
834
+ "field_scope": str(local_job.get("field_scope") or "all"),
835
+ "selected_field_count": _coerce_int(local_job.get("selected_field_count")),
836
+ "include_workflow_log": bool(local_job.get("include_workflow_log")),
654
837
  "num": snapshot.get("num"),
655
838
  "process_status": snapshot.get("process_status"),
656
839
  "error_code": snapshot.get("error_code"),
@@ -884,6 +1067,40 @@ def _coerce_int(value: Any) -> int | None:
884
1067
  return None
885
1068
 
886
1069
 
1070
+ def _normalize_export_columns(columns: list[JSONObject | int]) -> list[int]:
1071
+ normalized: list[int] = []
1072
+ for field_id in _normalize_public_column_selectors(columns):
1073
+ if field_id not in normalized:
1074
+ normalized.append(field_id)
1075
+ return normalized
1076
+
1077
+
1078
+ def _normalize_export_record_ids(record_ids: list[str | int]) -> list[int]:
1079
+ normalized: list[int] = []
1080
+ seen: set[int] = set()
1081
+ for item in record_ids:
1082
+ record_id = _coerce_int(item)
1083
+ if record_id is None or record_id <= 0 or record_id in seen:
1084
+ continue
1085
+ normalized.append(record_id)
1086
+ seen.add(record_id)
1087
+ return normalized
1088
+
1089
+
1090
+ def _effective_total(page: JSONObject, *, page_size: int) -> int:
1091
+ rows = page.get("list")
1092
+ returned_rows = len(rows) if isinstance(rows, list) else 0
1093
+ reported = _coerce_int(page.get("total"))
1094
+ if reported is None:
1095
+ reported = _coerce_int(page.get("count"))
1096
+ if reported is not None:
1097
+ return max(reported, returned_rows)
1098
+ page_amount = _coerce_int(page.get("pageAmount"))
1099
+ if page_amount is not None:
1100
+ return page_amount * page_size
1101
+ return returned_rows
1102
+
1103
+
887
1104
  def _resolve_download_targets(
888
1105
  destination_hint: str,
889
1106
  *,