@qingflow-tech/qingflow-cli 1.1.50-beta.3 → 1.1.50-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/pyproject.toml +1 -1
  4. package/skills/qingflow-cli/SKILL.md +37 -0
  5. package/skills/qingflow-cli/reference/builder/50-views.md +19 -4
  6. package/skills/qingflow-cli/reference/builder/80-buttons-associated-resources.md +2 -0
  7. package/src/qingflow_mcp/app_form_validator.py +53 -14
  8. package/src/qingflow_mcp/builder_facade/app_form.py +152 -47
  9. package/src/qingflow_mcp/builder_facade/models.py +19 -0
  10. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/complete.json +3 -3
  11. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/address.json +1 -1
  12. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/amount.json +1 -1
  13. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/attachment.json +1 -1
  14. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/code_block.json +3 -3
  15. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/data_relation.json +3 -3
  16. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/date.json +1 -1
  17. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/datetime.json +1 -1
  18. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/department.json +3 -3
  19. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/email.json +1 -1
  20. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/link.json +1 -1
  21. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/long_text.json +1 -1
  22. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/member.json +3 -3
  23. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/multi_select.json +1 -1
  24. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/number.json +1 -1
  25. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/phone.json +1 -1
  26. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/q_linker.json +3 -3
  27. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/relation.json +3 -3
  28. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/rich_text.json +1 -1
  29. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_choice.json +1 -1
  30. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/single_select.json +1 -1
  31. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/subtable.json +3 -3
  32. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/fields/text.json +1 -1
  33. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/formula.json +35 -0
  34. package/src/qingflow_mcp/builder_facade/schemas/app_form/v1alpha1/summary.json +2 -2
  35. package/src/qingflow_mcp/builder_facade/schemas/views/manifest.json +2 -3
  36. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/complete.json +68 -16
  37. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/button-config.json +6 -6
  38. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/data-permission.json +1 -1
  39. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/display.json +13 -11
  40. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/operation-permission.json +3 -3
  41. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/details/query-condition.json +1 -1
  42. package/src/qingflow_mcp/builder_facade/schemas/views/v1alpha1/summary.json +51 -6
  43. package/src/qingflow_mcp/builder_facade/service.py +198 -83
  44. package/src/qingflow_mcp/builder_facade/views_extract.py +21 -1
  45. package/src/qingflow_mcp/cli/commands/builder.py +4 -2
  46. package/src/qingflow_mcp/server_app_builder.py +2 -2
  47. package/src/qingflow_mcp/solution/compiler/view_compiler.py +2 -1
  48. package/src/qingflow_mcp/solution/executor.py +3 -1
  49. package/src/qingflow_mcp/solution/spec_models.py +1 -0
  50. package/src/qingflow_mcp/tools/ai_builder_tools.py +7 -5
@@ -163,9 +163,14 @@ FIELD_TYPE_TO_QUESTION_TYPE: dict[str, int] = {
163
163
  FieldType.relation.value: 25,
164
164
  }
165
165
 
166
+ # code_block / q_linker 输出目标字段类型。后端写链无类型白名单(FormSvcImpl 原样
167
+ # 落库),运行时 DataSvcImpl.getAnswerValueByString:attachment 按拆分 URL 写入、
168
+ # subtable 整表组装(均有专门分支),rich_text/data_relation 走 Question.canImport
169
+ # 兜底按字符串写入(不报错)——四者为存量合法配置,与 validator 侧白名单同口径。
166
170
  INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
167
171
  FieldType.text.value,
168
172
  FieldType.long_text.value,
173
+ "rich_text",
169
174
  FieldType.number.value,
170
175
  FieldType.amount.value,
171
176
  FieldType.date.value,
@@ -173,6 +178,9 @@ INTEGRATION_OUTPUT_TARGET_FIELD_TYPES: tuple[str, ...] = (
173
178
  FieldType.single_select.value,
174
179
  FieldType.multi_select.value,
175
180
  FieldType.boolean.value,
181
+ FieldType.attachment.value,
182
+ "data_relation",
183
+ FieldType.subtable.value,
176
184
  )
177
185
 
178
186
  MATCH_TYPE_ACCURACY = 1
@@ -3925,7 +3933,7 @@ class AiBuilderFacade:
3925
3933
  "reason_path": f"patch_buttons[{index}].button_id",
3926
3934
  "button_id": selector_id,
3927
3935
  "message": "button_id does not exist in the current app draft",
3928
- "next_action": "call app_get and use custom_buttons[].button_id",
3936
+ "next_action": "call app_get_buttons with button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
3929
3937
  }
3930
3938
  issues.append(issue)
3931
3939
  results.append({"index": index, "status": "failed", **issue})
@@ -4229,7 +4237,7 @@ class AiBuilderFacade:
4229
4237
  "reason_path": f"upsert_buttons[{index}].button_id",
4230
4238
  "button_id": button_id,
4231
4239
  "message": "button_id does not exist in the current app draft",
4232
- "next_action": "call app_get and use custom_buttons[].button_id",
4240
+ "next_action": "call app_get_buttons with button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
4233
4241
  }
