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

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.1011
6
+ npm install @josephyan/qingflow-cli@0.2.0-beta.1013
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-cli@0.2.0-beta.1011 qingflow
12
+ npx -y -p @josephyan/qingflow-cli@0.2.0-beta.1013 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.1011",
3
+ "version": "0.2.0-beta.1013",
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.0b1011"
7
+ version = "0.2.0b1013"
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.0b1011"
8
+ _FALLBACK_VERSION = "0.2.0b1013"
9
9
 
10
10
 
11
11
  def _resolve_local_pyproject_version() -> str | None:
@@ -15,6 +15,8 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
15
15
  start.add_argument("--view-id", default="system:all")
16
16
  start.add_argument("--column", dest="columns", action="append", type=int, default=[], help="只导出这些 field_id;不传时导出当前视图全部字段")
17
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("--order-by-file", help="JSON/YAML list,内容与 record list 的 order_by DSL 一致;内部查询和导出记录顺序保持一致")
18
20
  start.add_argument("--record-id", dest="record_ids", action="append", default=[], help="只导出这些 record_id;不传时导出当前视图全部数据")
19
21
  start.add_argument("--record-ids-file", help="JSON/YAML list,内容与 --record-id 语义一致")
20
22
  start.add_argument("--include-workflow-log", action=argparse.BooleanOptionalAction, default=False, help="是否同时导出流程日志")
@@ -34,6 +36,8 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
34
36
  direct.add_argument("--view-id", default="system:all")
35
37
  direct.add_argument("--column", dest="columns", action="append", type=int, default=[], help="只导出这些 field_id;不传时导出当前视图全部字段")
36
38
  direct.add_argument("--columns-file", help="JSON/YAML list,内容与 --column 语义一致")
39
+ direct.add_argument("--where-file", help="JSON/YAML list,内容与 record list 的 where DSL 一致;内部先查命中 record_id 再走原生导出")
40
+ direct.add_argument("--order-by-file", help="JSON/YAML list,内容与 record list 的 order_by DSL 一致;内部查询和导出记录顺序保持一致")
37
41
  direct.add_argument("--record-id", dest="record_ids", action="append", default=[], help="只导出这些 record_id;不传时导出当前视图全部数据")
38
42
  direct.add_argument("--record-ids-file", help="JSON/YAML list,内容与 --record-id 语义一致")
39
43
  direct.add_argument("--include-workflow-log", action=argparse.BooleanOptionalAction, default=False, help="是否同时导出流程日志")
@@ -56,12 +60,22 @@ def _record_ids(args: argparse.Namespace) -> list[str | int]:
56
60
  return record_ids
57
61
 
58
62
 
63
+ def _where(args: argparse.Namespace) -> list[dict]:
64
+ return load_list_arg(args.where_file, option_name="--where-file") if args.where_file else []
65
+
66
+
67
+ def _order_by(args: argparse.Namespace) -> list[dict]:
68
+ return load_list_arg(args.order_by_file, option_name="--order-by-file") if args.order_by_file else []
69
+
70
+
59
71
  def _handle_start(args: argparse.Namespace, context: CliContext) -> dict:
60
72
  return context.exports.record_export_start(
61
73
  profile=args.profile,
62
74
  app_key=args.app_key,
63
75
  view_id=args.view_id,
64
76
  columns=_columns(args),
77
+ where=_where(args),
78
+ order_by=_order_by(args),
65
79
  record_ids=_record_ids(args),
66
80
  include_workflow_log=args.include_workflow_log,
67
81
  )
@@ -88,6 +102,8 @@ def _handle_direct(args: argparse.Namespace, context: CliContext) -> dict:
88
102
  app_key=args.app_key,
89
103
  view_id=args.view_id,
90
104
  columns=_columns(args),
105
+ where=_where(args),
106
+ order_by=_order_by(args),
91
107
  record_ids=_record_ids(args),
92
108
  include_workflow_log=args.include_workflow_log,
93
109
  download_to_path=args.download_to_path,
@@ -158,6 +158,11 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
158
158
  - `record_export_start` / `record_export_direct` support frontend-like row selection:
159
159
  - omit `record_ids` to export all rows in the selected view
