@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 +2 -2
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/cli/commands/exports.py +39 -0
- package/src/qingflow_mcp/cli/formatters.py +15 -0
- package/src/qingflow_mcp/server.py +11 -0
- package/src/qingflow_mcp/server_app_user.py +32 -1
- package/src/qingflow_mcp/tools/export_tools.py +384 -7
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.
|
|
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.
|
|
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
package/pyproject.toml
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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
|
-
#
|
|
472
|
-
"auditRecordStatus":
|
|
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
|
|
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
|
*,
|