4234
4242
  )
4235
4243
  continue
@@ -4300,7 +4308,7 @@ class AiBuilderFacade:
4300
4308
  "reason_path": f"remove_buttons[{index}].button_id",
4301
4309
  "button_id": selector_id,
4302
4310
  "message": "button_id does not exist in the current app draft",
4303
- "next_action": "call app_get and use custom_buttons[].button_id",
4311
+ "next_action": "call app_get_buttons with button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
4304
4312
  }
4305
4313
  )
4306
4314
  continue
@@ -7173,10 +7181,14 @@ class AiBuilderFacade:
7173
7181
  fields = cast(list[dict[str, Any]], state["parsed"]["fields"])
7174
7182
  return _chart_field_names_by_id_from_public_fields(app_key=app_key, fields=fields), None
7175
7183
 
7176
- def app_get_buttons(self, *, profile: str, app_key: str) -> JSONObject:
7184
+ def app_get_buttons(self, *, profile: str, app_key: str, button_text: str | None = None) -> JSONObject:
7177
7185
  self.apps._require_app_key(app_key)
7178
7186
  warnings: list[dict[str, Any]] = []
7179
7187
  verification: dict[str, Any] = {"buttons_loaded": True, "view_configs_loaded": True}
7188
+ normalized_text = str(button_text or "").strip() or None
7189
+ normalized_args: dict[str, Any] = {"app_key": app_key}
7190
+ if normalized_text is not None:
7191
+ normalized_args["button_text"] = normalized_text
7180
7192
  try:
7181
7193
  raw = self.buttons.custom_button_list(profile=profile, app_key=app_key, being_draft=True)
7182
7194
  except (QingflowApiError, RuntimeError) as error:
@@ -7184,11 +7196,18 @@ class AiBuilderFacade:
7184
7196
  return _failed_from_api_error(
7185
7197
  "BUTTONS_READ_FAILED",
7186
7198
  api_error,
7187
- normalized_args={"app_key": app_key},
7199
+ normalized_args=normalized_args,
7188
7200
  details={"app_key": app_key},
7189
7201
  suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
7190
7202
  )
7191
7203
  buttons = raw.get("items") if isinstance(raw.get("items"), list) else []
7204
+ total_count = len(buttons)
7205
+ if normalized_text is not None:
7206
+ buttons = [
7207
+ item
7208
+ for item in buttons
7209
+ if isinstance(item, dict) and str(item.get("buttonText") or item.get("button_text") or "").strip() == normalized_text
7210
+ ]
7192
7211
  view_configs: list[dict[str, Any]] = []
7193
7212
  view_config_read_errors: list[dict[str, Any]] = []
7194
7213
  try:
@@ -7208,7 +7227,7 @@ class AiBuilderFacade:
7208
7227
  if view_config_read_errors:
7209
7228
  verification["view_configs_loaded"] = False
7210
7229
  warnings.append(_warning("BUTTON_VIEW_CONFIGS_PARTIAL", "some view button binding configs could not be read back"))
7211
- return {
7230
+ response: JSONObject = {
7212
7231
  "status": "success",
7213
7232
  "error_code": None,
7214
7233
  "recoverable": False,
@@ -7223,6 +7242,11 @@ class AiBuilderFacade:
7223
7242
  "verification": verification,
7224
7243
  "details": {"view_config_read_errors": view_config_read_errors} if view_config_read_errors else {},
7225
7244
  }
7245
+ if normalized_text is not None:
7246
+ response["button_text_filter"] = normalized_text
7247
+ response["total_count"] = total_count
7248
+ verification["button_text_exact_match"] = True
7249
+ return response
7226
7250
 
7227
7251
  def app_get_associated_resources(self, *, profile: str, app_key: str) -> JSONObject:
7228
7252
  self.apps._require_app_key(app_key)
@@ -9507,6 +9531,7 @@ class AiBuilderFacade:
9507
9531
  "view_name": patch.get("name"),
9508
9532
  }
9509
9533
  for issue in filter_issues
9534
+ if issue.get("error_code")
9510
9535
  )
9511
9536
  if translated_filters:
9512
9537
  patch["filters"] = [dict(rule) for rule in (patch.get("filters") or [])]
@@ -12406,6 +12431,7 @@ class AiBuilderFacade:
12406
12431
  and _extract_view_name(view) not in removed
12407
12432
  and _extract_view_key(view) not in removed_keys
12408
12433
  ]
12434
+ stale_warnings: list[dict[str, Any]] = []
12409
12435
  for ordinal, patch in ((1, upsert_views[0]),) if operation == "upsert" and upsert_views else ():
12410
12436
  apply_columns = _resolve_view_visible_que_ids(patch)
12411
12437
  if patch.type in {PublicViewType.table, PublicViewType.card} and patch.columns and not apply_columns:
@@ -12415,65 +12441,37 @@ class AiBuilderFacade:
12415
12441
  normalized_args=normalized_args,
12416
12442
  details={"app_key": app_key, "view_name": patch.name},
12417
12443
  )