160
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
+ - pass `order_by` to keep the internal query and export row order aligned with `record_list`
164
+ - then run native export as selected rows
165
+ - `where/order_by` and `record_ids` are mutually exclusive
161
166
  - `record_export_start` / `record_export_direct` also support frontend-like column selection:
162
167
  - omit `columns` to export all current-view fields
163
168
  - pass `columns` to export only selected fields, preserving the provided order
@@ -153,6 +153,11 @@ Use `record_code_block_run` when the user wants to execute a form code-block fie
153
153
  - `record_export_start` / `record_export_direct` support frontend-like row selection:
154
154
  - omit `record_ids` to export all rows in the selected view
155
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
+ - pass `order_by` to keep the internal query and export row order aligned with `record_list`
159
+ - then run native export as selected rows
160
+ - `where/order_by` and `record_ids` are mutually exclusive
156
161
  - `record_export_start` / `record_export_direct` also support frontend-like column selection:
157
162
  - omit `columns` to export all current-view fields
158
163
  - pass `columns` to export only selected fields, preserving the provided order
@@ -241,6 +246,8 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
241
246
  app_key: str = "",
242
247
  view_id: str = "system:all",
243
248
  columns: list[dict | int] | None = None,
249
+ where: list[dict] | None = None,
250
+ order_by: list[dict] | None = None,
244
251
  record_ids: list[str | int] | None = None,
245
252
  include_workflow_log: bool = False,
246
253
  ) -> dict:
@@ -249,6 +256,8 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
249
256
  app_key=app_key,
250
257
  view_id=view_id,
251
258
  columns=columns or [],
259
+ where=where or [],
260
+ order_by=order_by or [],
252
261
  record_ids=record_ids or [],
253
262
  include_workflow_log=include_workflow_log,
254
263
  )
@@ -278,6 +287,8 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
278
287
  app_key: str = "",
279
288
  view_id: str = "system:all",
280
289
  columns: list[dict | int] | None = None,
290
+ where: list[dict] | None = None,
291
+ order_by: list[dict] | None = None,
281
292
  record_ids: list[str | int] | None = None,
282
293
  include_workflow_log: bool = False,
283
294
  download_to_path: str | None = None,
@@ -288,6 +299,8 @@ If the current MCP capability is unsupported, the workflow is awkward, or the us
288
299
  app_key=app_key,
289
300
  view_id=view_id,
290
301
  columns=columns or [],
302
+ where=where or [],
303
+ order_by=order_by or [],
291
304
  record_ids=record_ids or [],
292
305
  include_workflow_log=include_workflow_log,
293
306
  download_to_path=download_to_path,
@@ -18,6 +18,7 @@ from ..json_types import JSONObject
18
18
  from .base import ToolBase, tool_cn_name
19
19
  from .record_tools import (
20
20
  AccessibleViewRoute,
21
+ DEFAULT_LIST_PAGE_SIZE,
21
22
  LAYOUT_ONLY_QUE_TYPES,
22
23
  RecordTools,
23
24
  _normalize_public_column_selectors,
@@ -58,6 +59,8 @@ class ExportTools(ToolBase):
58
59
  app_key: str = "",
59
60
  view_id: str = "system:all",
60
61
  columns: list[JSONObject | int] | None = None,
62
+ where: list[JSONObject] | None = None,
63
+ order_by: list[JSONObject] | None = None,
61
64
  record_ids: list[str | int] | None = None,
62
65
  include_workflow_log: bool = False,
63
66
  ) -> dict[str, Any]:
@@ -66,6 +69,8 @@ class ExportTools(ToolBase):
66
69
  app_key=app_key,
67
70
  view_id=view_id,
68
71
  columns=columns or [],
72
+ where=where or [],
73
+ order_by=order_by or [],
69
74
  record_ids=record_ids or [],
70
75
  include_workflow_log=include_workflow_log,
71
76
  )
@@ -98,6 +103,8 @@ class ExportTools(ToolBase):
98
103
  app_key: str = "",
99
104
  view_id: str = "system:all",
100
105
  columns: list[JSONObject | int] | None = None,
106
+ where: list[JSONObject] | None = None,
107
+ order_by: list[JSONObject] | None = None,
101
108
  record_ids: list[str | int] | None = None,
102
109
  include_workflow_log: bool = False,
