@qingflow-tech/qingflow-app-builder-mcp 1.0.3 → 1.0.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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/pyproject.toml +2 -1
- package/src/qingflow_mcp/__init__.py +1 -1
- package/src/qingflow_mcp/backend_client.py +164 -1
- package/src/qingflow_mcp/builder_facade/button_style_catalog.py +282 -0
- package/src/qingflow_mcp/builder_facade/models.py +44 -5
- package/src/qingflow_mcp/builder_facade/service.py +21 -8
- package/src/qingflow_mcp/cli/commands/__init__.py +2 -1
- package/src/qingflow_mcp/cli/commands/app.py +47 -1
- package/src/qingflow_mcp/cli/commands/builder.py +7 -0
- package/src/qingflow_mcp/cli/commands/exports.py +111 -0
- package/src/qingflow_mcp/cli/commands/record.py +44 -5
- package/src/qingflow_mcp/cli/commands/task.py +644 -22
- package/src/qingflow_mcp/cli/commands/workspace.py +64 -2
- package/src/qingflow_mcp/cli/context.py +3 -0
- package/src/qingflow_mcp/cli/formatters.py +240 -5
- package/src/qingflow_mcp/cli/interaction.py +72 -0
- package/src/qingflow_mcp/cli/main.py +5 -0
- package/src/qingflow_mcp/cli/terminal_ui.py +218 -0
- package/src/qingflow_mcp/errors.py +2 -2
- package/src/qingflow_mcp/export_store.py +14 -0
- package/src/qingflow_mcp/public_surface.py +7 -1
- package/src/qingflow_mcp/response_trim.py +188 -10
- package/src/qingflow_mcp/server.py +37 -9
- package/src/qingflow_mcp/server_app_builder.py +4 -0
- package/src/qingflow_mcp/server_app_user.py +115 -10
- package/src/qingflow_mcp/session_store.py +57 -6
- package/src/qingflow_mcp/tools/ai_builder_tools.py +59 -16
- package/src/qingflow_mcp/tools/auth_tools.py +26 -0
- package/src/qingflow_mcp/tools/custom_button_tools.py +0 -2
- package/src/qingflow_mcp/tools/export_tools.py +1565 -0
- package/src/qingflow_mcp/tools/import_tools.py +42 -2
- package/src/qingflow_mcp/tools/record_tools.py +12793 -8612
- package/src/qingflow_mcp/tools/resource_read_tools.py +40 -1
- package/src/qingflow_mcp/tools/task_context_tools.py +26 -8
package/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
Install:
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
|
-
npm install @qingflow-tech/qingflow-app-builder-mcp@1.0.
|
|
6
|
+
npm install @qingflow-tech/qingflow-app-builder-mcp@1.0.5
|
|
7
7
|
```
|
|
8
8
|
|
|
9
9
|
Run:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npx -y -p @qingflow-tech/qingflow-app-builder-mcp@1.0.
|
|
12
|
+
npx -y -p @qingflow-tech/qingflow-app-builder-mcp@1.0.5 qingflow-app-builder-mcp
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Environment:
|
package/package.json
CHANGED
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 = "1.0.
|
|
7
|
+
version = "1.0.5"
|
|
8
8
|
description = "User-authenticated MCP server for Qingflow"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -29,6 +29,7 @@ dependencies = [
|
|
|
29
29
|
"openpyxl>=3.1,<4.0",
|
|
30
30
|
"pydantic>=2.8,<3.0",
|
|
31
31
|
"pycryptodome>=3.20,<4.0",
|
|
32
|
+
"pypdf>=5.0,<6.0",
|
|
32
33
|
"python-socketio[client]>=5.11,<6.0",
|
|
33
34
|
]
|
|
34
35
|
|
|
@@ -4,7 +4,7 @@ from dataclasses import dataclass
|
|
|
4
4
|
from threading import Event
|
|
5
5
|
from time import sleep
|
|
6
6
|
from typing import Any
|
|
7
|
-
from urllib.parse import urlsplit, urlunsplit
|
|
7
|
+
from urllib.parse import urljoin, urlsplit, urlunsplit
|
|
8
8
|
from uuid import uuid4
|
|
9
9
|
|
|
10
10
|
import httpx
|
|
@@ -33,6 +33,15 @@ class BackendResponse:
|
|
|
33
33
|
qf_response_version: str | None = None
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class BackendBinaryResponse:
|
|
38
|
+
content: bytes
|
|
39
|
+
headers: dict[str, str]
|
|
40
|
+
http_status: int
|
|
41
|
+
final_url: str
|
|
42
|
+
redirected: bool
|
|
43
|
+
|
|
44
|
+
|
|
36
45
|
class BackendClient:
|
|
37
46
|
def __init__(self, timeout: float | None = None, client: httpx.Client | None = None) -> None:
|
|
38
47
|
self._owns_client = client is None
|
|
@@ -290,6 +299,51 @@ class BackendClient:
|
|
|
290
299
|
)
|
|
291
300
|
return response.content
|
|
292
301
|
|
|
302
|
+
def download_binary_with_cookie(
|
|
303
|
+
self,
|
|
304
|
+
context: BackendRequestContext,
|
|
305
|
+
url: str,
|
|
306
|
+
*,
|
|
307
|
+
cookie_name: str,
|
|
308
|
+
headers: dict[str, str] | None = None,
|
|
309
|
+
) -> BackendBinaryResponse:
|
|
310
|
+
qf_version, _source = self._resolve_qf_version(context.qf_version)
|
|
311
|
+
request_headers = {
|
|
312
|
+
"User-Agent": DEFAULT_USER_AGENT,
|
|
313
|
+
"Qf-Request-Id": context.qf_request_id or str(uuid4()),
|
|
314
|
+
}
|
|
315
|
+
if headers:
|
|
316
|
+
request_headers.update({key: value for key, value in headers.items() if value is not None})
|
|
317
|
+
cookie_parts = [f"{cookie_name}={context.token}"]
|
|
318
|
+
if qf_version:
|
|
319
|
+
cookie_parts.append(f"qfVersion={qf_version}")
|
|
320
|
+
request_headers["Cookie"] = "; ".join(cookie_parts)
|
|
321
|
+
try:
|
|
322
|
+
response = self._client.get(url, headers=request_headers, follow_redirects=False)
|
|
323
|
+
except httpx.RequestError as exc:
|
|
324
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
325
|
+
redirected = 300 <= response.status_code < 400 and bool(response.headers.get("location"))
|
|
326
|
+
if redirected:
|
|
327
|
+
redirect_headers = {key: value for key, value in request_headers.items() if key.lower() != "cookie"}
|
|
328
|
+
redirect_url = urljoin(str(response.url), response.headers["location"])
|
|
329
|
+
try:
|
|
330
|
+
response = self._client.get(redirect_url, headers=redirect_headers)
|
|
331
|
+
except httpx.RequestError as exc:
|
|
332
|
+
raise QingflowApiError(category="network", message=str(exc))
|
|
333
|
+
if response.status_code >= 400:
|
|
334
|
+
raise QingflowApiError(
|
|
335
|
+
category="http",
|
|
336
|
+
message=self._extract_message(response.text) or f"HTTP {response.status_code}",
|
|
337
|
+
http_status=response.status_code,
|
|
338
|
+
)
|
|
339
|
+
return BackendBinaryResponse(
|
|
340
|
+
content=response.content,
|
|
341
|
+
headers=dict(response.headers),
|
|
342
|
+
http_status=response.status_code,
|
|
343
|
+
final_url=str(response.url),
|
|
344
|
+
redirected=redirected or bool(response.history) or str(response.url) != url,
|
|
345
|
+
)
|
|
346
|
+
|
|
293
347
|
def request_multipart(
|
|
294
348
|
self,
|
|
295
349
|
method: str,
|
|
@@ -474,6 +528,115 @@ class BackendClient:
|
|
|
474
528
|
pass
|
|
475
529
|
return import_result
|
|
476
530
|
|
|
531
|
+
def start_socket_record_export(
|
|
532
|
+
self,
|
|
533
|
+
context: BackendRequestContext,
|
|
534
|
+
*,
|
|
535
|
+
app_key: str,
|
|
536
|
+
view_id: str,
|
|
537
|
+
filter_bean: JSONObject,
|
|
538
|
+
export_config: JSONObject,
|
|
539
|
+
view_key: str | None = None,
|
|
540
|
+
result_amount: int = 0,
|
|
541
|
+
ack_timeout_seconds: float = 8.0,
|
|
542
|
+
) -> dict[str, Any]:
|
|
543
|
+
try:
|
|
544
|
+
import socketio # type: ignore[import-not-found]
|
|
545
|
+
except ImportError as exc:
|
|
546
|
+
raise QingflowApiError(
|
|
547
|
+
category="config",
|
|
548
|
+
message=f"socket.io client dependency is missing: {exc}",
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
socket_base_url = self._build_socket_base_url(context.base_url)
|
|
552
|
+
export_result: dict[str, Any] = {
|
|
553
|
+
"backend_export_id": None,
|
|
554
|
+
"warnings": [],
|
|
555
|
+
}
|
|
556
|
+
sio = socketio.Client(reconnection=False, logger=False, engineio_logger=False)
|
|
557
|
+
event_name = "excelViewgraph" if view_key else "excel"
|
|
558
|
+
event_args: tuple[Any, ...]
|
|
559
|
+
if view_key:
|
|
560
|
+
event_args = (
|
|
561
|
+
context.token,
|
|
562
|
+
None,
|
|
563
|
+
view_key,
|
|
564
|
+
filter_bean,
|
|
565
|
+
export_config,
|
|
566
|
+
int(result_amount),
|
|
567
|
+
)
|
|
568
|
+
else:
|
|
569
|
+
event_args = (
|
|
570
|
+
context.token,
|
|
571
|
+
app_key,
|
|
572
|
+
filter_bean,
|
|
573
|
+
export_config,
|
|
574
|
+
int(result_amount),
|
|
575
|
+
)
|
|
576
|
+
try:
|
|
577
|
+
sio.connect(
|
|
578
|
+
socket_base_url,
|
|
579
|
+
transports=["websocket"],
|
|
580
|
+
socketio_path="socket.io",
|
|
581
|
+
headers=self._base_headers(
|
|
582
|
+
context.token,
|
|
583
|
+
context.ws_id,
|
|
584
|
+
qf_version=context.qf_version,
|
|
585
|
+
),
|
|
586
|
+
wait_timeout=ack_timeout_seconds,
|
|
587
|
+
)
|
|
588
|
+
sio.emit("token", context.token)
|
|
589
|
+
sleep(0.2)
|
|
590
|
+
ack = sio.call(
|
|
591
|
+
event_name,
|
|
592
|
+
event_args,
|
|
593
|
+
timeout=ack_timeout_seconds,
|
|
594
|
+
)
|
|
595
|
+
ack_payload = ack[0] if isinstance(ack, list) and ack else ack
|
|
596
|
+
export_id: Any = ack_payload
|
|
597
|
+
if isinstance(ack_payload, dict):
|
|
598
|
+
error_code = ack_payload.get("error")
|
|
599
|
+
ack_message = ack_payload.get("message")
|
|
600
|
+
export_id = ack_payload.get("data")
|
|
601
|
+
if isinstance(export_id, dict):
|
|
602
|
+
export_id = (
|
|
603
|
+
export_id.get("exportId")
|
|
604
|
+
or export_id.get("export_id")
|
|
605
|
+
or export_id.get("id")
|
|
606
|
+
)
|
|
607
|
+
if error_code not in (None, 0):
|
|
608
|
+
raise QingflowApiError(
|
|
609
|
+
category="backend",
|
|
610
|
+
message=str(ack_message or f"socket export rejected with error {error_code}"),
|
|
611
|
+
details={
|
|
612
|
+
"socket_error_code": error_code,
|
|
613
|
+
"app_key": app_key,
|
|
614
|
+
"view_id": view_id,
|
|
615
|
+
"view_key": view_key,
|
|
616
|
+
},
|
|
617
|
+
)
|
|
618
|
+
if not export_id:
|
|
619
|
+
raise QingflowApiError(category="backend", message="socket export ack did not return export_id")
|
|
620
|
+
export_result["backend_export_id"] = str(export_id)
|
|
621
|
+
except Exception as exc:
|
|
622
|
+
message = str(exc)
|
|
623
|
+
if "timeout" in message.lower():
|
|
624
|
+
raise QingflowApiError(
|
|
625
|
+
category="network",
|
|
626
|
+
message="socket export ack timed out",
|
|
627
|
+
details={"error_code": "EXPORT_SOCKET_ACK_TIMEOUT"},
|
|
628
|
+
)
|
|
629
|
+
if isinstance(exc, QingflowApiError):
|
|
630
|
+
raise
|
|
631
|
+
raise QingflowApiError(category="network", message=message or "socket export failed")
|
|
632
|
+
finally:
|
|
633
|
+
try:
|
|
634
|
+
if sio.connected:
|
|
635
|
+
sio.disconnect()
|
|
636
|
+
except Exception:
|
|
637
|
+
pass
|
|
638
|
+
return export_result
|
|
639
|
+
|
|
477
640
|
def _request_with_meta(
|
|
478
641
|
self,
|
|
479
642
|
method: str,
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
BUTTON_BACKGROUND_COLORS: tuple[str, ...] = (
|
|
7
|
+
"transparent",
|
|
8
|
+
"#FB9337",
|
|
9
|
+
"#FA6F32",
|
|
10
|
+
"#FAB300",
|
|
11
|
+
"#67C200",
|
|
12
|
+
"#00BD77",
|
|
13
|
+
"#00C5FB",
|
|
14
|
+
"#268BFB",
|
|
15
|
+
"#001A72",
|
|
16
|
+
"#9E64FB",
|
|
17
|
+
"#D164FB",
|
|
18
|
+
"#FB4B51",
|
|
19
|
+
"#FFFFFF",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
BUTTON_TEXT_COLORS: tuple[str, ...] = (
|
|
23
|
+
"#FB9337",
|
|
24
|
+
"#FA6F32",
|
|
25
|
+
"#FAB300",
|
|
26
|
+
"#67C200",
|
|
27
|
+
"#00BD77",
|
|
28
|
+
"#00C5FB",
|
|
29
|
+
"#268BFB",
|
|
30
|
+
"#001A72",
|
|
31
|
+
"#9E64FB",
|
|
32
|
+
"#D164FB",
|
|
33
|
+
"#FB4B51",
|
|
34
|
+
"#494F57",
|
|
35
|
+
"#FFFFFF",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
BUTTON_ICONS: tuple[str, ...] = (
|
|
39
|
+
"ex-print",
|
|
40
|
+
"ex-attachment",
|
|
41
|
+
"ex-handoff",
|
|
42
|
+
"ex-delete",
|
|
43
|
+
"ex-copy",
|
|
44
|
+
"ex-share",
|
|
45
|
+
"ex-edit2",
|
|
46
|
+
"ex-admin-outlined",
|
|
47
|
+
"ex-stamp",
|
|
48
|
+
"ex-kanban",
|
|
49
|
+
"ex-update",
|
|
50
|
+
"ex-tickincircle-outlined",
|
|
51
|
+
"ex-crossincircle-outlined",
|
|
52
|
+
"ex-plus-circle",
|
|
53
|
+
"ex-logout",
|
|
54
|
+
"ex-check",
|
|
55
|
+
"ex-rename",
|
|
56
|
+
"ex-locked",
|
|
57
|
+
"ex-shift",
|
|
58
|
+
"ex-new-tabpage",
|
|
59
|
+
"ex-cross",
|
|
60
|
+
"ex-switch",
|
|
61
|
+
"ex-layer",
|
|
62
|
+
"ex-import",
|
|
63
|
+
"ex-pin-outlined",
|
|
64
|
+
"ex-disabled",
|
|
65
|
+
"ex-duplicate",
|
|
66
|
+
"ex-heart-outlined",
|
|
67
|
+
"ex-plus",
|
|
68
|
+
"ex-save",
|
|
69
|
+
"ex-upload",
|
|
70
|
+
"ex-upgrade1",
|
|
71
|
+
"ex-downgrade",
|
|
72
|
+
"ex-unfold",
|
|
73
|
+
"ex-fold",
|
|
74
|
+
"ex-sort",
|
|
75
|
+
"ex-menu-control",
|
|
76
|
+
"ex-download",
|
|
77
|
+
"ex-insert-below",
|
|
78
|
+
"ex-left-outlined-double",
|
|
79
|
+
"ex-right-outlined-double",
|
|
80
|
+
"ex-recovery",
|
|
81
|
+
"ex-layout",
|
|
82
|
+
"ex-search",
|
|
83
|
+
"ex-preview",
|
|
84
|
+
"ex-invisible",
|
|
85
|
+
"ex-carboncopy",
|
|
86
|
+
"ex-basicInfo",
|
|
87
|
+
"ex-fillIn",
|
|
88
|
+
"ex-refresh",
|
|
89
|
+
"ex-display",
|
|
90
|
+
"ex-message",
|
|
91
|
+
"ex-edit",
|
|
92
|
+
"ex-heart-filled",
|
|
93
|
+
"ex-pin-filled",
|
|
94
|
+
"ex-transfer",
|
|
95
|
+
"ex-cross-circle",
|
|
96
|
+
"ex-clock",
|
|
97
|
+
"ex-admin-filled",
|
|
98
|
+
"ex-tickincircle-circle",
|
|
99
|
+
"ex-all-application",
|
|
100
|
+
"ex-help-filled",
|
|
101
|
+
"ex-streamline",
|
|
102
|
+
"ex-table",
|
|
103
|
+
"ex-rowheight-short",
|
|
104
|
+
"ex-rowheight-medium",
|
|
105
|
+
"ex-rowheight-tall",
|
|
106
|
+
"ex-rowheight-tallest",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
BUTTON_STYLE_PRESETS: tuple[dict[str, Any], ...] = (
|
|
110
|
+
{
|
|
111
|
+
"key": "primary_blue",
|
|
112
|
+
"label": "Primary Blue",
|
|
113
|
+
"button_type": "default",
|
|
114
|
+
"background_color": "#268BFB",
|
|
115
|
+
"text_color": "#FFFFFF",
|
|
116
|
+
"recommended_icons": ["ex-plus-circle", "ex-plus"],
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"key": "text_blue",
|
|
120
|
+
"label": "Text Blue",
|
|
121
|
+
"button_type": "text",
|
|
122
|
+
"background_color": "transparent",
|
|
123
|
+
"text_color": "#268BFB",
|
|
124
|
+
"recommended_icons": ["ex-share", "ex-new-tabpage"],
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
"key": "warning_orange",
|
|
128
|
+
"label": "Warning Orange",
|
|
129
|
+
"button_type": "default",
|
|
130
|
+
"background_color": "#FB9337",
|
|
131
|
+
"text_color": "#FFFFFF",
|
|
132
|
+
"recommended_icons": ["ex-message", "ex-clock"],
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
"key": "danger_red",
|
|
136
|
+
"label": "Danger Red",
|
|
137
|
+
"button_type": "default",
|
|
138
|
+
"background_color": "#FB4B51",
|
|
139
|
+
"text_color": "#FFFFFF",
|
|
140
|
+
"recommended_icons": ["ex-delete", "ex-cross-circle"],
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
"key": "neutral_outline",
|
|
144
|
+
"label": "Neutral Outline",
|
|
145
|
+
"button_type": "default",
|
|
146
|
+
"background_color": "#FFFFFF",
|
|
147
|
+
"text_color": "#494F57",
|
|
148
|
+
"recommended_icons": ["ex-edit", "ex-search"],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"key": "secondary_gray",
|
|
152
|
+
"label": "Secondary Gray",
|
|
153
|
+
"button_type": "text",
|
|
154
|
+
"background_color": "transparent",
|
|
155
|
+
"text_color": "#494F57",
|
|
156
|
+
"recommended_icons": ["ex-edit", "ex-search"],
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
_PRESET_BY_KEY: dict[str, dict[str, Any]] = {
|
|
161
|
+
str(item["key"]).strip(): dict(item) for item in BUTTON_STYLE_PRESETS
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
_COLOR_FAMILY_BY_VALUE: dict[str, str] = {
|
|
165
|
+
"transparent": "neutral",
|
|
166
|
+
"#FB9337": "orange",
|
|
167
|
+
"#FA6F32": "orange",
|
|
168
|
+
"#FAB300": "yellow",
|
|
169
|
+
"#67C200": "green",
|
|
170
|
+
"#00BD77": "green",
|
|
171
|
+
"#00C5FB": "cyan",
|
|
172
|
+
"#268BFB": "blue",
|
|
173
|
+
"#001A72": "blue",
|
|
174
|
+
"#9E64FB": "purple",
|
|
175
|
+
"#D164FB": "purple",
|
|
176
|
+
"#FB4B51": "red",
|
|
177
|
+
"#FFFFFF": "white",
|
|
178
|
+
"#494F57": "gray",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _normalize_color_value(value: str | None) -> str | None:
|
|
183
|
+
normalized = str(value or "").strip()
|
|
184
|
+
if not normalized:
|
|
185
|
+
return None
|
|
186
|
+
lowered = normalized.lower().replace(" ", "")
|
|
187
|
+
if lowered == "transparent":
|
|
188
|
+
return "transparent"
|
|
189
|
+
if lowered in {
|
|
190
|
+
"#fff",
|
|
191
|
+
"#ffffff",
|
|
192
|
+
"white",
|
|
193
|
+
"rgb(255,255,255)",
|
|
194
|
+
"rgba(255,255,255,1)",
|
|
195
|
+
}:
|
|
196
|
+
return "#FFFFFF"
|
|
197
|
+
if normalized.startswith("#"):
|
|
198
|
+
return normalized.upper()
|
|
199
|
+
return normalized
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def button_style_catalog_payload() -> dict[str, Any]:
|
|
203
|
+
return {
|
|
204
|
+
"icons": [{"value": icon, "label": icon} for icon in BUTTON_ICONS],
|
|
205
|
+
"background_colors": [
|
|
206
|
+
{
|
|
207
|
+
"value": color,
|
|
208
|
+
"label": color,
|
|
209
|
+
"family": _COLOR_FAMILY_BY_VALUE.get(color, "custom"),
|
|
210
|
+
}
|
|
211
|
+
for color in BUTTON_BACKGROUND_COLORS
|
|
212
|
+
],
|
|
213
|
+
"text_colors": [
|
|
214
|
+
{
|
|
215
|
+
"value": color,
|
|
216
|
+
"label": color,
|
|
217
|
+
"family": _COLOR_FAMILY_BY_VALUE.get(color, "custom"),
|
|
218
|
+
}
|
|
219
|
+
for color in BUTTON_TEXT_COLORS
|
|
220
|
+
],
|
|
221
|
+
"presets": [deepcopy(item) for item in BUTTON_STYLE_PRESETS],
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def resolve_button_style(
|
|
226
|
+
*,
|
|
227
|
+
style_preset: str | None,
|
|
228
|
+
button_icon: str | None,
|
|
229
|
+
background_color: str | None,
|
|
230
|
+
text_color: str | None,
|
|
231
|
+
require_complete_style: bool,
|
|
232
|
+
) -> dict[str, str | None]:
|
|
233
|
+
preset_key = str(style_preset or "").strip() or None
|
|
234
|
+
resolved_icon = str(button_icon or "").strip() or None
|
|
235
|
+
resolved_background = _normalize_color_value(background_color)
|
|
236
|
+
resolved_text = _normalize_color_value(text_color)
|
|
237
|
+
|
|
238
|
+
if preset_key is not None:
|
|
239
|
+
preset = _PRESET_BY_KEY.get(preset_key)
|
|
240
|
+
if preset is None:
|
|
241
|
+
raise ValueError(
|
|
242
|
+
"unsupported style_preset; use button_style_catalog_get to inspect available presets"
|
|
243
|
+
)
|
|
244
|
+
if resolved_background is None:
|
|
245
|
+
resolved_background = str(preset["background_color"])
|
|
246
|
+
if resolved_text is None:
|
|
247
|
+
resolved_text = str(preset["text_color"])
|
|
248
|
+
if resolved_icon is None:
|
|
249
|
+
recommended = preset.get("recommended_icons") or []
|
|
250
|
+
if recommended:
|
|
251
|
+
resolved_icon = str(recommended[0]).strip() or None
|
|
252
|
+
|
|
253
|
+
if require_complete_style:
|
|
254
|
+
missing: list[str] = []
|
|
255
|
+
if resolved_icon is None:
|
|
256
|
+
missing.append("button_icon")
|
|
257
|
+
if resolved_background is None:
|
|
258
|
+
missing.append("background_color")
|
|
259
|
+
if resolved_text is None:
|
|
260
|
+
missing.append("text_color")
|
|
261
|
+
if missing:
|
|
262
|
+
raise ValueError(f"button style requires {', '.join(missing)}")
|
|
263
|
+
|
|
264
|
+
if resolved_icon is not None and resolved_icon not in BUTTON_ICONS:
|
|
265
|
+
raise ValueError(
|
|
266
|
+
"unsupported button_icon; use button_style_catalog_get to inspect available icons"
|
|
267
|
+
)
|
|
268
|
+
if resolved_background is not None and resolved_background not in BUTTON_BACKGROUND_COLORS:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
"unsupported background_color; current frontend only supports template colors from button_style_catalog_get"
|
|
271
|
+
)
|
|
272
|
+
if resolved_text is not None and resolved_text not in BUTTON_TEXT_COLORS:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
"unsupported text_color; current frontend only supports template colors from button_style_catalog_get"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
"style_preset": preset_key,
|
|
279
|
+
"button_icon": resolved_icon,
|
|
280
|
+
"background_color": resolved_background,
|
|
281
|
+
"text_color": resolved_text,
|
|
282
|
+
}
|
|
@@ -5,6 +5,7 @@ from typing import Any
|
|
|
5
5
|
|
|
6
6
|
from pydantic import AliasChoices, Field, model_validator
|
|
7
7
|
|
|
8
|
+
from .button_style_catalog import resolve_button_style
|
|
8
9
|
from ..solution.spec_models import StrictModel
|
|
9
10
|
|
|
10
11
|
|
|
@@ -1180,10 +1181,10 @@ def _is_white_button_color(value: str | None) -> bool:
|
|
|
1180
1181
|
|
|
1181
1182
|
class CustomButtonPatch(StrictModel):
|
|
1182
1183
|
button_text: str = Field(validation_alias=AliasChoices("button_text", "buttonText"))
|
|
1183
|
-
background_color: str = Field(validation_alias=AliasChoices("background_color", "backgroundColor"))
|
|
1184
|
-
text_color: str = Field(validation_alias=AliasChoices("text_color", "textColor"))
|
|
1185
|
-
button_icon: str = Field(validation_alias=AliasChoices("button_icon", "buttonIcon"))
|
|
1186
|
-
|
|
1184
|
+
background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
|
|
1185
|
+
text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
|
|
1186
|
+
button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
|
|
1187
|
+
style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
|
|
1187
1188
|
trigger_action: PublicButtonTriggerAction = Field(validation_alias=AliasChoices("trigger_action", "triggerAction"))
|
|
1188
1189
|
trigger_link_url: str | None = Field(default=None, validation_alias=AliasChoices("trigger_link_url", "triggerLinkUrl"))
|
|
1189
1190
|
trigger_add_data_config: CustomButtonAddDataConfigPatch | None = Field(
|
|
@@ -1204,8 +1205,29 @@ class CustomButtonPatch(StrictModel):
|
|
|
1204
1205
|
validation_alias=AliasChoices("trigger_wings_config", "triggerWingsConfig"),
|
|
1205
1206
|
)
|
|
1206
1207
|
|
|
1208
|
+
@model_validator(mode="before")
|
|
1209
|
+
@classmethod
|
|
1210
|
+
def normalize_aliases(cls, value: Any) -> Any:
|
|
1211
|
+
if not isinstance(value, dict):
|
|
1212
|
+
return value
|
|
1213
|
+
payload = dict(value)
|
|
1214
|
+
if "icon_color" in payload or "iconColor" in payload:
|
|
1215
|
+
raise ValueError("icon_color is not supported; icon color follows text_color")
|
|
1216
|
+
return payload
|
|
1217
|
+
|
|
1207
1218
|
@model_validator(mode="after")
|
|
1208
1219
|
def validate_shape(self) -> "CustomButtonPatch":
|
|
1220
|
+
resolved = resolve_button_style(
|
|
1221
|
+
style_preset=self.style_preset,
|
|
1222
|
+
button_icon=self.button_icon,
|
|
1223
|
+
background_color=self.background_color,
|
|
1224
|
+
text_color=self.text_color,
|
|
1225
|
+
require_complete_style=True,
|
|
1226
|
+
)
|
|
1227
|
+
self.style_preset = resolved["style_preset"]
|
|
1228
|
+
self.button_icon = resolved["button_icon"]
|
|
1229
|
+
self.background_color = resolved["background_color"]
|
|
1230
|
+
self.text_color = resolved["text_color"]
|
|
1209
1231
|
if self.trigger_action == PublicButtonTriggerAction.link and not str(self.trigger_link_url or "").strip():
|
|
1210
1232
|
raise ValueError("link buttons require trigger_link_url")
|
|
1211
1233
|
if self.trigger_action == PublicButtonTriggerAction.add_data and self.trigger_add_data_config is None:
|
|
@@ -1225,7 +1247,7 @@ class ViewButtonBindingPatch(StrictModel):
|
|
|
1225
1247
|
button_id: int = Field(validation_alias=AliasChoices("button_id", "buttonId", "id"))
|
|
1226
1248
|
button_text: str | None = Field(default=None, validation_alias=AliasChoices("button_text", "buttonText"))
|
|
1227
1249
|
button_icon: str | None = Field(default=None, validation_alias=AliasChoices("button_icon", "buttonIcon"))
|
|
1228
|
-
|
|
1250
|
+
style_preset: str | None = Field(default=None, validation_alias=AliasChoices("style_preset", "stylePreset"))
|
|
1229
1251
|
background_color: str | None = Field(default=None, validation_alias=AliasChoices("background_color", "backgroundColor"))
|
|
1230
1252
|
text_color: str | None = Field(default=None, validation_alias=AliasChoices("text_color", "textColor"))
|
|
1231
1253
|
trigger_action: str | None = Field(default=None, validation_alias=AliasChoices("trigger_action", "triggerAction"))
|
|
@@ -1244,6 +1266,8 @@ class ViewButtonBindingPatch(StrictModel):
|
|
|
1244
1266
|
if not isinstance(value, dict):
|
|
1245
1267
|
return value
|
|
1246
1268
|
payload = dict(value)
|
|
1269
|
+
if "icon_color" in payload or "iconColor" in payload:
|
|
1270
|
+
raise ValueError("icon_color is not supported; icon color follows text_color")
|
|
1247
1271
|
raw_button_type = payload.get("button_type", payload.get("buttonType"))
|
|
1248
1272
|
if isinstance(raw_button_type, str):
|
|
1249
1273
|
normalized_type = raw_button_type.strip().lower()
|
|
@@ -1265,6 +1289,21 @@ class ViewButtonBindingPatch(StrictModel):
|
|
|
1265
1289
|
|
|
1266
1290
|
@model_validator(mode="after")
|
|
1267
1291
|
def validate_shape(self) -> "ViewButtonBindingPatch":
|
|
1292
|
+
require_complete_style = self.button_type == PublicViewButtonType.system or any(
|
|
1293
|
+
str(value or "").strip()
|
|
1294
|
+
for value in (self.style_preset, self.button_icon, self.background_color, self.text_color)
|
|
1295
|
+
)
|
|
1296
|
+
resolved = resolve_button_style(
|
|
1297
|
+
style_preset=self.style_preset,
|
|
1298
|
+
button_icon=self.button_icon,
|
|
1299
|
+
background_color=self.background_color,
|
|
1300
|
+
text_color=self.text_color,
|
|
1301
|
+
require_complete_style=require_complete_style,
|
|
1302
|
+
)
|
|
1303
|
+
self.style_preset = resolved["style_preset"]
|
|
1304
|
+
self.button_icon = resolved["button_icon"]
|
|
1305
|
+
self.background_color = resolved["background_color"]
|
|
1306
|
+
self.text_color = resolved["text_color"]
|
|
1268
1307
|
if self.button_type == PublicViewButtonType.system:
|
|
1269
1308
|
missing = [
|
|
1270
1309
|
field_name
|