12444
+ # 死引用(字段已从表单删除,后端保存视图时原样透传)降级为警告透传,不再整视图拒绝
12418
12445
  missing_columns = [
12419
12446
  que for que in apply_columns
12420
12447
  if not _is_known_system_view_que_id(que) and que not in fields_by_que_id
12421
12448
  ]
12422
12449
  if missing_columns:
12423
- return _failed(
12424
- "UNKNOWN_VIEW_FIELD",
12425
- "view columns reference unknown fields",
12426
- normalized_args=normalized_args,
12427
- details={
12428
- "app_key": app_key,
12429
- "view_name": patch.name,
12430
- "missing_fields": missing_columns,
12431
- },
12432
- missing_fields=missing_columns,
12433
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12450
+ stale_warnings.append(
12451
+ _stale_view_reference_warning(scope="columns", view_name=patch.name, field_ids=missing_columns)
12434
12452
  )
12435
12453
  if patch.group_que_id and patch.group_que_id not in fields_by_que_id:
12436
- return _failed(
12437
- "UNKNOWN_VIEW_FIELD",
12438
- f"group_by references unknown field '{patch.group_que_id}'",
12439
- normalized_args=normalized_args,
12440
- details={
12441
- "app_key": app_key,
12442
- "view_name": patch.name,
12443
- "missing_fields": [patch.group_que_id],
12444
- },
12445
- missing_fields=[patch.group_que_id],
12446
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12454
+ stale_warnings.append(
12455
+ _stale_view_reference_warning(scope="group_que_id", view_name=patch.name, field_ids=[patch.group_que_id])
12447
12456
  )
12448
- for gantt_que_id in (patch.start_que_id, patch.end_que_id, patch.title_que_id):
12449
- if gantt_que_id and gantt_que_id not in fields_by_que_id:
12450
- return _failed(
12451
- "UNKNOWN_VIEW_FIELD",
12452
- f"gantt configuration references unknown field '{gantt_que_id}'",
12453
- normalized_args=normalized_args,
12454
- details={
12455
- "app_key": app_key,
12456
- "view_name": patch.name,
12457
- "missing_fields": [gantt_que_id],
12458
- },
12459
- missing_fields=[gantt_que_id],
12460
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12457
+ for ref_scope, ref_que_id in (
12458
+ ("gantt.start_que_id", patch.start_que_id),
12459
+ ("gantt.end_que_id", patch.end_que_id),
12460
+ ("title_que_id", patch.title_que_id),
12461
+ ):
12462
+ # 系统字段(提交人/提交时间等)可作标题/时间轴;业务字段死引用仅警告透传
12463
+ if ref_que_id and not _is_known_system_view_que_id(ref_que_id) and ref_que_id not in fields_by_que_id:
12464
+ stale_warnings.append(
12465
+ _stale_view_reference_warning(scope=ref_scope, view_name=patch.name, field_ids=[ref_que_id])
12461
12466
  )
12462
12467
  for group_name, config_que_ids in _iter_view_config_group_field_names(patch):
12463
- missing_config_fields = [que for que in config_que_ids if que not in fields_by_que_id]
12468
+ missing_config_fields = [
12469
+ que for que in config_que_ids
12470
+ if que not in fields_by_que_id and not _is_known_system_view_que_id(que)
12471
+ ]
12464
12472
  if missing_config_fields:
12465
- return _failed(
12466
- "UNKNOWN_VIEW_FIELD",
12467
- f"view {group_name} configuration references unknown fields",
12468
- normalized_args=normalized_args,
12469
- details={
12470
- "app_key": app_key,
12471
- "view_name": patch.name,
12472
- "missing_fields": missing_config_fields,
12473
- "reason_path": f"{group_name}",
12474
- },
12475
- missing_fields=missing_config_fields,
12476
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12473
+ stale_warnings.append(
12474
+ _stale_view_reference_warning(scope=group_name, view_name=patch.name, field_ids=missing_config_fields)
12477
12475
  )
12478
12476
  hierarchy_root_filter_groups: list[list[dict[str, Any]]] | None = None
12479
12477
  if patch.hierarchy is not None and patch.hierarchy.root_filter:
@@ -12483,8 +12481,19 @@ class AiBuilderFacade:
12483
12481
  fields_by_que_id=fields_by_que_id,
12484
12482
  filters=root_filter_group,
12485
12483
  )
12486
- if hierarchy_filter_issues:
12487
- first_issue = hierarchy_filter_issues[0]
12484
+ for issue in hierarchy_filter_issues:
12485
+ if issue.get("warning_code") == "STALE_OPTION_REFERENCE":
12486
+ stale_warnings.append(
12487
+ _stale_view_reference_warning(
12488
+ scope="hierarchy.root_filter",
12489
+ view_name=patch.name,
12490
+ values=issue.get("details", {}).get("unresolved_values") or [],
12491
+ option=True,
12492
+ )
12493
+ )
12494
+ hierarchy_filter_errors = [issue for issue in hierarchy_filter_issues if issue.get("error_code")]
12495
+ if hierarchy_filter_errors:
12496
+ first_issue = hierarchy_filter_errors[0]
12488
12497
  return _failed(
12489
12498
  str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
12490
12499
  "view hierarchy root_filter references invalid fields or values",
@@ -12512,20 +12521,32 @@ class AiBuilderFacade:
12512
12521
  else:
12513
12522
  translated_filters, filter_issues = _build_view_filter_groups(fields_by_que_id=fields_by_que_id, filters=patch.filters)
12514
12523
  if filter_issues:
12515
- first_issue = filter_issues[0]
12516
- return _failed(
12517
- str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
12518
- "view filters reference invalid fields or values",
12519
- normalized_args=normalized_args,
12520
- details={
12521
- "app_key": app_key,
12522
- "view_name": patch.name,
12523
- **first_issue,
12524
- },
12525
- missing_fields=list(first_issue.get("missing_fields") or []),
12526
- allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
12527
- suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12528
- )
12524
+ for issue in filter_issues:
12525
+ if issue.get("warning_code") == "STALE_OPTION_REFERENCE":
12526
+ stale_warnings.append(
12527
+ _stale_view_reference_warning(
12528
+ scope="filters",
12529
+ view_name=patch.name,
12530
+ values=issue.get("details", {}).get("unresolved_values") or [],
12531
+ option=True,
12532
+ )
12533
+ )
12534
+ filter_errors = [issue for issue in filter_issues if issue.get("error_code")]
12535
+ if filter_errors:
12536
+ first_issue = filter_errors[0]
12537
+ return _failed(
12538
+ str(first_issue.get("error_code") or "UNKNOWN_VIEW_FIELD"),
12539
+ "view filters reference invalid fields or values",
12540
+ normalized_args=normalized_args,
12541
+ details={
12542
+ "app_key": app_key,
12543
+ "view_name": patch.name,
12544
+ **first_issue,
12545
+ },
12546
+ missing_fields=list(first_issue.get("missing_fields") or []),
12547
+ allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
12548
+ suggested_next_call={"tool_name": "app_get_fields", "arguments": {"profile": profile, "app_key": app_key}},
12549
+ )
12529
12550
  if patch.preserve_query_conditions:
12530
12551
  query_condition_payload = None
12531
12552
  expected_query_conditions = None
@@ -12606,7 +12627,10 @@ class AiBuilderFacade:
12606
12627
  },
12607
12628
  missing_fields=list(first_issue.get("missing_fields") or []),
12608
12629
  allowed_values=first_issue.get("allowed_values") or {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
12609
- suggested_next_call={"tool_name": "app_get", "arguments": {"profile": profile, "app_key": app_key}},
12630
+ suggested_next_call={
12631
+ "tool_name": "app_get_buttons",
12632
+ "arguments": {"profile": profile, "app_key": app_key},
12633
+ },
12610
12634
  )
12611
12635
  expected_button_summary = _normalize_expected_view_buttons_for_compare(
12612
12636
  explicit_button_dtos or [],
@@ -13509,7 +13533,8 @@ class AiBuilderFacade:
13509
13533
  "http_status": first_failure.get("http_status"),
13510
13534
  "noop": noop,
13511
13535
  "warnings": (
13512
- (
13536
+ list(stale_warnings)
13537
+ + (
13513
13538
  [_warning("VIEW_FILTERS_UNVERIFIED", "view definitions may exist, but saved filter behavior is not fully verified")]
13514
13539
  if (filter_readback_pending or filter_mismatches)
13515
13540
  else []
@@ -13564,6 +13589,7 @@ class AiBuilderFacade:
13564
13589
  }
13565
13590
  return finish_with_publish(response)
13566
13591
  warnings: list[dict[str, Any]] = []
13592
+ warnings.extend(stale_warnings)
13567
13593
  if filter_readback_pending or filter_mismatches:
13568
13594
  warnings.append(_warning("VIEW_FILTERS_UNVERIFIED", "view definitions were applied, but saved filter behavior is not fully verified"))
13569
13595
  if query_condition_readback_pending or query_condition_mismatches:
@@ -17438,7 +17464,7 @@ def _resolve_custom_button_view_button_ref(
17438
17464
  "reason_path": reason_path,
17439
17465
  "button_ref": button_ref,
17440
17466
  "message": "button_ref id does not exist in the current app draft or was removed in this apply",
17441
- "next_action": "call app_get and use custom_buttons[].button_id, or use a client_key from upsert_buttons",
17467
+ "next_action": "call app_get_buttons with button_text to find an existing button_id, or use a client_key from upsert_buttons",
17442
17468
  }
17443
17469
  ref_text = str(button_ref or "").strip()
17444
17470
  if ref_text in client_key_map:
@@ -20942,6 +20968,8 @@ def _normalize_portal_view_type(view: dict[str, Any]) -> str:
20942
20968
  "ganttView": "ganttView",
20943
20969
  "hierarchy": "hierarchyView",
20944
20970
  "hierarchyView": "hierarchyView",
20971
+ "calendar": "calendarView",
20972
+ "calendarView": "calendarView",
20945
20973
  }
20946
20974
  return mapping.get(raw, "tableView")
20947
20975
 
@@ -23215,6 +23243,23 @@ def _apply_field_mutation(field: dict[str, Any], mutation: Any) -> None:
23215
23243
  field.pop("_question_rebuild_required", None)
23216
23244
 
23217
23245
 
23246
+ class AmbiguousFieldSelector(ValueError):
23247
+ """name-only selector 命中多个同名字段。
23248
+
23249
+ 方案 A(duplicate-field-name):后端以 queId 为字段身份、允许同父级同名;
23250
+ name 命中多个字段时禁止静默取第一个,必须显式失败并给出候选 queId。
23251
+ compiler 包装层(app_form)把它转写为 AMBIGUOUS_FIELD_SELECTOR 错误码。
23252
+ """
23253
+
23254
+ def __init__(self, location: str, candidates: list[int]) -> None:
23255
+ self.location = location
23256
+ self.candidates = candidates
23257
+ super().__init__(
23258
+ f"{location} matches multiple fields with the same name; "
23259
+ f"use queId to disambiguate (candidate queIds: {sorted(candidates)})"
23260
+ )
23261
+
23262
+
23218
23263
  def _resolve_field_selector_with_uniqueness(
23219
23264
  *,
23220
23265
  fields: list[dict[str, Any]],
@@ -23230,7 +23275,12 @@ def _resolve_field_selector_with_uniqueness(
23230
23275
  name = str(selector.name or "").strip()
23231
23276
  matched = [field for field in fields if str(field.get("name") or "").strip() == name]
23232
23277
  if len(matched) > 1:
23233
- raise ValueError(f"{location} matched multiple fields with the same name; use field_id or que_id")
23278
+ candidate_ids = [
23279
+ ref
23280
+ for candidate in matched
23281
+ if (ref := _coerce_any_int(candidate.get("que_id"))) is not None
23282
+ ]
23283
+ raise AmbiguousFieldSelector(location, candidate_ids)
23234
23284
  else:
23235
23285
  matched = []
23236
23286
  if len(matched) != 1:
@@ -26778,7 +26828,7 @@ def _merge_view_summary_with_config(
26778
26828
  and str(entry.get("name") or "").strip()
26779
26829
  and str(entry.get("name") or "").strip() not in _KNOWN_SYSTEM_VIEW_COLUMNS
26780
26830
  ]
26781
- summary["columns"] = canonical_display_column_ids
26831
+ summary["columns"] = canonical_display_columns
26782
26832
  summary["display_columns"] = canonical_display_columns
26783
26833
  summary["display_column_ids"] = canonical_display_column_ids
26784
26834
  summary["column_details"] = canonical_display_entries
@@ -27556,6 +27606,7 @@ def _serialize_view_button_binding(
27556
27606
  "reason_path": "buttons[].button_id",
27557
27607
  "missing_fields": [],
27558
27608
  "details": {"button_id": binding.button_id},
27609
+ "next_action": "call app_get_buttons with the intended button_text to reuse an existing custom button; create only if absent via app_custom_buttons_apply",
27559
27610
  }
27560
27611
  ]
27561
27612
  translated_limits, limit_issues = _build_view_button_limit_groups(
@@ -27616,6 +27667,11 @@ def _build_view_button_limit_groups(
27616
27667
  issue["reason_path"] = "buttons[].button_limit[].values"
27617
27668
  issues.append(issue)
27618
27669
  continue
27670
+ stale_warning = translated_rule.pop("_stale_warning", None)
27671
+ if stale_warning is not None:
27672
+ stale_warning = deepcopy(stale_warning)
27673
+ stale_warning["reason_path"] = "buttons[].button_limit[].values"
27674
+ issues.append(stale_warning)
27619
27675
  translated_rules.append(translated_rule)
27620
27676
  if translated_rules:
27621
27677
  translated_groups.append(translated_rules)
@@ -29741,6 +29797,8 @@ def _normalize_view_type_name(value: Any) -> str:
29741
29797
  return "board"
29742
29798
  if "hierarchy" in normalized:
29743
29799
  return "hierarchy"
29800
+ if "calendar" in normalized:
29801
+ return "calendar"
29744
29802
  if "card" in normalized:
29745
29803
  return "card"
29746
29804
  return "table"
@@ -29847,6 +29905,10 @@ def _build_view_update_payload(
29847
29905
  payload["viewgraphType"] = "hierarchyView"
29848
29906
  payload["beingShowTitleQue"] = True
29849
29907
  payload.pop("groupQueId", None)
29908
+ elif normalized_type == "calendar":
29909
+ payload["viewgraphType"] = "calendarView"
29910
+ payload["beingShowTitleQue"] = True
29911
+ payload.pop("groupQueId", None)
29850
29912
  return _hydrate_view_backend_payload(
29851
29913
  payload=payload,
29852
29914
  view_type=normalized_type,
@@ -29949,6 +30011,7 @@ def _build_minimal_view_payload(
29949
30011
  "board": "boardView",
29950
30012
  "gantt": "ganttView",
29951
30013
  "hierarchy": "hierarchyView",
30014
+ "calendar": "calendarView",
29952
30015
  }[patch.type.value],
29953
30016
  "ordinal": ordinal,
29954
30017
  "viewgraphQueIds": visible_que_id_values,
@@ -30067,6 +30130,11 @@ def _hydrate_view_backend_payload(
30067
30130
  data["beingShowTitleQue"] = True
30068
30131
  data["groupQueId"] = None
30069
30132
  data["viewgraphGanttConfigVO"] = None
30133
+ elif view_type == "calendar":
30134
+ data["viewgraphType"] = "calendarView"
30135
+ data["beingShowTitleQue"] = True
30136
+ data["groupQueId"] = None
30137
+ data["viewgraphGanttConfigVO"] = None
30070
30138
  if view_filters is not None:
30071
30139
  data["viewgraphLimit"] = deepcopy(view_filters)
30072
30140
  data["viewgraphLimitType"] = 1
@@ -30164,6 +30232,13 @@ def _apply_view_config_groups(
30164
30232
  "rootNodeFilter": deepcopy(hierarchy_filter_groups) if hierarchy_filter_groups is not None else [],
30165
30233
  }
30166
30234
  data["titleQue"] = patch.hierarchy.title_que_id
30235
+ if patch.calendar is not None and view_type == "calendar":
30236
+ data["viewgraphCalendarConfigVO"] = {
30237
+ "titleQueId": patch.calendar.title_que_id,
30238
+ "dateQueId": patch.calendar.date_que_id,
30239
+ "colorQueId": patch.calendar.color_que_id,
30240
+ }
30241
+ data["titleQue"] = patch.calendar.title_que_id
30167
30242
  display = patch.display
30168
30243
  if display is not None:
30169
30244
  if display.row_height is not None:
@@ -30202,6 +30277,33 @@ def _apply_view_config_groups(
30202
30277
  data["beingPrintStatus"] = share.print_enabled
30203
30278
 
30204
30279
 
30280
+ def _stale_view_reference_warning(
30281
+ *,
30282
+ scope: str,
30283
+ view_name: str,
30284
+ field_ids: list[Any] | None = None,
30285
+ values: list[Any] | None = None,
30286
+ option: bool = False,
30287
+ ) -> dict[str, Any]:
30288
+ """存量死引用(已删字段/已删选项)透传警告:后端保存视图不校验引用存在性并原样透传,
30289
+ CLI 同口径透传写回,仅以警告留痕,不再整视图拒绝(apply(get(S))≡S 重放兼容)。"""
30290
+ warning = _warning(
30291
+ "STALE_OPTION_REFERENCE" if option else "STALE_FIELD_REFERENCE",
30292
+ (
30293
+ f"view {scope} references option values not present in the current form; original values passed through (backend tolerates stale references)"
30294
+ if option
30295
+ else f"view {scope} references fields not present in the current form; ids passed through (backend tolerates stale references)"
30296
+ ),
30297
+ scope=scope,
30298
+ view_name=view_name,
30299
+ )
30300
+ if field_ids:
30301
+ warning["field_ids"] = list(field_ids)
30302
+ if values:
30303
+ warning["values"] = list(values)
30304
+ return warning
30305
+
30306
+
30205
30307
  def _iter_view_config_group_field_names(patch: ViewUpsertPatch) -> list[tuple[str, list[int]]]:
30206
30308
  """枚举 complete 档新字段组引用的 queId(用于 UNKNOWN_VIEW_FIELD 校验)。"""
30207
30309
  groups: list[tuple[str, list[int]]] = []
@@ -30231,6 +30333,11 @@ def _iter_view_config_group_field_names(patch: ViewUpsertPatch) -> list[tuple[st
30231
30333
  groups.append(("gantt", gantt_ids))
30232
30334
  if patch.hierarchy is not None:
30233
30335
  groups.append(("hierarchy", [patch.hierarchy.title_que_id, patch.hierarchy.children_que_id]))
30336
+ if patch.calendar is not None:
30337
+ calendar_ids = [patch.calendar.title_que_id, patch.calendar.date_que_id]
30338
+ if patch.calendar.color_que_id is not None:
30339
+ calendar_ids.append(patch.calendar.color_que_id)
30340
+ groups.append(("calendar", calendar_ids))
30234
30341
  return groups
30235
30342
 
30236
30343
 
@@ -30241,6 +30348,8 @@ def _resolve_view_visible_que_ids(patch: ViewUpsertPatch) -> list[int]:
30241
30348
  extra_ids: list[int | None] = []
30242
30349
  if patch.hierarchy is not None:
30243
30350
  extra_ids.extend([patch.hierarchy.title_que_id, patch.hierarchy.children_que_id])
30351
+ if patch.calendar is not None:
30352
+ extra_ids.extend([patch.calendar.title_que_id, patch.calendar.date_que_id, patch.calendar.color_que_id])
30244
30353
  for value in [*patch.columns, patch.title_que_id, patch.start_que_id, patch.end_que_id, patch.group_que_id, *extra_ids]:
30245
30354
  que_id = _coerce_any_int(value)
30246
30355
  if que_id is not None and (que_id > 0 or _is_known_system_view_que_id(que_id)) and que_id not in ordered:
@@ -30328,6 +30437,9 @@ def _build_view_filter_groups(
30328
30437
  if issue:
30329
30438
  issues.append(issue)
30330
30439
  continue
30440
+ stale_warning = translated_rule.pop("_stale_warning", None)
30441
+ if stale_warning is not None:
30442
+ issues.append(stale_warning)
30331
30443
  translated_rules.append(translated_rule)
30332
30444
  return ([translated_rules] if translated_rules else []), issues
30333
30445
 
@@ -30338,9 +30450,9 @@ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any])
30338
30450
  field_type = str(field.get("type") or FieldType.text.value)
30339
30451
  que_id = _coerce_positive_int(field.get("que_id")) or 0
30340
30452
  que_type = _coerce_positive_int(field.get("que_type")) or FIELD_TYPE_TO_QUESTION_TYPE.get(field_type, 2)
30341
- judge_values, judge_value_details, issue = _resolve_view_filter_values(field=field, values=values)
30342
- if issue:
30343
- return {}, issue
30453
+ judge_values, judge_value_details, value_issue = _resolve_view_filter_values(field=field, values=values)
30454
+ if value_issue and value_issue.get("error_code"):
30455
+ return {}, value_issue
30344
30456
  payload: dict[str, Any] = {
30345
30457
  "queId": que_id,
30346
30458
  "queTitle": str(field.get("name") or ""),
@@ -30372,6 +30484,9 @@ def _translate_view_filter_rule(*, field: dict[str, Any], rule: dict[str, Any])
30372
30484
  elif operator == "not_empty":
30373
30485
  payload["judgeType"] = JUDGE_UNEQUAL
30374
30486
  payload["judgeValues"] = []
30487
+ if value_issue is not None:
30488
+ # 死选项透传:规则照常写入,warning 随 payload 携带由上层收集(_build_view_filter_groups 弹出)
30489
+ payload["_stale_warning"] = value_issue
30375
30490
  return payload, None
30376
30491
 
30377
30492
 
@@ -30403,15 +30518,15 @@ def _resolve_view_filter_values(
30403
30518
  continue
30404
30519
  resolved_details.append({"id": resolved.get("id"), "value": str(resolved.get("value") or resolved.get("id"))})
30405
30520
  if unresolved_values and not resolved_details:
30406
- # 全部值都解析不出(选项已删):返回错误让上层判定(该规则已死语义)
30521
+ # 全部值都解析不出(选项已删):原值透传(后端对存量死选项引用原样保存),
30522
+ # 以 STALE_OPTION_REFERENCE 警告留痕,不再整视图失败
30407
30523
  return (
30408
- [],
30409
- [],
30524
+ [_stringify_condition_value(value) for value in values],
30525
+ _build_view_filter_value_details(values),
30410
30526
  {
30411
- "error_code": "UNKNOWN_VIEW_FILTER_VALUE",
30527
+ "warning_code": "STALE_OPTION_REFERENCE",
30412
30528
  "reason_path": "filters[].values",
30413
30529
  "missing_fields": [],
30414
- "allowed_values": {"filter_values": sorted(option_by_value.keys())},
30415
30530
  "details": {
30416
30531
  "field_name": str(field.get("name") or ""),
30417
30532
  "unresolved_values": unresolved_values,
@@ -25,6 +25,7 @@ _VIEW_TYPE_BY_BACKEND = {
25
25
  "boardView": "board",
26
26
  "ganttView": "gantt",
27
27
  "hierarchyView": "hierarchy",
28
+ "calendarView": "calendar",
28
29
  }
29
30
 
30
31
  # 后端 dataScope → 公开 data_permission.scope(与 _DATA_PERMISSION_SCOPE_MAP 对称)
@@ -68,7 +69,6 @@ _SYSTEM_COLUMN_NAMES = {
68
69
  # complete 契约不覆盖、读面存在的配置键(进入 SKIP 账本,evaluation.md §4.3)
69
70
  _UNCOVERED_READBACK_KEYS = (
70
71
  "attachVisibleOnlyConfig",
71
- "viewgraphCalendarConfigVO",
72
72
  "beingRemarkStatus",
73
73
  "querySearchStatus",
74
74
  "beingDefaultPrintTplStatus",
@@ -417,6 +417,26 @@ def extract_complete_view_declaration(
417
417
  declaration["hierarchy"] = hierarchy
418
418
  _optionally(declaration, "title_que_id", title_que)
419
419
 
420
+ if view_type == "calendar":
421
+ calendar_raw = view_info.get("viewgraphCalendarConfigVO") if isinstance(view_info.get("viewgraphCalendarConfigVO"), dict) else {}
422
+ title_que = _coerce_positive_int(calendar_raw.get("titleQueId"))
423
+ date_que = _coerce_any_int(calendar_raw.get("dateQueId"))
424
+ color_que = _coerce_positive_int(calendar_raw.get("colorQueId"))
425
+ if title_que is None or title_que not in fields_by_que_id:
426
+ gaps.append(_gap("calendar.title_que_id", "unresolved_calendar_config", "calendar title queId does not resolve; view is not extractable", que_id=title_que))
427
+ return {}, gaps
428
+ if date_que is None or (date_que not in {2, 3} and date_que not in fields_by_que_id):
429
+ gaps.append(_gap("calendar.date_que_id", "unresolved_calendar_config", "calendar date queId does not resolve; view is not extractable", que_id=date_que))
430
+ return {}, gaps
431
+ calendar: dict[str, Any] = {"title_que_id": title_que, "date_que_id": date_que}
432
+ if color_que is not None:
433
+ if color_que not in fields_by_que_id:
434
+ gaps.append(_gap("calendar.color_que_id", "unresolved_que_id", "calendar color queId does not resolve", que_id=color_que))
435
+ return {}, gaps
436
+ calendar["color_que_id"] = color_que
437
+ declaration["calendar"] = calendar
438
+ declaration["title_que_id"] = title_que
439
+
420
440
  if view_type in {"card", "board"}:
421
441
  _optionally(declaration, "title_que_id", _coerce_positive_int(view_info.get("titleQue")))
422
442
 
@@ -225,6 +225,7 @@ def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) ->
225
225
  button_get.add_argument("--app-key", default="")
226
226
  button_get.add_argument("--app-keys", default="", help="逗号分隔的多应用 app_key;用于批量读取按钮配置")
227
227
  button_get.add_argument("--app-keys-file", help="JSON 字符串数组;用于批量读取按钮配置")
228
+ button_get.add_argument("--button-text", default="", help="按按钮文案精确查重;命中复用其 button_id,未命中再创建")
228
229
  button_get.set_defaults(handler=_handle_button_get, format_hint="builder_summary")
229
230
 
230
231
  button_apply = button_subparsers.add_parser("apply", help="声明式创建、更新或删除自定义按钮")
@@ -898,12 +899,13 @@ def _handle_associated_resource_apply(args: argparse.Namespace, context: CliCont
898
899
 
899
900
 
900
901
  def _handle_button_get(args: argparse.Namespace, context: CliContext) -> dict:
902
+ button_text = str(getattr(args, "button_text", "") or "").strip() or None
901
903
  app_keys = _app_keys_from_args(args)
902
904
  if app_keys is not None:
903
- return context.builder.app_get_buttons(profile=args.profile, app_key="", app_keys=app_keys)
905
+ return context.builder.app_get_buttons(profile=args.profile, app_key="", app_keys=app_keys, button_text=button_text)
904
906
  if not (args.app_key or "").strip():
905
907
  raise_config_error("builder button get requires --app-key unless --app-keys or --app-keys-file is provided")
906
- return context.builder.app_get_buttons(profile=args.profile, app_key=args.app_key)
908
+ return context.builder.app_get_buttons(profile=args.profile, app_key=args.app_key, button_text=button_text)
907
909
 
908
910
 
909
911
  def _handle_associated_resource_get(args: argparse.Namespace, context: CliContext) -> dict:
@@ -471,8 +471,8 @@ def build_builder_server() -> FastMCP:
471
471
  return ai_builder.app_get_charts(profile=profile, app_key=app_key, app_keys=app_keys)
472
472
 
473
473
  @server.tool()
474
- def app_get_buttons(profile: str = DEFAULT_PROFILE, app_key: str = "", app_keys: list[str] | None = None) -> dict:
475
- return ai_builder.app_get_buttons(profile=profile, app_key=app_key, app_keys=app_keys)
474
+ def app_get_buttons(profile: str = DEFAULT_PROFILE, app_key: str = "", app_keys: list[str] | None = None, button_text: str | None = None) -> dict:
475
+ return ai_builder.app_get_buttons(profile=profile, app_key=app_key, app_keys=app_keys, button_text=button_text)
476
476
 
477
477
  @server.tool()
478
478
  def app_get_associated_resources(profile: str = DEFAULT_PROFILE, app_key: str = "", app_keys: list[str] | None = None) -> dict:
@@ -12,6 +12,7 @@ VIEW_TYPE_MAP = {
12
12
  ViewType.board: "boardView",
13
13
  ViewType.gantt: "ganttView",
14
14
  ViewType.hierarchy: "hierarchyView",
15
+ ViewType.calendar: "calendarView",
15
16
  }
16
17
 
17
18
 
@@ -32,7 +33,7 @@ def compile_views(entity: EntitySpec) -> list[dict[str, Any]]:
32
33
  "viewgraphQueIds": list(view.field_ids),
33
34
  "viewgraphQuestions": [],
34
35
  "ordinal": ordinal,
35
- "beingShowTitleQue": view.type in (ViewType.card, ViewType.board, ViewType.gantt, ViewType.hierarchy),
36
+ "beingShowTitleQue": view.type in (ViewType.card, ViewType.board, ViewType.gantt, ViewType.hierarchy, ViewType.calendar),
36
37
  "beingShowCover": False,
37
38
  "viewgraphLimitType": 1,
38
39
  "viewgraphLimit": view.filters,