103
110
  download_to_path: str | None = None,
@@ -108,6 +115,8 @@ class ExportTools(ToolBase):
108
115
  app_key=app_key,
109
116
  view_id=view_id,
110
117
  columns=columns or [],
118
+ where=where or [],
119
+ order_by=order_by or [],
111
120
  record_ids=record_ids or [],
112
121
  include_workflow_log=include_workflow_log,
113
122
  download_to_path=download_to_path,
@@ -122,12 +131,16 @@ class ExportTools(ToolBase):
122
131
  app_key: str,
123
132
  view_id: str = "system:all",
124
133
  columns: list[JSONObject | int] | None = None,
134
+ where: list[JSONObject] | None = None,
135
+ order_by: list[JSONObject] | None = None,
125
136
  record_ids: list[str | int] | None = None,
126
137
  include_workflow_log: bool = False,
127
138
  ) -> dict[str, Any]:
128
139
  normalized_app_key = str(app_key or "").strip()
129
140
  normalized_view_id = str(view_id or "").strip() or "system:all"
130
141
  normalized_columns = _normalize_export_columns(columns or [])
142
+ normalized_where = self._record_tools._normalize_record_list_where(where or [])
143
+ normalized_order_by = self._record_tools._normalize_record_list_order_by(order_by or [])
131
144
  normalized_record_ids = _normalize_export_record_ids(record_ids or [])
132
145
  if not normalized_app_key:
133
146
  return self._failed_export_result(
@@ -154,11 +167,21 @@ class ExportTools(ToolBase):
154
167
  resolved_view=resolved_view,
155
168
  column_selectors=normalized_columns,
156
169
  )
170
+ effective_record_ids, row_scope = self._resolve_export_record_scope(
171
+ profile=profile,
172
+ context=context,
173
+ app_key=normalized_app_key,
174
+ resolved_view=resolved_view,
175
+ selected_record_ids=normalized_record_ids,
176
+ where_filters=normalized_where,
177
+ order_by=normalized_order_by,
178
+ select_columns=cast(list[JSONObject], export_config.get("questionExportConfigList") or []),
179
+ )
157
180
  result_amount = self._estimate_export_result_amount(
158
181
  context,
159
182
  app_key=normalized_app_key,
160
183
  resolved_view=resolved_view,
161
- selected_record_ids=normalized_record_ids,
184
+ selected_record_ids=effective_record_ids,
162
185
  )
163
186
  self._validate_export_limits(
164
187
  result_amount=result_amount,
@@ -167,7 +190,7 @@ class ExportTools(ToolBase):
167
190
  )
168
191
  filter_bean = self._build_export_filter_bean(
169
192
  resolved_view,
170
- selected_record_ids=normalized_record_ids,
193
+ selected_record_ids=effective_record_ids,
171
194
  include_workflow_log=include_workflow_log,
172
195
  )
173
196
  started_at = _utc_now().replace(microsecond=0).isoformat()
@@ -180,7 +203,6 @@ class ExportTools(ToolBase):
180
203
  export_config=export_config,
181
204
  result_amount=result_amount,
182
205
  )
183
- row_scope = "selected" if normalized_record_ids else "all"
184
206
  column_scope = "selected" if normalized_columns else "all"
185
207
  export_handle = uuid4().hex
186
208
  self._job_store.put(
@@ -194,7 +216,7 @@ class ExportTools(ToolBase):
194
216
  "started_at": started_at,
195
217
  "uid": session_profile.uid,
196
218
  "row_scope": row_scope,
197
- "selected_record_count": len(normalized_record_ids),
219
+ "selected_record_count": len(effective_record_ids),
198
220
  "field_scope": column_scope,
199
221
  "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
200
222
  "include_workflow_log": bool(include_workflow_log),
@@ -212,7 +234,7 @@ class ExportTools(ToolBase):
212
234
  "view_id": resolved_view.view_id,
213
235
  "export_handle": export_handle,
214
236
  "row_scope": row_scope,
215
- "selected_record_count": len(normalized_record_ids),
237
+ "selected_record_count": len(effective_record_ids),
216
238
  "field_scope": column_scope,
217
239
  "selected_field_count": len(cast(list[JSONObject], export_config.get("questionExportConfigList") or [])),
218
240
  "include_workflow_log": bool(include_workflow_log),
@@ -223,7 +245,9 @@ class ExportTools(ToolBase):
223
245
  "verification": {
224
246
  "export_acknowledged": bool(socket_result.get("backend_export_id")),
225
247
  "view_route_supported": True,
226
- "row_selection_applied": bool(normalized_record_ids),
248
+ "row_selection_applied": bool(effective_record_ids),
249
+ "query_filter_applied": bool(normalized_where),
250
+ "query_sort_applied": bool(normalized_order_by),
227
251
  "field_selection_applied": bool(normalized_columns),
228
252
  },
229
253
  "request_route": self.backend.describe_route(context),
@@ -407,6 +431,8 @@ class ExportTools(ToolBase):
407
431
  app_key: str,
408
432
  view_id: str = "system:all",
409
433
  columns: list[JSONObject | int] | None = None,
434
+ where: list[JSONObject] | None = None,
435
+ order_by: list[JSONObject] | None = None,
410
436
  record_ids: list[str | int] | None = None,
411
437
  include_workflow_log: bool = False,
412
438
  download_to_path: str | None = None,
@@ -428,6 +454,8 @@ class ExportTools(ToolBase):
428
454
  app_key=normalized_app_key,
429
455
  view_id=normalized_view_id,
430
456
  columns=columns or [],
457
+ where=where or [],
458
+ order_by=order_by or [],
431
459
  record_ids=record_ids or [],
432
460
  include_workflow_log=include_workflow_log,
433
461
  )
@@ -552,6 +580,152 @@ class ExportTools(ToolBase):
552
580
  extra={"app_key": normalized_app_key, "view_id": normalized_view_id},
553
581
  )
554
582
 
583
+ def _resolve_export_record_scope(
584
+ self,
585
+ *,
586
+ profile: str,
587
+ context,
588
+ app_key: str,
589
+ resolved_view: AccessibleViewRoute,
590
+ selected_record_ids: list[int],
591
+ where_filters: list[JSONObject],
592
+ order_by: list[JSONObject],
593
+ select_columns: list[JSONObject],
594
+ ) -> tuple[list[int], str]:
595
+ if selected_record_ids and (where_filters or order_by):
596
+ raise QingflowApiError(
597
+ category="config",
598
+ message="record export does not allow record_ids together with query selectors",
599
+ details={
600
+ "error_code": "EXPORT_ROW_SCOPE_CONFLICT",
601
+ "fix_hint": "Use record_ids for explicit selected rows, or use where/order_by for internal query selection, but not both.",
602
+ },
603
+ )
604
+ if selected_record_ids:
605
+ return selected_record_ids, "selected"
606
+ if not where_filters and not order_by:
607
+ return [], "all"
608
+ resolved_ids = self._collect_record_ids_from_query(
609
+ profile=profile,
610
+ context=context,
611
+ app_key=app_key,
612
+ resolved_view=resolved_view,
613
+ where_filters=where_filters,
614
+ order_by=order_by,
615
+ select_columns=select_columns,
616
+ )
617
+ if not resolved_ids:
618
+ raise QingflowApiError.config_error(
619
+ "record export query did not match any records",
620
+ details={
621
+ "error_code": "EXPORT_NO_MATCHED_RECORDS",
622
+ "view_id": resolved_view.view_id,
623
+ },
624
+ )
625
+ return resolved_ids, "queried"
626
+
627
+ def _collect_record_ids_from_query(
628
+ self,
629
+ *,
630
+ profile: str,
631
+ context,
632
+ app_key: str,
633
+ resolved_view: AccessibleViewRoute,
634
+ where_filters: list[JSONObject],
635
+ order_by: list[JSONObject],
636
+ select_columns: list[JSONObject],
637
+ ) -> list[int]:
638
+ browse_scope = self._record_tools._build_browse_write_scope(
639
+ profile,
640
+ context,
641
+ app_key,
642
+ resolved_view,
643
+ force_refresh=False,
644
+ )
645
+ index = browse_scope["index"]
646
+ match_rules = self._record_tools._resolve_match_rules(context, where_filters, index)
647
+ query_sorts = [
648
+ {
649
+ "queId": _coerce_int(item.get("field_id")),
650
+ "direction": str(item.get("direction") or "asc"),
651
+ }
652
+ for item in order_by
653
+ if isinstance(item, dict) and _coerce_int(item.get("field_id")) is not None
654
+ ]
655
+ dept_member_cache: dict[int, set[int]] = {}
656
+ current_page = 1
657
+ selected_ids: list[int] = []
658
+ seen: set[int] = set()
659
+ primary_field_ids = [
660
+ _coerce_int(item.get("queId"))
661
+ for item in select_columns
662
+ if isinstance(item, dict)
663
+ ]
664
+ primary_search_que_ids = [item for item in primary_field_ids if item is not None][:1] or None
665
+
666
+ while True:
667
+ page = self._record_tools._search_page(
668
+ context,
669
+ app_key=app_key,
670
+ view_selection=resolved_view.view_selection,
671
+ page_num=current_page,
672
+ page_size=DEFAULT_LIST_PAGE_SIZE,
673
+ query_key=None,
674
+ match_rules=match_rules,
675
+ sorts=cast(list[JSONObject], query_sorts),
676
+ search_que_ids=primary_search_que_ids,
677
+ list_type=resolved_view.list_type if resolved_view.list_type is not None else DEFAULT_RECORD_LIST_TYPE,
678
+ )
679
+ raw_rows = page.get("list")
680
+ items = raw_rows if isinstance(raw_rows, list) else []
681
+ for item in items:
682
+ if not isinstance(item, dict):
683
+ continue
684
+ answers = item.get("answers")
685
+ answer_list = answers if isinstance(answers, list) else []
686
+ if not self._record_tools._matches_view_selection(
687
+ context,
688
+ answer_list,
689
+ view_selection=resolved_view.view_selection,
690
+ dept_member_cache=dept_member_cache,
691
+ ):
692
+ continue
693
+ record_id = _coerce_int(item.get("applyId"))
694
+ if record_id is None:
695
+ record_id = _coerce_int(item.get("apply_id"))
696
+ if record_id is None:
697
+ record_id = _coerce_int(item.get("id"))
698
+ if record_id is None:
699
+ record_id = _coerce_int(item.get("record_id"))
700
+ if record_id is None or record_id in seen or record_id <= 0:
701
+ continue
702
+ seen.add(record_id)
703
+ selected_ids.append(record_id)
704
+ if len(selected_ids) > EXPORT_ROWS_LIMIT:
705
+ raise QingflowApiError.config_error(
706
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
707
+ details={
708
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
709
+ "result_amount": len(selected_ids),
710
+ "row_limit": EXPORT_ROWS_LIMIT,
711
+ },
712
+ )
713
+ if current_page == 1:
714
+ reported_total = _effective_total(page, page_size=DEFAULT_LIST_PAGE_SIZE)
715
+ if reported_total > EXPORT_ROWS_LIMIT:
716
+ raise QingflowApiError.config_error(
717
+ f"record export exceeds the native row limit of {EXPORT_ROWS_LIMIT}",
718
+ details={
719
+ "error_code": "EXPORT_ROWS_LIMIT_EXCEEDED",
720
+ "result_amount": reported_total,
721
+ "row_limit": EXPORT_ROWS_LIMIT,
722
+ },
723
+ )
724
+ if not _page_has_more(page, current_page=current_page, page_size=DEFAULT_LIST_PAGE_SIZE, returned_rows=len(items)):
725
+ break
726
+ current_page += 1
727
+ return selected_ids
728
+
555
729
  def _build_export_filter_bean(
556
730
  self,
557
731
  resolved_view: AccessibleViewRoute,
@@ -1101,6 +1275,13 @@ def _effective_total(page: JSONObject, *, page_size: int) -> int:
1101
1275
  return returned_rows
1102
1276
 
1103
1277
 
1278
+ def _page_has_more(page: JSONObject, *, current_page: int, page_size: int, returned_rows: int) -> bool:
1279
+ page_amount = _coerce_int(page.get("pageAmount"))
1280
+ if page_amount is not None:
1281
+ return current_page < page_amount
1282
+ return returned_rows >= page_size
1283
+
1284
+
1104
1285
  def _resolve_download_targets(
1105
1286
  destination_hint: str,
1106
1287
  *,