Agentic-GenUI-Tools 0.2.0__py3-none-any.whl
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.
- adaptive_card_tools/__init__.py +10 -0
- adaptive_card_tools/builder.py +1891 -0
- adaptive_card_tools/catalog.py +488 -0
- adaptive_card_tools/examples.py +169 -0
- adaptive_card_tools/normalize.py +581 -0
- adaptive_card_tools/py.typed +0 -0
- adaptive_card_tools/theme.py +210 -0
- adaptive_card_tools/tool.py +142 -0
- agentic_genui_tools-0.2.0.dist-info/METADATA +284 -0
- agentic_genui_tools-0.2.0.dist-info/RECORD +13 -0
- agentic_genui_tools-0.2.0.dist-info/WHEEL +5 -0
- agentic_genui_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
- agentic_genui_tools-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1891 @@
|
|
|
1
|
+
"""Forgiving element descriptions -> deterministic, styled Adaptive Cards 1.5.
|
|
2
|
+
|
|
3
|
+
Pipeline: load (lenient JSON) -> document (unwrap, root fields) -> canonical
|
|
4
|
+
nodes (type resolution/inference, field aliases) -> merge adjacent singles ->
|
|
5
|
+
render with the theme -> width estimate. Every repair is recorded in metadata.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from . import theme
|
|
15
|
+
from .catalog import (CONTENT_KEYS, FIELD_ALIASES, SPECS, STYLE_KEYS, TYPE_KEYS, infer_type, map_fields, resolve_type)
|
|
16
|
+
from .normalize import (WRAPPER_KEYS, BuilderConfig, CardError, Context, as_bool, as_date, as_id, as_number,
|
|
17
|
+
as_time, as_url, clean_text, closest, display_text, format_number, is_slug_like,
|
|
18
|
+
load_request, parse_json_text, parse_number, slug, submit_text, token)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Node:
|
|
23
|
+
type: str
|
|
24
|
+
f: dict
|
|
25
|
+
path: str
|
|
26
|
+
hints: dict = field(default_factory=dict)
|
|
27
|
+
extras: list = field(default_factory=list)
|
|
28
|
+
unknown: list = field(default_factory=list)
|
|
29
|
+
source: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _keys(*names: str) -> set[str]:
|
|
33
|
+
return {token(n) for group in names for n in group.split()}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pick(obj: dict, keys: set[str]):
|
|
37
|
+
"""First (key, value) whose key token is in keys."""
|
|
38
|
+
for k, v in obj.items():
|
|
39
|
+
if token(k) in keys and v is not None:
|
|
40
|
+
return k, v
|
|
41
|
+
return None, None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def humanize(key: str) -> str:
|
|
45
|
+
if " " in key.strip() or not re.search(r"[_\-]|[a-z][A-Z]", key):
|
|
46
|
+
return key[:1].upper() + key[1:] if key[:1].islower() and " " not in key else key
|
|
47
|
+
words = re.sub(r"([a-z])([A-Z])", r"\1 \2", key).replace("_", " ").replace("-", " ").split()
|
|
48
|
+
return " ".join(words)[:1].upper() + " ".join(words)[1:].lower() if words else key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
ROOT_FIELDS = {
|
|
52
|
+
"title": _keys("title heading header card_title cardtitle headline"),
|
|
53
|
+
"subtitle": _keys("subtitle subheading subheader tagline byline"),
|
|
54
|
+
"status": _keys("status badge state"),
|
|
55
|
+
"elements": _keys("elements body items components questions fields children blocks sections widgets cards contents"),
|
|
56
|
+
"submit_label": _keys("submit_label submit_text submit_title submit_button button_text button_label submit"),
|
|
57
|
+
"buttons": _keys("buttons actions links ctas"),
|
|
58
|
+
}
|
|
59
|
+
ROOT_ALIAS = {t: name for name, tokens in ROOT_FIELDS.items() for t in tokens}
|
|
60
|
+
LABEL_KEYS = _keys("title label text name display display_name caption option choice heading key fact property field attribute term metric kpi stat")
|
|
61
|
+
VALUE_KEYS = _keys("value val id code data answer detail details content definition amount number figure count total score current current_value result y size")
|
|
62
|
+
POINT_LABEL_KEYS = _keys("label name x category key title legend month date period year day week quarter group item time region product")
|
|
63
|
+
SERIES_NAME_KEYS = _keys("name label legend title series key")
|
|
64
|
+
SERIES_VALUE_KEYS = _keys("values data points y items")
|
|
65
|
+
METRIC_MAP = {t: name for name, words in {
|
|
66
|
+
"label": "label title name metric kpi stat key caption heading",
|
|
67
|
+
"value": "value amount number figure count total score current current_value val result",
|
|
68
|
+
"change": "change delta diff difference trend growth variance pct_change percent_change movement change_percent",
|
|
69
|
+
"note": "note period context subtext secondary comparison target description subtitle footnote",
|
|
70
|
+
"lower_is_better": "lower_is_better good_direction inverse invert", "icon": "icon emoji"}.items() for t in _keys(words)}
|
|
71
|
+
BUTTON_MAP = {t: name for name, words in {
|
|
72
|
+
"label": "label title text name caption", "url": "url href link uri target open_url openurl website destination",
|
|
73
|
+
"data": "data payload submit_data params parameters body value", "action": "action type kind on_click onclick click behavior action_type",
|
|
74
|
+
"style": "style tone variant color appearance intent importance priority", "icon": "icon emoji iconurl"}.items() for t in _keys(words)}
|
|
75
|
+
TONE_WORDS = {
|
|
76
|
+
"danger": "danger error red critical bad negative failed fail failure rejected blocked overdue urgent offline down outage cancelled canceled lost expired declined denied breach destructive alert attention offtrack",
|
|
77
|
+
"warning": "warning warn yellow orange amber caution pending review inreview awaiting waiting atrisk risk degraded delayed partial expiring due onhold hold behind",
|
|
78
|
+
"success": "success successful good green positive ok okay done complete completed approved active passed pass paid resolved shipped delivered live healthy online available enabled won confirmed accepted ontrack ahead",
|
|
79
|
+
"info": "info information informative blue accent primary new inprogress progress open running scheduled planned processing started ongoing",
|
|
80
|
+
"neutral": "neutral default gray grey secondary muted subtle closed archived draft inactive paused none unknown notstarted",
|
|
81
|
+
}
|
|
82
|
+
TONE_LOOKUP = {w: tone for tone, words in TONE_WORDS.items() for w in words.split()}
|
|
83
|
+
TONE_NAMES = _keys("success successful good warning warn danger error info information informative neutral default red green "
|
|
84
|
+
"yellow orange amber blue gray grey accent attention positive negative critical primary secondary muted subtle")
|
|
85
|
+
SUBMIT_WORDS = _keys("submit actionsubmit execute actionexecute postback imback messageback send save")
|
|
86
|
+
LINK_WORDS = _keys("openurl actionopenurl link url navigate open href redirect website")
|
|
87
|
+
NUMERIC_CELL = re.compile(r"[(~+\-−]?\s*[$€£¥₹]?\s*\d[\d.,' ]*\s*(%|[kKmMbB]n?|[A-Za-z]{1,3})?\)?")
|
|
88
|
+
MIME = {".mp4": "video/mp4", ".m4v": "video/mp4", ".mov": "video/quicktime", ".webm": "video/webm",
|
|
89
|
+
".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".aac": "audio/aac"}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def tone_of(value, fallback_text: str = "") -> str:
|
|
93
|
+
for candidate in (value, fallback_text):
|
|
94
|
+
if isinstance(candidate, str) and candidate.strip():
|
|
95
|
+
t = token(candidate)
|
|
96
|
+
if t in TONE_LOOKUP:
|
|
97
|
+
return TONE_LOOKUP[t]
|
|
98
|
+
words = [token(w) for w in re.findall(r"[A-Za-z]+", candidate)]
|
|
99
|
+
for tone in ("danger", "warning", "success", "info"):
|
|
100
|
+
if any(TONE_LOOKUP.get(w) == tone for w in words):
|
|
101
|
+
return tone
|
|
102
|
+
return "neutral"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_input(card_element: dict) -> bool:
|
|
106
|
+
return str(card_element.get("type", "")).startswith("Input.")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class CardBuilder:
|
|
110
|
+
def __init__(self, config: BuilderConfig):
|
|
111
|
+
self.config = config
|
|
112
|
+
self.ctx = Context(config)
|
|
113
|
+
self.input_ids: list[str] = []
|
|
114
|
+
self.used_ids: set[str] = set()
|
|
115
|
+
self.reserved_ids: set[str] = set()
|
|
116
|
+
self.choice_values: dict[str, list[dict]] = {}
|
|
117
|
+
self.element_count = 0
|
|
118
|
+
self.column_depth = 0
|
|
119
|
+
self.min_width, self.floor_percent, self.force_overflow = 320, 50, False
|
|
120
|
+
self.width_reasons: list[str] = []
|
|
121
|
+
|
|
122
|
+
# ------------------------------------------------------------ helpers
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def native(self) -> bool:
|
|
126
|
+
return self.config.profile == "microsoft"
|
|
127
|
+
|
|
128
|
+
def native_ok(self, name: str) -> bool:
|
|
129
|
+
return self.native and (self.config.native_types is None or name in self.config.native_types)
|
|
130
|
+
|
|
131
|
+
def extension(self, native: dict, fallback: dict, path: str) -> dict:
|
|
132
|
+
if self.native_ok(native["type"]):
|
|
133
|
+
return {**native, "fallback": fallback}
|
|
134
|
+
return fallback
|
|
135
|
+
|
|
136
|
+
def need(self, pixels: int, percent: int, reason: str, *, overflow=False):
|
|
137
|
+
self.min_width = max(self.min_width, pixels)
|
|
138
|
+
self.floor_percent = max(self.floor_percent, percent)
|
|
139
|
+
self.force_overflow |= overflow
|
|
140
|
+
if reason not in self.width_reasons:
|
|
141
|
+
self.width_reasons.append(reason)
|
|
142
|
+
|
|
143
|
+
def title_prefix(self, node: Node) -> list[dict]:
|
|
144
|
+
title = clean_text(node.f.get("title"))
|
|
145
|
+
return [theme.group_title(self.with_icon(title, node))] if title else []
|
|
146
|
+
|
|
147
|
+
def with_icon(self, text: str, node: Node) -> str:
|
|
148
|
+
icon = node.hints.get("icon")
|
|
149
|
+
if isinstance(icon, str) and 0 < len(icon.strip()) <= 4 and not icon.strip().isascii():
|
|
150
|
+
return f"{icon.strip()} {text}"
|
|
151
|
+
return text
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------ document
|
|
154
|
+
|
|
155
|
+
def unwrap(self, raw, path="$"):
|
|
156
|
+
for _ in range(5):
|
|
157
|
+
if isinstance(raw, str) and raw.strip()[:1] in "[{":
|
|
158
|
+
raw = parse_json_text(raw, self.ctx, path)
|
|
159
|
+
continue
|
|
160
|
+
if isinstance(raw, dict):
|
|
161
|
+
keys = {token(k): k for k in raw}
|
|
162
|
+
carrier = next((keys[t] for t in ("arguments", "args", "parameters", "params", "input") if t in keys), None)
|
|
163
|
+
if carrier and set(keys) - {token(carrier)} <= {"name", "tool", "function", "id", "toolname", "type"}:
|
|
164
|
+
raw = raw[carrier]
|
|
165
|
+
self.ctx.fixed(path, "unwrapped_tool_call", key=carrier)
|
|
166
|
+
continue
|
|
167
|
+
if len(raw) == 1:
|
|
168
|
+
(k, v), = raw.items()
|
|
169
|
+
if token(k) in WRAPPER_KEYS and (isinstance(v, (dict, list)) or isinstance(v, str) and v.strip()[:1] in "[{"):
|
|
170
|
+
raw = v
|
|
171
|
+
self.ctx.fixed(path, "unwrapped", key=k)
|
|
172
|
+
continue
|
|
173
|
+
break
|
|
174
|
+
return raw
|
|
175
|
+
|
|
176
|
+
def document(self, raw) -> dict:
|
|
177
|
+
raw = self.unwrap(raw)
|
|
178
|
+
if isinstance(raw, list):
|
|
179
|
+
return {"elements": raw}
|
|
180
|
+
if not isinstance(raw, dict):
|
|
181
|
+
return {"elements": [raw]}
|
|
182
|
+
type_key = next((k for k in raw if token(k) in TYPE_KEYS and isinstance(raw[k], str)), None)
|
|
183
|
+
if type_key is not None:
|
|
184
|
+
resolved = resolve_type(raw[type_key], Context(self.config), "$." + type_key)
|
|
185
|
+
if resolved and resolved[0] in {"document", "section"}:
|
|
186
|
+
self.ctx.fixed("$." + type_key, "type_alias", source=raw[type_key], target="document")
|
|
187
|
+
return self.root_fields({k: v for k, v in raw.items() if k != type_key})
|
|
188
|
+
if resolved or not any(token(k) in ROOT_FIELDS["elements"] for k in raw):
|
|
189
|
+
return self.single_element(raw, resolved[0] if resolved else None)
|
|
190
|
+
elements_key = next((k for k, v in raw.items() if token(k) in ROOT_FIELDS["elements"] and isinstance(v, (list, dict)) and v), None)
|
|
191
|
+
if elements_key and not (token(elements_key) == "items" and isinstance(raw[elements_key], list)
|
|
192
|
+
and all(isinstance(i, (str, int, float)) for i in raw[elements_key])):
|
|
193
|
+
return self.root_fields(raw)
|
|
194
|
+
inferred = infer_type(raw)
|
|
195
|
+
if inferred and inferred[0] not in {"text", "heading", "section"}:
|
|
196
|
+
# Only a single element if every key belongs to it (or to the card header).
|
|
197
|
+
own = FIELD_ALIASES[inferred[0]]
|
|
198
|
+
if all(token(k) in own or token(k) in ROOT_ALIAS and ROOT_ALIAS[token(k)] != "elements" or token(k) in STYLE_KEYS for k in raw):
|
|
199
|
+
return self.single_element(raw, inferred[0])
|
|
200
|
+
return self.root_fields(raw)
|
|
201
|
+
|
|
202
|
+
def single_element(self, raw: dict, canonical: str | None) -> dict:
|
|
203
|
+
"""A lone element sent as the whole request; card-level keys move to the card."""
|
|
204
|
+
doc = {"elements": []}
|
|
205
|
+
element = {}
|
|
206
|
+
spec = SPECS.get(canonical) if canonical else None
|
|
207
|
+
label_like = sum(1 for k in raw if token(k) in _keys("label question prompt text"))
|
|
208
|
+
for k, v in raw.items():
|
|
209
|
+
field = ROOT_ALIAS.get(token(k))
|
|
210
|
+
if field in {"subtitle", "submit_label"} or (field == "status" and canonical != "badge"):
|
|
211
|
+
doc[field] = v
|
|
212
|
+
elif token(k) == "title" and spec and "title" not in doc and (
|
|
213
|
+
spec.kind == "display" and canonical not in {"text", "heading", "note", "badge"}
|
|
214
|
+
or spec.kind == "input" and label_like):
|
|
215
|
+
doc["title"] = v
|
|
216
|
+
else:
|
|
217
|
+
element[k] = v
|
|
218
|
+
doc["elements"].append(element)
|
|
219
|
+
return doc
|
|
220
|
+
|
|
221
|
+
def root_fields(self, raw: dict) -> dict:
|
|
222
|
+
doc = {"elements": []}
|
|
223
|
+
pending_facts: dict = {}
|
|
224
|
+
|
|
225
|
+
def flush():
|
|
226
|
+
if pending_facts:
|
|
227
|
+
doc["elements"].append({"type": "facts", "facts": dict(pending_facts)})
|
|
228
|
+
pending_facts.clear()
|
|
229
|
+
|
|
230
|
+
for key, value in raw.items():
|
|
231
|
+
t = token(key)
|
|
232
|
+
field = ROOT_ALIAS.get(t)
|
|
233
|
+
if field is None and t not in STYLE_KEYS and t not in {"schema", "version", "type"}:
|
|
234
|
+
near = closest(t, ROOT_ALIAS, self.ctx)
|
|
235
|
+
field = ROOT_ALIAS.get(near) if near and len(t) >= 6 else None
|
|
236
|
+
if field == "buttons" and value == []:
|
|
237
|
+
doc.setdefault("buttons", [])
|
|
238
|
+
continue
|
|
239
|
+
if field is not None and field != "elements" and value in (None, "", [], {}):
|
|
240
|
+
continue
|
|
241
|
+
if field == "elements":
|
|
242
|
+
flush()
|
|
243
|
+
if isinstance(value, dict):
|
|
244
|
+
value = list(value.values()) if all(isinstance(v, dict) for v in value.values()) else [value]
|
|
245
|
+
doc["elements"].extend(value if isinstance(value, list) else [value])
|
|
246
|
+
elif field == "buttons" or (field == "submit_label" and isinstance(value, (dict, list))):
|
|
247
|
+
doc.setdefault("buttons", []).extend(value if isinstance(value, list) else [value])
|
|
248
|
+
elif field in {"title", "subtitle", "status", "submit_label"}:
|
|
249
|
+
if field in doc:
|
|
250
|
+
doc["elements"].append({"type": "text", "text": display_text(value)})
|
|
251
|
+
else:
|
|
252
|
+
doc[field] = value
|
|
253
|
+
elif t in STYLE_KEYS or t in {"schema", "version", "type", "id"}:
|
|
254
|
+
self.ctx.fixed("$." + key, "ignored_style_key")
|
|
255
|
+
else:
|
|
256
|
+
# A root key named after an element: {"chart": {...}}, {"footer": "..."}, {"logo": "https://..."}.
|
|
257
|
+
resolved = resolve_type(key, Context(self.config), "$." + key)
|
|
258
|
+
scalar = isinstance(value, (str, int, float, bool))
|
|
259
|
+
if resolved and resolved[0] not in {"document", "column"} and not (scalar and SPECS[resolved[0]].kind == "input"):
|
|
260
|
+
flush()
|
|
261
|
+
canonical = resolved[0]
|
|
262
|
+
flat = isinstance(value, dict) and all(not isinstance(v, (dict, list)) for v in value.values())
|
|
263
|
+
if isinstance(value, dict) and any(token(k) in TYPE_KEYS for k in value):
|
|
264
|
+
element = value
|
|
265
|
+
elif isinstance(value, dict) and not (flat and canonical in {"facts", "chart", "metrics", "table"}):
|
|
266
|
+
element = {"type": key, **value}
|
|
267
|
+
else:
|
|
268
|
+
element = {"type": key, SPECS[canonical].main or "text": value}
|
|
269
|
+
doc["elements"].append(element)
|
|
270
|
+
self.ctx.fixed("$." + key, "root_key_as_element", target=canonical)
|
|
271
|
+
elif scalar or (isinstance(value, list) and all(isinstance(v, (str, int, float)) for v in value)):
|
|
272
|
+
pending_facts[humanize(key)] = value
|
|
273
|
+
self.ctx.fixed("$." + key, "root_value_as_fact")
|
|
274
|
+
elif isinstance(value, (dict, list)) and value:
|
|
275
|
+
flush()
|
|
276
|
+
doc["elements"].append(value if isinstance(value, dict) else {"type": "section", "elements": value})
|
|
277
|
+
self.ctx.fixed("$." + key, "root_key_as_element")
|
|
278
|
+
flush()
|
|
279
|
+
return doc
|
|
280
|
+
|
|
281
|
+
# ------------------------------------------------------------ canonical nodes
|
|
282
|
+
|
|
283
|
+
def canonicalize(self, raw, path: str) -> list[Node]:
|
|
284
|
+
if raw is None or raw == "" or raw == {} or raw == []:
|
|
285
|
+
return []
|
|
286
|
+
if isinstance(raw, (str, int, float, bool)):
|
|
287
|
+
return self.markdown_nodes(display_text(raw), path)
|
|
288
|
+
if isinstance(raw, list):
|
|
289
|
+
return [Node("section", {"elements": raw}, path)]
|
|
290
|
+
type_keys = [k for k in raw if token(k) in TYPE_KEYS and isinstance(raw[k], str)]
|
|
291
|
+
type_keys.sort(key=lambda k: token(k) != "type")
|
|
292
|
+
resolved, source = None, ""
|
|
293
|
+
for k in type_keys:
|
|
294
|
+
resolved = resolve_type(raw[k], self.ctx, f"{path}.{k}")
|
|
295
|
+
source = token(raw[k])
|
|
296
|
+
if resolved:
|
|
297
|
+
break
|
|
298
|
+
if not resolved:
|
|
299
|
+
resolved = infer_type({k: v for k, v in raw.items() if k not in type_keys})
|
|
300
|
+
if resolved and type_keys:
|
|
301
|
+
self.ctx.warn(path, "unknown_type", f"Unknown type '{raw[type_keys[0]]}'; shown as {resolved[0]} based on its fields.")
|
|
302
|
+
elif resolved:
|
|
303
|
+
self.ctx.fixed(path, "type_inferred", target=resolved[0])
|
|
304
|
+
if not resolved:
|
|
305
|
+
texts = [clean_text(v) for k, v in raw.items()
|
|
306
|
+
if k not in type_keys and (token(k) in CONTENT_KEYS or token(k) in {"title", "label", "name", "value"})]
|
|
307
|
+
texts = [t for t in texts if t]
|
|
308
|
+
if texts:
|
|
309
|
+
self.ctx.warn(path, "unknown_element", "Could not tell what this element is; its text is shown as a paragraph.")
|
|
310
|
+
return [Node("text", {"text": "\n\n".join(texts)}, path)]
|
|
311
|
+
self.ctx.warn(path, "dropped_element", "Could not tell what this element is, so it was left out.")
|
|
312
|
+
return []
|
|
313
|
+
canonical, implied = resolved
|
|
314
|
+
canonical, implied = self.refine(canonical, dict(implied), raw, source)
|
|
315
|
+
if canonical == "document":
|
|
316
|
+
inner = self.root_fields({k: v for k, v in raw.items() if k not in type_keys})
|
|
317
|
+
title = inner.get("title")
|
|
318
|
+
return [Node("section", {"title": title, "elements": inner["elements"] + ([{"type": "buttons", "buttons": inner["buttons"]}] if inner.get("buttons") else [])}, path)]
|
|
319
|
+
fields = map_fields(canonical, raw, path, self.ctx, skip=type_keys)
|
|
320
|
+
values = {**implied, **fields.values}
|
|
321
|
+
return [Node(canonical, values, path, fields.hints, fields.extras, fields.unknown, source)]
|
|
322
|
+
|
|
323
|
+
def refine(self, canonical: str, implied: dict, raw: dict, source: str) -> tuple[str, dict]:
|
|
324
|
+
"""Correct a type using the fields that are actually present."""
|
|
325
|
+
keys = {token(k) for k in raw}
|
|
326
|
+
kind = SPECS[canonical].kind
|
|
327
|
+
if kind == "input" and canonical != "choice" and keys & {"options", "choices"}:
|
|
328
|
+
return "choice", ({"multiple": True} if canonical == "checkbox" else {})
|
|
329
|
+
if canonical in {"text", "note", "heading"}:
|
|
330
|
+
has_text = keys & {"text", "content", "body", "value", "message", "paragraph", "markdown"}
|
|
331
|
+
if not has_text and keys & {"placeholder", "required", "isrequired", "maxlength", "multiline", "ismultiline"}:
|
|
332
|
+
return "text_input", {}
|
|
333
|
+
if not has_text and "items" in keys:
|
|
334
|
+
return "list", implied
|
|
335
|
+
if canonical == "chart" and not keys & {"data", "series", "datasets", "values", "value", "points"} and keys & {"rows", "records"}:
|
|
336
|
+
return "table", {}
|
|
337
|
+
if canonical in {"columns", "list", "facts"} and "rows" in keys and canonical != "facts":
|
|
338
|
+
return "table", {}
|
|
339
|
+
if canonical == "table" and "columns" in keys and not keys & {"rows", "data", "records", "items", "entries"}:
|
|
340
|
+
return "columns", {}
|
|
341
|
+
if canonical == "image" and keys & {"images", "urls", "gallery"}:
|
|
342
|
+
return "images", {}
|
|
343
|
+
if canonical == "section" and "columns" in keys and not keys & {"elements", "items", "body", "children"}:
|
|
344
|
+
return "columns", {}
|
|
345
|
+
if canonical == "chart" and source == "line" and not keys & {"data", "series", "datasets", "values", "points", "value"}:
|
|
346
|
+
return "divider", {}
|
|
347
|
+
return canonical, implied
|
|
348
|
+
|
|
349
|
+
def markdown_nodes(self, text: str, path: str) -> list[Node]:
|
|
350
|
+
"""Split markdown-ish text into headings, lists, tables and paragraphs."""
|
|
351
|
+
if "\n" not in text and not re.match(r"\s*#{1,6}\s", text):
|
|
352
|
+
return [Node("text", {"text": text}, path)]
|
|
353
|
+
nodes, para, bullets, numbered, table_lines = [], [], [], False, []
|
|
354
|
+
|
|
355
|
+
def flush_para():
|
|
356
|
+
if para:
|
|
357
|
+
joined = "\n".join(para).strip()
|
|
358
|
+
if joined:
|
|
359
|
+
nodes.append(Node("text", {"text": joined}, path))
|
|
360
|
+
para.clear()
|
|
361
|
+
|
|
362
|
+
def flush_list():
|
|
363
|
+
nonlocal numbered
|
|
364
|
+
if bullets:
|
|
365
|
+
nodes.append(Node("list", {"items": list(bullets), "numbered": numbered}, path))
|
|
366
|
+
bullets.clear()
|
|
367
|
+
numbered = False
|
|
368
|
+
|
|
369
|
+
def flush_table():
|
|
370
|
+
if table_lines:
|
|
371
|
+
rows = [[c.strip() for c in line.strip().strip("|").split("|")] for line in table_lines
|
|
372
|
+
if not re.fullmatch(r"\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*", line)]
|
|
373
|
+
if len(rows) >= 2:
|
|
374
|
+
nodes.append(Node("table", {"columns": rows[0], "rows": rows[1:]}, path))
|
|
375
|
+
else:
|
|
376
|
+
para.extend(table_lines)
|
|
377
|
+
table_lines.clear()
|
|
378
|
+
|
|
379
|
+
for line in text.splitlines():
|
|
380
|
+
heading = re.match(r"\s*#{1,6}\s+(.*)", line)
|
|
381
|
+
bullet = re.match(r"\s*[-*•]\s+(.*)", line)
|
|
382
|
+
number = re.match(r"\s*\d{1,3}[.)]\s+(.*)", line)
|
|
383
|
+
if line.strip().startswith("|") and line.count("|") >= 2:
|
|
384
|
+
flush_para(), flush_list()
|
|
385
|
+
table_lines.append(line)
|
|
386
|
+
continue
|
|
387
|
+
flush_table()
|
|
388
|
+
if heading:
|
|
389
|
+
flush_para(), flush_list()
|
|
390
|
+
nodes.append(Node("heading", {"text": heading.group(1).strip().strip("#").strip()}, path))
|
|
391
|
+
elif bullet or number:
|
|
392
|
+
flush_para()
|
|
393
|
+
if bullets and bool(number) != numbered:
|
|
394
|
+
flush_list()
|
|
395
|
+
numbered = bool(number)
|
|
396
|
+
bullets.append((bullet or number).group(1))
|
|
397
|
+
elif not line.strip():
|
|
398
|
+
flush_para(), flush_list()
|
|
399
|
+
else:
|
|
400
|
+
flush_list()
|
|
401
|
+
para.append(line)
|
|
402
|
+
flush_table(), flush_para(), flush_list()
|
|
403
|
+
if len(nodes) > 1 or (nodes and nodes[0].type != "text"):
|
|
404
|
+
self.ctx.fixed(path, "markdown_structure")
|
|
405
|
+
return nodes
|
|
406
|
+
|
|
407
|
+
def merge(self, nodes: list[Node]) -> list[Node]:
|
|
408
|
+
"""Combine adjacent single metrics, buttons, badges and columns into one row."""
|
|
409
|
+
out: list[Node] = []
|
|
410
|
+
for node in nodes:
|
|
411
|
+
prev = out[-1] if out else None
|
|
412
|
+
if node.type == "metrics" and "items" not in node.f:
|
|
413
|
+
single = dict(node.f)
|
|
414
|
+
if "label" not in single and "title" in single:
|
|
415
|
+
single["label"] = single.pop("title")
|
|
416
|
+
single.pop("title", None)
|
|
417
|
+
if "icon" in node.hints:
|
|
418
|
+
single["icon"] = node.hints["icon"]
|
|
419
|
+
node.f = {"items": [single], "_single": True}
|
|
420
|
+
if node.type == "column":
|
|
421
|
+
node = Node("columns", {"columns": [{"title": node.f.get("title"), "elements": node.f.get("elements", [])}], "_single": True}, node.path)
|
|
422
|
+
if prev and prev.type == node.type and prev.f.get("_single") and node.f.get("_single") and "title" not in node.f:
|
|
423
|
+
if node.type == "metrics":
|
|
424
|
+
prev.f["items"].extend(node.f["items"])
|
|
425
|
+
continue
|
|
426
|
+
if node.type == "columns":
|
|
427
|
+
prev.f["columns"].extend(node.f["columns"])
|
|
428
|
+
continue
|
|
429
|
+
if prev and node.type == "buttons" and prev.type == "buttons" and not node.extras and not prev.extras:
|
|
430
|
+
prev.f.setdefault("_merged", []).append(node)
|
|
431
|
+
continue
|
|
432
|
+
if prev and node.type == "badge" and prev.type == "badge" and not node.extras and not prev.extras:
|
|
433
|
+
prev.f.setdefault("_merged", []).append(node)
|
|
434
|
+
continue
|
|
435
|
+
out.append(node)
|
|
436
|
+
for node in out:
|
|
437
|
+
if node.type == "columns" and node.f.get("_single") and len(node.f["columns"]) == 1:
|
|
438
|
+
node.type, node.f = "section", {"title": node.f["columns"][0].get("title"), "elements": node.f["columns"][0].get("elements", [])}
|
|
439
|
+
return out
|
|
440
|
+
|
|
441
|
+
# ------------------------------------------------------------ rendering
|
|
442
|
+
|
|
443
|
+
def render_list(self, raw_items, path: str, depth: int) -> list[dict]:
|
|
444
|
+
if depth > self.config.max_depth:
|
|
445
|
+
raise CardError(path, "Nesting limit exceeded.", "limit_exceeded")
|
|
446
|
+
if isinstance(raw_items, dict):
|
|
447
|
+
raw_items = [raw_items]
|
|
448
|
+
elif not isinstance(raw_items, list):
|
|
449
|
+
raw_items = [raw_items]
|
|
450
|
+
nodes = []
|
|
451
|
+
for i, item in enumerate(raw_items):
|
|
452
|
+
nodes.extend(self.canonicalize(item, f"{path}[{i}]"))
|
|
453
|
+
out: list[dict] = []
|
|
454
|
+
divider = False
|
|
455
|
+
for node in self.merge(nodes):
|
|
456
|
+
if node.type == "divider":
|
|
457
|
+
divider = bool(out)
|
|
458
|
+
continue
|
|
459
|
+
rendered = self.render(node, depth)
|
|
460
|
+
if rendered and divider:
|
|
461
|
+
rendered[0] = {**rendered[0], "separator": True, "spacing": "Large"}
|
|
462
|
+
divider = divider and not rendered
|
|
463
|
+
out.extend(rendered)
|
|
464
|
+
return out
|
|
465
|
+
|
|
466
|
+
def render(self, node: Node, depth: int) -> list[dict]:
|
|
467
|
+
self.element_count += 1
|
|
468
|
+
if self.element_count > self.config.max_elements:
|
|
469
|
+
raise CardError(node.path, f"More than {self.config.max_elements} elements; split the content into several cards.", "limit_exceeded")
|
|
470
|
+
out = getattr(self, "r_" + node.type)(node, depth)
|
|
471
|
+
if out:
|
|
472
|
+
out.extend(self.extra_blocks(node))
|
|
473
|
+
for key, _ in node.unknown:
|
|
474
|
+
self.ctx.warn(f"{node.path}.{key}", "ignored_field", f"Field '{key}' is not used by {node.type} and was left out.")
|
|
475
|
+
return out
|
|
476
|
+
|
|
477
|
+
def extra_blocks(self, node: Node) -> list[dict]:
|
|
478
|
+
blocks = []
|
|
479
|
+
for key, value in node.extras:
|
|
480
|
+
content = clean_text(value)
|
|
481
|
+
if not content:
|
|
482
|
+
continue
|
|
483
|
+
long_form = token(key) in _keys("description desc body content message text details detail summary explanation context instructions")
|
|
484
|
+
blocks.append(theme.body(content) if long_form or len(content) > 160 else theme.note(content))
|
|
485
|
+
self.ctx.fixed(f"{node.path}.{key}", "shown_as_text")
|
|
486
|
+
if blocks and node.type in {"heading"}:
|
|
487
|
+
blocks[0] = {**blocks[0], "spacing": "None"}
|
|
488
|
+
return blocks
|
|
489
|
+
|
|
490
|
+
def text_of(self, node: Node, *fallbacks: str) -> str:
|
|
491
|
+
for name in ("text", *fallbacks):
|
|
492
|
+
value = node.f.get(name)
|
|
493
|
+
if isinstance(value, list) and value and all(isinstance(v, dict) for v in value):
|
|
494
|
+
# RichTextBlock inlines / TextRuns.
|
|
495
|
+
runs = []
|
|
496
|
+
for run in value:
|
|
497
|
+
_, piece = pick(run, _keys("text content value"))
|
|
498
|
+
piece = display_text(piece)
|
|
499
|
+
bold = token(run.get("weight", "")) == "bolder" and piece.strip()
|
|
500
|
+
runs.append(f"**{piece}**" if bold else piece)
|
|
501
|
+
value = "".join(runs)
|
|
502
|
+
value = clean_text(value)
|
|
503
|
+
if value:
|
|
504
|
+
return value
|
|
505
|
+
if node.extras:
|
|
506
|
+
key, value = node.extras.pop(0)
|
|
507
|
+
return clean_text(value)
|
|
508
|
+
return ""
|
|
509
|
+
|
|
510
|
+
def r_heading(self, node: Node, depth: int) -> list[dict]:
|
|
511
|
+
content = self.text_of(node, "title", "label")
|
|
512
|
+
if not content:
|
|
513
|
+
self.ctx.warn(node.path, "empty_element", "Heading had no text and was left out.")
|
|
514
|
+
return []
|
|
515
|
+
return [theme.spaced(theme.heading(self.with_icon(content, node)), "Large")]
|
|
516
|
+
|
|
517
|
+
def r_text(self, node: Node, depth: int) -> list[dict]:
|
|
518
|
+
title = clean_text(node.f.get("title"))
|
|
519
|
+
content = self.text_of(node, "label")
|
|
520
|
+
if not content:
|
|
521
|
+
if title:
|
|
522
|
+
return [theme.body(title)]
|
|
523
|
+
self.ctx.warn(node.path, "empty_element", "Text element had no text and was left out.")
|
|
524
|
+
return []
|
|
525
|
+
prefix = [theme.group_title(title)] if title else []
|
|
526
|
+
size, weight = token(node.hints.get("size", "")), token(node.hints.get("weight", ""))
|
|
527
|
+
if "\n" in content and re.search(r"(^|\n)\s*(#{1,6}\s|\|.*\|)", content):
|
|
528
|
+
parts = self.markdown_nodes(content, node.path)
|
|
529
|
+
return prefix + [b for part in parts for b in self.render(part, depth)]
|
|
530
|
+
if (size in {"large", "extralarge", "xl", "xxl", "big", "huge", "medium"} or weight in {"bolder", "bold"}
|
|
531
|
+
or node.hints.get("style") == "heading" or node.source in {"h1", "h2", "h3"}) and len(content) <= 120 and "\n" not in content:
|
|
532
|
+
self.ctx.fixed(node.path, "styled_text_as_heading")
|
|
533
|
+
return prefix + [theme.spaced(theme.heading(self.with_icon(content, node)), "Large")]
|
|
534
|
+
if size in {"small", "tiny", "xs"} or as_bool(node.hints.get("issubtle"), False) or token(node.hints.get("color", "")) in {"gray", "grey", "muted", "subtle", "secondary"}:
|
|
535
|
+
return prefix + [theme.note(content)]
|
|
536
|
+
self.need(min(640, max(320, len(content) * 4)), 50, "Text wraps within the card.")
|
|
537
|
+
return prefix + [theme.body(self.with_icon(content, node))]
|
|
538
|
+
|
|
539
|
+
def r_note(self, node: Node, depth: int) -> list[dict]:
|
|
540
|
+
content = self.text_of(node, "title", "label")
|
|
541
|
+
return [theme.note(content)] if content else []
|
|
542
|
+
|
|
543
|
+
# -- lists, facts, tables
|
|
544
|
+
|
|
545
|
+
def list_items(self, raw) -> list[str]:
|
|
546
|
+
if isinstance(raw, str):
|
|
547
|
+
raw = [line for line in raw.splitlines() if line.strip()] if "\n" in raw else [raw]
|
|
548
|
+
if isinstance(raw, dict):
|
|
549
|
+
return [f"**{k}**: {display_text(v)}" for k, v in raw.items()]
|
|
550
|
+
items = []
|
|
551
|
+
for item in raw if isinstance(raw, list) else [raw]:
|
|
552
|
+
if isinstance(item, dict):
|
|
553
|
+
_, head = pick(item, _keys("title label name heading text"))
|
|
554
|
+
_, detail = pick(item, _keys("description detail details text body value summary"))
|
|
555
|
+
if head is not None and detail is not None and head is not detail:
|
|
556
|
+
items.append(f"**{display_text(head)}**: {display_text(detail)}")
|
|
557
|
+
continue
|
|
558
|
+
item = head if head is not None else detail if detail is not None else item
|
|
559
|
+
text = clean_text(item)
|
|
560
|
+
if text:
|
|
561
|
+
items.append(text)
|
|
562
|
+
return items
|
|
563
|
+
|
|
564
|
+
def r_list(self, node: Node, depth: int) -> list[dict]:
|
|
565
|
+
items = self.list_items(node.f.get("items", []))
|
|
566
|
+
if not items:
|
|
567
|
+
self.ctx.warn(node.path, "empty_element", "List had no items and was left out.")
|
|
568
|
+
return []
|
|
569
|
+
numbered = as_bool(node.f.get("numbered"), False)
|
|
570
|
+
stripped = [re.sub(r"^\s*(?:[-*•]|\d{1,3}[.)])\s+", "", i) for i in items]
|
|
571
|
+
if stripped != items:
|
|
572
|
+
numbered = numbered or all(re.match(r"^\s*\d{1,3}[.)]\s", i) for i in items)
|
|
573
|
+
self.ctx.fixed(node.path, "removed_list_markers")
|
|
574
|
+
width = 12 if not numbered else 20 if len(stripped) < 10 else 28
|
|
575
|
+
rows = [theme.bullet_row(f"{i}." if numbered else "•", item, width) for i, item in enumerate(stripped, 1)]
|
|
576
|
+
rows[0] = {**rows[0], "spacing": "Default"}
|
|
577
|
+
self.need(min(680, max(320, max(len(i) for i in stripped) * 4)), 50, "Text wraps within the card.")
|
|
578
|
+
return self.title_prefix(node) + rows
|
|
579
|
+
|
|
580
|
+
def fact_pairs(self, raw, path: str) -> list[tuple[str, str]] | None:
|
|
581
|
+
"""Label/value pairs from many shapes. None means 'this is really a table'."""
|
|
582
|
+
if isinstance(raw, str):
|
|
583
|
+
raw = [line for line in raw.splitlines() if line.strip()]
|
|
584
|
+
pairs = []
|
|
585
|
+
if isinstance(raw, dict):
|
|
586
|
+
for k, v in raw.items():
|
|
587
|
+
pairs.append((display_text(k), clean_text(v)))
|
|
588
|
+
return pairs
|
|
589
|
+
if not isinstance(raw, list):
|
|
590
|
+
return [("", clean_text(raw))]
|
|
591
|
+
dict_items = [i for i in raw if isinstance(i, dict)]
|
|
592
|
+
if dict_items and len(dict_items) == len(raw) and len(raw) > 1:
|
|
593
|
+
shared = set(dict_items[0])
|
|
594
|
+
if len(shared) >= 2 and all(set(d) == shared for d in dict_items) and not {token(k) for k in shared} & LABEL_KEYS:
|
|
595
|
+
return None
|
|
596
|
+
for item in raw:
|
|
597
|
+
if isinstance(item, dict):
|
|
598
|
+
if len(item) == 1:
|
|
599
|
+
(k, v), = item.items()
|
|
600
|
+
pairs.append((display_text(k), clean_text(v)))
|
|
601
|
+
continue
|
|
602
|
+
lk, label = pick(item, LABEL_KEYS)
|
|
603
|
+
rest = {k: v for k, v in item.items() if k != lk}
|
|
604
|
+
vk, value = pick(rest, VALUE_KEYS)
|
|
605
|
+
if label is not None and vk is not None:
|
|
606
|
+
pairs.append((display_text(label), clean_text(value)))
|
|
607
|
+
else:
|
|
608
|
+
pairs.extend((humanize(k), clean_text(v)) for k, v in item.items())
|
|
609
|
+
elif isinstance(item, list) and len(item) == 2:
|
|
610
|
+
pairs.append((display_text(item[0]), clean_text(item[1])))
|
|
611
|
+
else:
|
|
612
|
+
text = clean_text(item)
|
|
613
|
+
m = re.match(r"\s*([^:=\t]{1,80}?)\s*(?::|=|\t| - | – )\s*(.+)", text, re.S)
|
|
614
|
+
pairs.append((m.group(1), m.group(2)) if m else ("", text))
|
|
615
|
+
return pairs
|
|
616
|
+
|
|
617
|
+
def r_facts(self, node: Node, depth: int) -> list[dict]:
|
|
618
|
+
raw = node.f.get("facts")
|
|
619
|
+
inline = [(humanize(k), clean_text(v)) for k, v in node.unknown if not isinstance(v, (dict, list))]
|
|
620
|
+
if inline:
|
|
621
|
+
# {"type": "facts", "Owner": "Dana"}: extra scalar keys on a facts element are facts.
|
|
622
|
+
node.unknown = [(k, v) for k, v in node.unknown if isinstance(v, (dict, list))]
|
|
623
|
+
self.ctx.fixed(node.path, "fields_as_facts")
|
|
624
|
+
pairs = self.fact_pairs(raw, node.path) if raw is not None else []
|
|
625
|
+
if pairs is not None:
|
|
626
|
+
pairs = pairs + inline
|
|
627
|
+
if pairs is None:
|
|
628
|
+
self.ctx.fixed(node.path, "facts_as_table")
|
|
629
|
+
return self.r_table(Node("table", {"rows": raw, "title": node.f.get("title")}, node.path), depth)
|
|
630
|
+
if pairs and all(not label for label, _ in pairs):
|
|
631
|
+
return self.r_list(Node("list", {"items": [v for _, v in pairs], "title": node.f.get("title")}, node.path), depth)
|
|
632
|
+
pairs = [(label, value or "—") for label, value in pairs if label or value]
|
|
633
|
+
if not pairs:
|
|
634
|
+
self.ctx.warn(node.path, "empty_element", "Facts had no entries and were left out.")
|
|
635
|
+
return []
|
|
636
|
+
longest = max(len(a) + len(b) for a, b in pairs)
|
|
637
|
+
self.need(min(720, max(400, longest * 7)), 50, "Fact labels and values share a row.")
|
|
638
|
+
return self.title_prefix(node) + [theme.spaced({"type": "FactSet", "facts": [{"title": a, "value": b} for a, b in pairs]}, "Small")]
|
|
639
|
+
|
|
640
|
+
def cell_text(self, cell) -> str:
|
|
641
|
+
if isinstance(cell, dict):
|
|
642
|
+
items = next((v for k, v in cell.items() if token(k) in {"items", "elements", "content"}), None)
|
|
643
|
+
if isinstance(items, list):
|
|
644
|
+
return " ".join(clean_text(self.cell_text(i)) for i in items).strip() or "—"
|
|
645
|
+
_, value = pick(cell, _keys("text value title label"))
|
|
646
|
+
if value is not None:
|
|
647
|
+
return clean_text(value) or "—"
|
|
648
|
+
return clean_text(cell) or "—"
|
|
649
|
+
|
|
650
|
+
def table_data(self, node: Node):
|
|
651
|
+
rows = node.f.get("rows")
|
|
652
|
+
columns = node.f.get("columns")
|
|
653
|
+
header_labels = {}
|
|
654
|
+
if isinstance(columns, str):
|
|
655
|
+
columns = [c.strip() for c in re.split(r"[,|]", columns) if c.strip()]
|
|
656
|
+
if isinstance(columns, list):
|
|
657
|
+
normalized = []
|
|
658
|
+
for c in columns:
|
|
659
|
+
if isinstance(c, dict):
|
|
660
|
+
kk, key = pick(c, _keys("key field id name accessor datakey data_index"))
|
|
661
|
+
_, label = pick(c, _keys("title label header heading text display"))
|
|
662
|
+
key = display_text(key if key is not None else label)
|
|
663
|
+
header_labels[key] = display_text(label if label is not None else key)
|
|
664
|
+
normalized.append(key)
|
|
665
|
+
else:
|
|
666
|
+
normalized.append(display_text(c))
|
|
667
|
+
columns = normalized
|
|
668
|
+
elif columns is not None:
|
|
669
|
+
columns = None
|
|
670
|
+
if isinstance(rows, str):
|
|
671
|
+
rows = [[c.strip() for c in re.split(r"\t|\|", line) if c.strip()] for line in rows.splitlines() if line.strip()]
|
|
672
|
+
if isinstance(rows, dict):
|
|
673
|
+
values = list(rows.values())
|
|
674
|
+
if values and all(isinstance(v, list) for v in values) and len({len(v) for v in values}) == 1:
|
|
675
|
+
columns = columns or list(rows)
|
|
676
|
+
rows = [dict(zip(rows, r)) for r in zip(*values)]
|
|
677
|
+
self.ctx.fixed(node.path, "columnar_table_transposed")
|
|
678
|
+
elif values and all(isinstance(v, dict) for v in values):
|
|
679
|
+
rows = [{"Item": k, **v} for k, v in rows.items()]
|
|
680
|
+
self.ctx.fixed(node.path, "keyed_rows")
|
|
681
|
+
else:
|
|
682
|
+
rows = [{"Item": k, "Value": v} for k, v in rows.items()]
|
|
683
|
+
if not isinstance(rows, list) or not rows:
|
|
684
|
+
return None, None
|
|
685
|
+
if all(isinstance(r, dict) and any(token(k) == "cells" for k in r) for r in rows):
|
|
686
|
+
rows = [[self.cell_text(c) for c in next(v for k, v in r.items() if token(k) == "cells")] for r in rows]
|
|
687
|
+
if columns is None and len(rows) > 1 and as_bool(node.hints.get("firstrowasheader"), True):
|
|
688
|
+
columns, rows = rows[0], rows[1:]
|
|
689
|
+
if all(isinstance(r, dict) for r in rows):
|
|
690
|
+
keys = list(dict.fromkeys(k for r in rows for k in r))
|
|
691
|
+
headers = list(columns or []) + [k for k in keys if k not in (columns or [])]
|
|
692
|
+
data = [[self.cell_text(r.get(h)) for h in headers] for r in rows]
|
|
693
|
+
elif all(isinstance(r, list) for r in rows):
|
|
694
|
+
width = max(len(r) for r in rows)
|
|
695
|
+
if columns is None:
|
|
696
|
+
if len(rows) > 1 and all(isinstance(v, str) for v in rows[0]) and len(rows[0]) == width:
|
|
697
|
+
columns, rows = rows[0], rows[1:]
|
|
698
|
+
self.ctx.fixed(node.path, "first_row_as_header")
|
|
699
|
+
else:
|
|
700
|
+
columns = []
|
|
701
|
+
headers = list(columns) + [f"Column {i + 1}" for i in range(len(columns), width)]
|
|
702
|
+
data = [[self.cell_text(v) for v in r] + ["—"] * (len(headers) - len(r)) for r in rows]
|
|
703
|
+
if any(len(r) != width for r in rows):
|
|
704
|
+
self.ctx.fixed(node.path, "padded_short_rows")
|
|
705
|
+
else:
|
|
706
|
+
if all(not isinstance(r, (dict, list)) for r in rows):
|
|
707
|
+
return "list", rows
|
|
708
|
+
headers = list(columns or ["Value"])
|
|
709
|
+
data = [[self.cell_text(r)] if not isinstance(r, list) else [self.cell_text(v) for v in r] for r in rows]
|
|
710
|
+
width = max(len(r) for r in data)
|
|
711
|
+
headers += [f"Column {i + 1}" for i in range(len(headers), width)]
|
|
712
|
+
data = [r + ["—"] * (len(headers) - len(r)) for r in data]
|
|
713
|
+
headers = [header_labels.get(h, humanize(display_text(h)) if isinstance(h, str) and "_" in h else display_text(h)) or "—" for h in headers]
|
|
714
|
+
return headers, data
|
|
715
|
+
|
|
716
|
+
def r_table(self, node: Node, depth: int) -> list[dict]:
|
|
717
|
+
headers, data = self.table_data(node)
|
|
718
|
+
if headers == "list":
|
|
719
|
+
return self.r_list(Node("list", {"items": data, "title": node.f.get("title")}, node.path), depth)
|
|
720
|
+
if not headers or not data:
|
|
721
|
+
self.ctx.warn(node.path, "empty_element", "Table had no rows and was left out.")
|
|
722
|
+
return []
|
|
723
|
+
numeric = [all(NUMERIC_CELL.fullmatch(r[i].strip()) for r in data if r[i] != "—") and any(r[i] != "—" for r in data)
|
|
724
|
+
for i in range(len(headers))]
|
|
725
|
+
lengths = [max(len(h), *(len(r[i]) for r in data)) for i, h in enumerate(headers)]
|
|
726
|
+
weights = [max(1, min(4, math.ceil(n / 12))) for n in lengths]
|
|
727
|
+
sizes = [max(96, min(240, n * 7 + 24)) for n in lengths]
|
|
728
|
+
self.need(sum(sizes) + 32, 50 if len(headers) <= 3 else 80 if len(headers) <= 5 else 100,
|
|
729
|
+
"Tables need enough width for each column; cell text wraps.")
|
|
730
|
+
return self.title_prefix(node) + [theme.spaced(theme.table(headers, data, numeric, weights), "Small")]
|
|
731
|
+
|
|
732
|
+
# -- metrics, badges, progress, ratings
|
|
733
|
+
|
|
734
|
+
def metric_items(self, raw) -> list[dict]:
|
|
735
|
+
items = []
|
|
736
|
+
if isinstance(raw, dict):
|
|
737
|
+
raw = [{"label": k, **(v if isinstance(v, dict) else {"value": v})} for k, v in raw.items()]
|
|
738
|
+
for item in raw if isinstance(raw, list) else [raw]:
|
|
739
|
+
if isinstance(item, str):
|
|
740
|
+
m = re.match(r"\s*(.+?)\s*[:=]\s*(.+)", item)
|
|
741
|
+
item = {"label": m.group(1), "value": m.group(2)} if m else {"value": item}
|
|
742
|
+
if not isinstance(item, dict):
|
|
743
|
+
item = {"value": item}
|
|
744
|
+
mapped = {}
|
|
745
|
+
for k, v in item.items():
|
|
746
|
+
name = METRIC_MAP.get(token(k))
|
|
747
|
+
if name and name not in mapped:
|
|
748
|
+
mapped[name] = v
|
|
749
|
+
elif not name and len(item) == 1:
|
|
750
|
+
mapped = {"label": k, "value": v}
|
|
751
|
+
items.append(mapped)
|
|
752
|
+
return items
|
|
753
|
+
|
|
754
|
+
def r_metrics(self, node: Node, depth: int) -> list[dict]:
|
|
755
|
+
items = self.metric_items(node.f.get("items", []))
|
|
756
|
+
tiles = []
|
|
757
|
+
for i, item in enumerate(items):
|
|
758
|
+
value = clean_text(item.get("value"))
|
|
759
|
+
label = clean_text(item.get("label"))
|
|
760
|
+
if not value and not label:
|
|
761
|
+
continue
|
|
762
|
+
change = clean_text(item.get("change"))
|
|
763
|
+
color = "Default"
|
|
764
|
+
if change:
|
|
765
|
+
direction = self.change_direction(change)
|
|
766
|
+
if direction:
|
|
767
|
+
lower_better = item.get("lower_is_better")
|
|
768
|
+
good = direction > 0
|
|
769
|
+
if as_bool(lower_better, False) or token(lower_better or "") in {"down", "lower", "decrease"}:
|
|
770
|
+
good = not good
|
|
771
|
+
color = "Good" if good else "Attention"
|
|
772
|
+
if not re.match(r"\s*[▲▼↑↓⬆⬇]", change):
|
|
773
|
+
change = f"{'▲' if direction > 0 else '▼'} {change.strip()}"
|
|
774
|
+
icon = item.get("icon")
|
|
775
|
+
if isinstance(icon, str) and 0 < len(icon.strip()) <= 4 and not icon.isascii():
|
|
776
|
+
label = f"{icon.strip()} {label}"
|
|
777
|
+
tiles.append(theme.metric_tile(label or " ", value or "—", change or None, color, clean_text(item.get("note")) or None))
|
|
778
|
+
if not tiles:
|
|
779
|
+
self.ctx.warn(node.path, "empty_element", "Metrics had no values and were left out.")
|
|
780
|
+
return []
|
|
781
|
+
per_row = min(4, len(tiles))
|
|
782
|
+
rows_needed = math.ceil(len(tiles) / per_row)
|
|
783
|
+
per_row = math.ceil(len(tiles) / rows_needed)
|
|
784
|
+
rows = [theme.row(*tiles[i:i + per_row], spacing="Medium") for i in range(0, len(tiles), per_row)]
|
|
785
|
+
self.need(min(1100, 200 * per_row + 40), 50 if per_row <= 2 else 60 if per_row == 3 else 80, "Metric tiles sit side by side.")
|
|
786
|
+
return self.title_prefix(node) + rows
|
|
787
|
+
|
|
788
|
+
@staticmethod
|
|
789
|
+
def change_direction(change: str) -> int:
|
|
790
|
+
t = change.strip()
|
|
791
|
+
if re.match(r"[+▲↑⬆]|up\b|increase|rise|higher|grew|gain", t, re.I):
|
|
792
|
+
return 1
|
|
793
|
+
if re.match(r"[-−–▼↓⬇]|down\b|decrease|drop|fall|lower|declin|loss", t, re.I):
|
|
794
|
+
return -1
|
|
795
|
+
number = as_number(t)
|
|
796
|
+
return 0 if not number else (1 if number > 0 else -1)
|
|
797
|
+
|
|
798
|
+
def badge_element(self, text: str, tone: str, path: str) -> dict:
|
|
799
|
+
_, style, _ = theme.TONES[tone]
|
|
800
|
+
return self.extension({"type": "Badge", "text": text, "style": style, "appearance": "tint"}, theme.badge(text, tone), path)
|
|
801
|
+
|
|
802
|
+
def r_badge(self, node: Node, depth: int) -> list[dict]:
|
|
803
|
+
badges = []
|
|
804
|
+
for item in [node, *node.f.get("_merged", [])]:
|
|
805
|
+
text = self.text_of(item, "title", "label")
|
|
806
|
+
tone_raw = item.f.get("tone")
|
|
807
|
+
if not text and isinstance(tone_raw, str) and token(tone_raw) not in TONE_NAMES:
|
|
808
|
+
# {"status": "Shipped"}: the status word is the badge text.
|
|
809
|
+
text = clean_text(tone_raw)
|
|
810
|
+
if not text:
|
|
811
|
+
continue
|
|
812
|
+
badges.append(self.badge_element(self.with_icon(text, item), tone_of(tone_raw, text), item.path))
|
|
813
|
+
if not badges:
|
|
814
|
+
return []
|
|
815
|
+
return [badges[0]] if len(badges) == 1 else [theme.badge_row(badges)]
|
|
816
|
+
|
|
817
|
+
def r_progress(self, node: Node, depth: int) -> list[dict]:
|
|
818
|
+
label = clean_text(node.f.get("label")) or clean_text(node.f.get("title")) or "Progress"
|
|
819
|
+
maximum = as_number(node.f.get("max"))
|
|
820
|
+
parsed = parse_number(node.f.get("value"))
|
|
821
|
+
if maximum is not None and maximum <= 0:
|
|
822
|
+
self.ctx.warn(node.path + ".max", "invalid_value", "max must be positive; 100 was used.")
|
|
823
|
+
maximum = None
|
|
824
|
+
value = None
|
|
825
|
+
if parsed is not None:
|
|
826
|
+
value = parsed.value
|
|
827
|
+
if parsed.denominator and maximum is None:
|
|
828
|
+
maximum = parsed.denominator
|
|
829
|
+
elif parsed.percent:
|
|
830
|
+
value = value / 100 * (maximum or 100)
|
|
831
|
+
elif maximum is None and 0 < value < 1 and not float(value).is_integer():
|
|
832
|
+
value *= 100
|
|
833
|
+
self.ctx.fixed(node.path + ".value", "fraction_as_percent")
|
|
834
|
+
elif node.f.get("value") not in (None, ""):
|
|
835
|
+
self.ctx.warn(node.path + ".value", "invalid_value", "Progress value was not a number; shown as in progress.")
|
|
836
|
+
maximum = maximum or 100
|
|
837
|
+
if value is None:
|
|
838
|
+
items = theme.progress(label, "In progress", None)
|
|
839
|
+
return [self.extension({"type": "ProgressBar", "max": maximum}, items[0], node.path)] if self.native_ok("ProgressBar") else items
|
|
840
|
+
fraction = max(0.0, value / maximum)
|
|
841
|
+
value_text = f"{format_number(round(value, 2))}%" if maximum == 100 else f"{format_number(value)} / {format_number(maximum)} ({fraction:.0%})"
|
|
842
|
+
color = "Good" if fraction >= 1 else "Accent"
|
|
843
|
+
items = theme.progress(label, value_text, min(1.0, fraction), color)
|
|
844
|
+
self.need(420, 50, "Progress displays need room for a label and indicator.")
|
|
845
|
+
if self.native_ok("ProgressBar"):
|
|
846
|
+
return [items[0], self.extension({"type": "ProgressBar", "value": min(max(value, 0), maximum), "max": maximum}, items[1], node.path)]
|
|
847
|
+
return items
|
|
848
|
+
|
|
849
|
+
def r_spinner(self, node: Node, depth: int) -> list[dict]:
|
|
850
|
+
label = clean_text(node.f.get("label")) or "Loading"
|
|
851
|
+
return [self.extension({"type": "ProgressRing", "label": label}, theme.note(label + "…"), node.path)]
|
|
852
|
+
|
|
853
|
+
def r_rating(self, node: Node, depth: int) -> list[dict]:
|
|
854
|
+
parsed = parse_number(node.f.get("value"))
|
|
855
|
+
if parsed is None:
|
|
856
|
+
self.ctx.warn(node.path + ".value", "invalid_value", "Rating value was not a number; rating left out.")
|
|
857
|
+
return []
|
|
858
|
+
value = parsed.value
|
|
859
|
+
maximum = as_number(node.f.get("max")) or parsed.denominator or (100 if parsed.percent else 5)
|
|
860
|
+
maximum = int(maximum) if float(maximum).is_integer() else math.ceil(maximum)
|
|
861
|
+
if value < 0 or value > maximum or maximum <= 0:
|
|
862
|
+
self.ctx.warn(node.path + ".value", "invalid_value", f"Rating {format_number(value)} is outside 0–{maximum}; shown as text.")
|
|
863
|
+
return self.title_prefix(node) + [theme.body(f"Rating: {display_text(node.f.get('value'))}")]
|
|
864
|
+
count = as_number(node.f.get("count"))
|
|
865
|
+
caption = f"{format_number(value)} / {maximum}"
|
|
866
|
+
if count is not None:
|
|
867
|
+
caption += f" · {format_number(count)} rating{'s' if count != 1 else ''}"
|
|
868
|
+
if maximum > 10:
|
|
869
|
+
fallback = theme.body(f"Rating: {caption}")
|
|
870
|
+
return self.title_prefix(node) + [fallback]
|
|
871
|
+
fallback = theme.stars(value, maximum, caption)
|
|
872
|
+
native = {"type": "Rating", "value": value, "max": maximum}
|
|
873
|
+
if count is not None and float(count).is_integer():
|
|
874
|
+
native["count"] = int(count)
|
|
875
|
+
content = self.extension(native, fallback, node.path) if maximum <= 5 else fallback
|
|
876
|
+
return self.title_prefix(node) + [content]
|
|
877
|
+
|
|
878
|
+
# -- media
|
|
879
|
+
|
|
880
|
+
def image_size(self, node: Node) -> str:
|
|
881
|
+
hint = token(node.f.get("size") or node.hints.get("size") or node.hints.get("width") or "")
|
|
882
|
+
if node.source in {"avatar", "icon"} or hint in {"small", "icon", "thumbnail", "avatar", "xs", "tiny", "sm"}:
|
|
883
|
+
return "Small"
|
|
884
|
+
if node.source == "logo" or hint in {"medium", "md", "logo"}:
|
|
885
|
+
return "Medium"
|
|
886
|
+
if node.source in {"hero", "banner"} or hint in {"stretch", "full", "fullwidth", "hero", "banner", "cover", "wide", "100", "100%"}:
|
|
887
|
+
return "Stretch"
|
|
888
|
+
if hint in {"large", "lg", "big", "xl"}:
|
|
889
|
+
return "Large"
|
|
890
|
+
if hint == "auto":
|
|
891
|
+
return "Auto"
|
|
892
|
+
return "Stretch" if self.column_depth else "Large"
|
|
893
|
+
|
|
894
|
+
def r_image(self, node: Node, depth: int) -> list[dict]:
|
|
895
|
+
raw = node.f.get("url")
|
|
896
|
+
link = as_url(raw)
|
|
897
|
+
caption = clean_text(node.f.get("caption"))
|
|
898
|
+
if not link:
|
|
899
|
+
if raw not in (None, ""):
|
|
900
|
+
self.ctx.warn(node.path + ".url", "invalid_url", "Image URL was not an absolute http(s) link; the image was left out.")
|
|
901
|
+
else:
|
|
902
|
+
self.ctx.warn(node.path, "missing_url", "Image had no URL and was left out.")
|
|
903
|
+
return self.title_prefix(node) + ([theme.note(caption)] if caption else [])
|
|
904
|
+
if raw != link:
|
|
905
|
+
self.ctx.fixed(node.path + ".url", "url_normalized")
|
|
906
|
+
image = {"type": "Image", "url": link, "size": self.image_size(node)}
|
|
907
|
+
alt = clean_text(node.f.get("alt")) or caption or clean_text(node.f.get("title"))
|
|
908
|
+
if alt:
|
|
909
|
+
image["altText"] = alt
|
|
910
|
+
shape = token(node.f.get("shape") or "")
|
|
911
|
+
if node.source == "avatar" or shape in {"person", "circle", "round", "rounded", "avatar", "profile", "circular"}:
|
|
912
|
+
image["style"] = "person"
|
|
913
|
+
self.need(420, 50, "Images use a fixed template size.")
|
|
914
|
+
return self.title_prefix(node) + [image] + ([theme.spaced(theme.note(caption), "Small")] if caption else [])
|
|
915
|
+
|
|
916
|
+
def r_images(self, node: Node, depth: int) -> list[dict]:
|
|
917
|
+
raw = node.f.get("images")
|
|
918
|
+
raw = raw if isinstance(raw, list) else [raw]
|
|
919
|
+
images = []
|
|
920
|
+
for i, item in enumerate(raw):
|
|
921
|
+
link = as_url(item)
|
|
922
|
+
if not link:
|
|
923
|
+
self.ctx.warn(f"{node.path}.images[{i}]", "invalid_url", "Image URL was not an absolute http(s) link; it was left out.")
|
|
924
|
+
continue
|
|
925
|
+
image = {"type": "Image", "url": link}
|
|
926
|
+
if isinstance(item, dict):
|
|
927
|
+
_, alt = pick(item, _keys("alt alt_text caption title description"))
|
|
928
|
+
if alt:
|
|
929
|
+
image["altText"] = clean_text(alt)
|
|
930
|
+
images.append(image)
|
|
931
|
+
if not images:
|
|
932
|
+
return []
|
|
933
|
+
self.need(min(960, len(images) * 180), 50 if len(images) < 3 else 80, "Image sets wrap to the available width.")
|
|
934
|
+
return self.title_prefix(node) + [{"type": "ImageSet", "imageSize": "Medium", "images": images}]
|
|
935
|
+
|
|
936
|
+
def r_media(self, node: Node, depth: int) -> list[dict]:
|
|
937
|
+
raw = node.f.get("sources")
|
|
938
|
+
raw = raw if isinstance(raw, list) else [raw]
|
|
939
|
+
sources, links = [], []
|
|
940
|
+
for i, item in enumerate(raw):
|
|
941
|
+
link = as_url(item)
|
|
942
|
+
if not link:
|
|
943
|
+
self.ctx.warn(f"{node.path}.sources[{i}]", "invalid_url", "Media URL was not an absolute http(s) link; it was left out.")
|
|
944
|
+
continue
|
|
945
|
+
links.append(link)
|
|
946
|
+
mime = None
|
|
947
|
+
if isinstance(item, dict):
|
|
948
|
+
_, mime = pick(item, _keys("mime_type mimetype mime content_type type"))
|
|
949
|
+
suffix = "." + link.split("?")[0].split("#")[0].rsplit(".", 1)[-1].lower() if "." in link.rsplit("/", 1)[-1] else ""
|
|
950
|
+
mime = mime if isinstance(mime, str) and re.fullmatch(r"(?:audio|video)/[a-z0-9.+-]+", mime.strip().lower()) else MIME.get(suffix)
|
|
951
|
+
if mime:
|
|
952
|
+
sources.append({"url": link, "mimeType": mime.strip().lower()})
|
|
953
|
+
if not links:
|
|
954
|
+
return []
|
|
955
|
+
title = clean_text(node.f.get("title"))
|
|
956
|
+
open_button = {"type": "ActionSet", "actions": [{"type": "Action.OpenUrl", "title": f"Open {'media' if not title else title}", "url": links[0]}]}
|
|
957
|
+
if not sources:
|
|
958
|
+
self.ctx.fixed(node.path, "media_as_link")
|
|
959
|
+
return self.title_prefix(node) + [open_button]
|
|
960
|
+
media = {"type": "Media", "sources": sources, "fallback": open_button}
|
|
961
|
+
poster = as_url(node.f.get("poster"))
|
|
962
|
+
if poster:
|
|
963
|
+
media["poster"] = poster
|
|
964
|
+
alt = clean_text(node.f.get("alt")) or title
|
|
965
|
+
if alt:
|
|
966
|
+
media["altText"] = alt
|
|
967
|
+
self.need(640, 60, "Media playback needs a larger viewport.")
|
|
968
|
+
return self.title_prefix(node) + [media]
|
|
969
|
+
|
|
970
|
+
# -- charts
|
|
971
|
+
|
|
972
|
+
def points(self, raw, labels=None) -> list[tuple[object, object]] | None:
|
|
973
|
+
"""(label, raw value) pairs from a mapping, pair list, {label,value} list or value list."""
|
|
974
|
+
if isinstance(raw, dict):
|
|
975
|
+
return list(raw.items())
|
|
976
|
+
if not isinstance(raw, list):
|
|
977
|
+
return None
|
|
978
|
+
if all(not isinstance(v, (dict, list)) for v in raw):
|
|
979
|
+
if labels is not None and len(labels) == len(raw):
|
|
980
|
+
return list(zip(labels, raw))
|
|
981
|
+
if labels is None and all(as_number(v) is not None for v in raw):
|
|
982
|
+
return [(i + 1, v) for i, v in enumerate(raw)]
|
|
983
|
+
return None
|
|
984
|
+
out = []
|
|
985
|
+
for item in raw:
|
|
986
|
+
if isinstance(item, list) and len(item) == 2:
|
|
987
|
+
out.append((item[0], item[1]))
|
|
988
|
+
elif isinstance(item, dict):
|
|
989
|
+
if len(item) == 1:
|
|
990
|
+
out.append(next(iter(item.items())))
|
|
991
|
+
continue
|
|
992
|
+
lk, label = pick(item, POINT_LABEL_KEYS)
|
|
993
|
+
rest = {k: v for k, v in item.items() if k != lk}
|
|
994
|
+
_, value = pick(rest, _keys("value y count amount total number size score"))
|
|
995
|
+
if value is None and len(rest) == 1:
|
|
996
|
+
value = next(iter(rest.values()))
|
|
997
|
+
if label is None or value is None:
|
|
998
|
+
return None
|
|
999
|
+
out.append((label, value))
|
|
1000
|
+
else:
|
|
1001
|
+
return None
|
|
1002
|
+
return out
|
|
1003
|
+
|
|
1004
|
+
def chart_data(self, node: Node):
|
|
1005
|
+
"""Returns (categories, [(series name, [raw values])]) or None."""
|
|
1006
|
+
f = node.f
|
|
1007
|
+
labels = f.get("labels")
|
|
1008
|
+
labels = labels if isinstance(labels, list) else None
|
|
1009
|
+
raw_series = f.get("series")
|
|
1010
|
+
data = f.get("data")
|
|
1011
|
+
if isinstance(data, dict) and any(token(k) == "datasets" for k in data):
|
|
1012
|
+
labels = next((v for k, v in data.items() if token(k) == "labels"), labels)
|
|
1013
|
+
raw_series, data = next(v for k, v in data.items() if token(k) == "datasets"), None
|
|
1014
|
+
if raw_series is None and isinstance(data, dict) and data and all(isinstance(v, (dict, list)) for v in data.values()) \
|
|
1015
|
+
and not all(isinstance(v, list) and len(v) == 2 for v in data.values()):
|
|
1016
|
+
raw_series, data = [{"name": k, "values": v} for k, v in data.items()], None
|
|
1017
|
+
if raw_series is None and isinstance(data, list) and data and all(isinstance(d, dict) for d in data):
|
|
1018
|
+
if all(any(token(k) in SERIES_VALUE_KEYS for k in d) and isinstance(next(v for k, v in d.items() if token(k) in SERIES_VALUE_KEYS), (list, dict)) for d in data):
|
|
1019
|
+
raw_series, data = data, None
|
|
1020
|
+
elif self.points(data) is None or any(len(d) > 2 for d in data):
|
|
1021
|
+
# Records: one label field and one or more numeric fields.
|
|
1022
|
+
keys = list(dict.fromkeys(k for d in data for k in d))
|
|
1023
|
+
numeric = [k for k in keys if all(as_number(d.get(k)) is not None for d in data)]
|
|
1024
|
+
label_keys = [k for k in keys if k not in numeric]
|
|
1025
|
+
if numeric and len(label_keys) <= 1:
|
|
1026
|
+
lk = label_keys[0] if label_keys else None
|
|
1027
|
+
labels = [d.get(lk, i + 1) if lk else i + 1 for i, d in enumerate(data)]
|
|
1028
|
+
raw_series = [{"name": humanize(k), "values": dict(zip(map(display_text, labels), (d[k] for d in data)))} for k in numeric]
|
|
1029
|
+
data = None
|
|
1030
|
+
if not f.get("x_label") and lk:
|
|
1031
|
+
f["x_label"] = humanize(lk)
|
|
1032
|
+
self.ctx.fixed(node.path, "records_as_chart_series")
|
|
1033
|
+
if raw_series is not None:
|
|
1034
|
+
if isinstance(raw_series, dict):
|
|
1035
|
+
raw_series = [{"name": k, "values": v} for k, v in raw_series.items()]
|
|
1036
|
+
series = []
|
|
1037
|
+
for i, s in enumerate(raw_series if isinstance(raw_series, list) else []):
|
|
1038
|
+
if not isinstance(s, dict):
|
|
1039
|
+
continue
|
|
1040
|
+
_, name = pick(s, SERIES_NAME_KEYS)
|
|
1041
|
+
_, values = pick(s, SERIES_VALUE_KEYS)
|
|
1042
|
+
pts = self.points(values, labels)
|
|
1043
|
+
if pts is None:
|
|
1044
|
+
return None
|
|
1045
|
+
series.append((clean_text(name) or f"Series {i + 1}", pts))
|
|
1046
|
+
else:
|
|
1047
|
+
pts = self.points(data if data is not None else f.get("values"), labels)
|
|
1048
|
+
if pts is None:
|
|
1049
|
+
return None
|
|
1050
|
+
series = [(clean_text(f.get("y_label")) or clean_text(f.get("title")) or "Value", pts)]
|
|
1051
|
+
if not series or not any(pts for _, pts in series):
|
|
1052
|
+
return None
|
|
1053
|
+
categories = list(dict.fromkeys(display_text(x) if not isinstance(x, (int, float)) else x for _, pts in series for x, _ in pts))
|
|
1054
|
+
table = []
|
|
1055
|
+
for name, pts in series:
|
|
1056
|
+
lookup = {}
|
|
1057
|
+
for x, y in pts:
|
|
1058
|
+
lookup.setdefault(display_text(x) if not isinstance(x, (int, float)) else x, y)
|
|
1059
|
+
table.append((name, [lookup.get(c) for c in categories]))
|
|
1060
|
+
return categories, table
|
|
1061
|
+
|
|
1062
|
+
def chart_kind(self, node: Node) -> str | None:
|
|
1063
|
+
t = token(node.f.get("chart_type") or "")
|
|
1064
|
+
hints = node.hints
|
|
1065
|
+
if as_bool(hints.get("stacked"), False):
|
|
1066
|
+
t += "stacked"
|
|
1067
|
+
if as_bool(hints.get("horizontal"), False) or token(hints.get("orientation", "")) == "horizontal":
|
|
1068
|
+
t += "horizontal"
|
|
1069
|
+
for words, kind in ((("gauge", "meter", "dial", "speedo"), "gauge"), (("donut", "doughnut", "ring"), "donut"), (("pie",), "pie"),
|
|
1070
|
+
(("line", "area", "trend", "time", "spark", "scatter"), "line")):
|
|
1071
|
+
if any(w in t for w in words):
|
|
1072
|
+
return kind
|
|
1073
|
+
if "stack" in t:
|
|
1074
|
+
return "stacked_horizontal_bar" if "horizontal" in t else "stacked_bar"
|
|
1075
|
+
if any(w in t for w in ("group", "cluster", "multi", "sidebyside")):
|
|
1076
|
+
return "grouped_bar"
|
|
1077
|
+
if "horizontal" in t or t in {"hbar", "barh"}:
|
|
1078
|
+
return "horizontal_bar"
|
|
1079
|
+
if any(w in t for w in ("bar", "column", "histogram", "vertical")):
|
|
1080
|
+
return "bar"
|
|
1081
|
+
if t:
|
|
1082
|
+
self.ctx.warn(node.path + ".chart_type", "unknown_chart_type", f"Chart type '{node.f.get('chart_type')}' is not supported; a suitable type was chosen.")
|
|
1083
|
+
return None
|
|
1084
|
+
|
|
1085
|
+
def r_chart(self, node: Node, depth: int) -> list[dict]:
|
|
1086
|
+
kind = self.chart_kind(node)
|
|
1087
|
+
if kind == "gauge" or (kind is None and node.f.get("value") is not None and node.f.get("data") is None and node.f.get("series") is None):
|
|
1088
|
+
return self.gauge(node)
|
|
1089
|
+
parsed = self.chart_data(node)
|
|
1090
|
+
if parsed is None:
|
|
1091
|
+
if node.f.get("value") is not None:
|
|
1092
|
+
return self.gauge(node)
|
|
1093
|
+
self.ctx.warn(node.path, "invalid_chart_data", "Chart data could not be read; use \"data\": {\"Label\": number}.")
|
|
1094
|
+
return self.title_prefix(node)
|
|
1095
|
+
categories, series = parsed
|
|
1096
|
+
numbers = [[parse_number(v) for v in values] for _, values in series]
|
|
1097
|
+
unreadable = any(v is not None and n is None for (_, values), nums in zip(series, numbers) for v, n in zip(values, nums))
|
|
1098
|
+
gaps = any(v is None for _, values in series for v in values)
|
|
1099
|
+
values = [[n.value if n else None for n in nums] for nums in numbers]
|
|
1100
|
+
multi = len(series) > 1
|
|
1101
|
+
if kind is None:
|
|
1102
|
+
dates = all(as_date(c)[0] for c in categories if isinstance(c, str)) and all(isinstance(c, str) for c in categories)
|
|
1103
|
+
kind = "line" if dates and len(categories) >= 3 else ("grouped_bar" if multi else "bar")
|
|
1104
|
+
if multi and kind in {"bar", "horizontal_bar", "pie", "donut"}:
|
|
1105
|
+
if kind in {"pie", "donut"}:
|
|
1106
|
+
self.ctx.warn(node.path, "chart_type_changed", "Pie/donut charts show one series; several series are shown as grouped bars.")
|
|
1107
|
+
kind = "grouped_bar"
|
|
1108
|
+
if not multi and kind in {"grouped_bar", "stacked_bar", "stacked_horizontal_bar"}:
|
|
1109
|
+
kind = "bar"
|
|
1110
|
+
flat = [v for row in values for v in row if v is not None]
|
|
1111
|
+
if kind in {"pie", "donut"} and (any(v < 0 for v in flat) or sum(flat) <= 0):
|
|
1112
|
+
self.ctx.warn(node.path, "chart_type_changed", "Pie/donut values must be positive; shown as a bar chart.")
|
|
1113
|
+
kind = "bar"
|
|
1114
|
+
if "stacked" in kind and any(v < 0 for v in flat):
|
|
1115
|
+
kind = "grouped_bar"
|
|
1116
|
+
x_values = categories
|
|
1117
|
+
if kind == "line":
|
|
1118
|
+
dates = [as_date(c)[0] if isinstance(c, str) else None for c in categories]
|
|
1119
|
+
nums = [as_number(c) for c in categories]
|
|
1120
|
+
if all(dates):
|
|
1121
|
+
x_values = dates
|
|
1122
|
+
elif all(n is not None for n in nums):
|
|
1123
|
+
x_values = nums
|
|
1124
|
+
else:
|
|
1125
|
+
x_values = None
|
|
1126
|
+
if x_values is not None and len(set(x_values)) == len(x_values) and x_values != sorted(x_values):
|
|
1127
|
+
order = sorted(range(len(x_values)), key=lambda i: x_values[i])
|
|
1128
|
+
x_values = [x_values[i] for i in order]
|
|
1129
|
+
categories = [categories[i] for i in order]
|
|
1130
|
+
values = [[row[i] for i in order] for row in values]
|
|
1131
|
+
series = [(name, [vals[i] for i in order]) for name, vals in series]
|
|
1132
|
+
self.ctx.fixed(node.path, "line_points_sorted_by_x")
|
|
1133
|
+
if x_values is None or len(set(x_values)) != len(x_values):
|
|
1134
|
+
if self.native:
|
|
1135
|
+
self.ctx.fixed(node.path, "line_with_categories_as_bars")
|
|
1136
|
+
kind = "grouped_bar" if multi else "bar"
|
|
1137
|
+
x_values = categories
|
|
1138
|
+
if unreadable:
|
|
1139
|
+
self.ctx.warn(node.path, "chart_values_not_numeric", "Some chart values were not numbers, so the data is shown as a table.")
|
|
1140
|
+
fallback = self.chart_fallback(node, kind, categories, series, values, force_table=unreadable)
|
|
1141
|
+
self.need(640, 60 if not multi else 80, "Charts need room for data and axis labels.")
|
|
1142
|
+
prefix = self.title_prefix(node)
|
|
1143
|
+
if unreadable or gaps or not self.native_ok(NATIVE_CHARTS[kind]):
|
|
1144
|
+
if gaps and self.native_ok(NATIVE_CHARTS[kind]):
|
|
1145
|
+
self.ctx.warn(node.path, "chart_missing_values", "Series have different categories; shown as a table with blanks rather than inventing zeros.")
|
|
1146
|
+
return prefix + fallback
|
|
1147
|
+
native = {"type": NATIVE_CHARTS[kind]}
|
|
1148
|
+
if clean_text(node.f.get("title")):
|
|
1149
|
+
native.update(title=clean_text(node.f["title"]), showTitle=False)
|
|
1150
|
+
for key, prop in (("x_label", "xAxisTitle"), ("y_label", "yAxisTitle")):
|
|
1151
|
+
if clean_text(node.f.get(key)) and kind not in {"pie", "donut"}:
|
|
1152
|
+
native[prop] = clean_text(node.f[key])
|
|
1153
|
+
cats = [display_text(c) for c in categories]
|
|
1154
|
+
if kind in {"pie", "donut"}:
|
|
1155
|
+
native["data"] = [{"legend": c, "value": v} for c, v in zip(cats, values[0])]
|
|
1156
|
+
elif kind in {"bar", "horizontal_bar"}:
|
|
1157
|
+
native["data"] = [{"x": c, "y": v} for c, v in zip(cats, values[0])]
|
|
1158
|
+
elif kind == "stacked_horizontal_bar":
|
|
1159
|
+
native["data"] = [{"title": c, "data": [{"legend": name, "value": vals[i]} for (name, _), vals in zip(series, values)]} for i, c in enumerate(cats)]
|
|
1160
|
+
else:
|
|
1161
|
+
xs = x_values if kind == "line" else cats
|
|
1162
|
+
native["data"] = [{"legend": name, "values": [{"x": x, "y": v} for x, v in zip(xs, vals)]} for (name, _), vals in zip(series, values)]
|
|
1163
|
+
if kind == "stacked_bar":
|
|
1164
|
+
native["stacked"] = True
|
|
1165
|
+
wrapped = fallback[0] if len(fallback) == 1 else {"type": "Container", "items": fallback}
|
|
1166
|
+
return prefix + [self.extension(native, wrapped, node.path)]
|
|
1167
|
+
|
|
1168
|
+
def chart_fallback(self, node, kind, categories, series, values, *, force_table=False) -> list[dict]:
|
|
1169
|
+
labels = [display_text(c) for c in categories]
|
|
1170
|
+
y_label = clean_text(node.f.get("y_label"))
|
|
1171
|
+
if not force_table and len(series) == 1 and kind in {"bar", "horizontal_bar", "pie", "donut", "line"} and all(v is not None and v >= 0 for v in values[0]):
|
|
1172
|
+
top = max(values[0]) or 1
|
|
1173
|
+
total = sum(values[0])
|
|
1174
|
+
label_px = max(56, min(180, max(len(l) for l in labels) * 7 + 8))
|
|
1175
|
+
rows = []
|
|
1176
|
+
for label, raw, v in zip(labels, series[0][1], values[0]):
|
|
1177
|
+
text = display_text(raw) if isinstance(raw, str) else format_number(v)
|
|
1178
|
+
if kind in {"pie", "donut"} and total:
|
|
1179
|
+
text += f" ({v / total:.0%})"
|
|
1180
|
+
rows.append(theme.bar_row(label, v / top, text, label_px))
|
|
1181
|
+
return ([theme.note(y_label)] if y_label else []) + rows
|
|
1182
|
+
x_label = clean_text(node.f.get("x_label")) or ("Date" if kind == "line" else "Category")
|
|
1183
|
+
headers = [x_label, *[name for name, _ in series]]
|
|
1184
|
+
rows = [[label, *[display_text(vals[i]) if vals[i] is not None else "—" for _, vals in series]] for i, label in enumerate(labels)]
|
|
1185
|
+
table_node = Node("table", {"columns": headers, "rows": rows}, node.path)
|
|
1186
|
+
return ([theme.note(y_label)] if y_label else []) + self.r_table(table_node, 0)
|
|
1187
|
+
|
|
1188
|
+
def gauge(self, node: Node) -> list[dict]:
|
|
1189
|
+
parsed = parse_number(node.f.get("value"))
|
|
1190
|
+
if parsed is None:
|
|
1191
|
+
self.ctx.warn(node.path + ".value", "invalid_value", "Gauge value was not a number; gauge left out.")
|
|
1192
|
+
return self.title_prefix(node)
|
|
1193
|
+
low = as_number(node.f.get("min"))
|
|
1194
|
+
high = as_number(node.f.get("max"))
|
|
1195
|
+
low = 0 if low is None else low
|
|
1196
|
+
high = parsed.denominator or (100 if high is None else high)
|
|
1197
|
+
if high <= low:
|
|
1198
|
+
low, high = min(low, high), max(low, high) if high != low else low + 100
|
|
1199
|
+
self.ctx.warn(node.path, "invalid_range", "Gauge max must exceed min; the range was corrected.")
|
|
1200
|
+
value = parsed.value
|
|
1201
|
+
segments = []
|
|
1202
|
+
raw_segments = node.f.get("segments")
|
|
1203
|
+
pts = self.points(raw_segments) if raw_segments is not None else None
|
|
1204
|
+
if pts:
|
|
1205
|
+
sizes = [(display_text(k), as_number(v)) for k, v in pts]
|
|
1206
|
+
if all(s is not None and s > 0 for _, s in sizes):
|
|
1207
|
+
total = sum(s for _, s in sizes)
|
|
1208
|
+
if math.isclose(total, high - low, rel_tol=1e-9):
|
|
1209
|
+
segments = sizes
|
|
1210
|
+
elif all(a < b for (_, a), (_, b) in zip(sizes, sizes[1:])) and math.isclose(sizes[-1][1], high):
|
|
1211
|
+
ends = [s for _, s in sizes]
|
|
1212
|
+
segments = [(n, e - s) for (n, e), s in zip(sizes, [low, *ends[:-1]])]
|
|
1213
|
+
self.ctx.fixed(node.path + ".segments", "segment_endpoints_to_sizes")
|
|
1214
|
+
if not segments:
|
|
1215
|
+
self.ctx.warn(node.path + ".segments", "invalid_segments", "Gauge segments did not cover min–max and were left out.")
|
|
1216
|
+
label = clean_text(node.f.get("title")) or clean_text(node.f.get("label")) or "Value"
|
|
1217
|
+
fraction = (value - low) / (high - low)
|
|
1218
|
+
value_text = f"{format_number(value)} / {format_number(high)}" if low == 0 else f"{format_number(value)} (range {format_number(low)}–{format_number(high)})"
|
|
1219
|
+
items = theme.progress(label, value_text, max(0.0, min(1.0, fraction)))
|
|
1220
|
+
if segments:
|
|
1221
|
+
ranges, start = [], low
|
|
1222
|
+
for name, size in segments:
|
|
1223
|
+
ranges.append(f"{name} {format_number(start)}–{format_number(start + size)}")
|
|
1224
|
+
start += size
|
|
1225
|
+
items.append(theme.spaced(theme.note(" · ".join(ranges)), "None"))
|
|
1226
|
+
self.need(420, 50, "Gauges need room for a label and indicator.")
|
|
1227
|
+
native = {"type": "Chart.Gauge", "min": low, "max": high, "value": min(max(value, low), high), "valueFormat": "fraction",
|
|
1228
|
+
"segments": [{"legend": n, "size": s} for n, s in (segments or [("Range", high - low)])]}
|
|
1229
|
+
if node.f.get("title"):
|
|
1230
|
+
native.update(title=label, showTitle=False)
|
|
1231
|
+
if self.native_ok("Chart.Gauge"):
|
|
1232
|
+
return [self.extension(native, {"type": "Container", "items": items}, node.path)]
|
|
1233
|
+
return items
|
|
1234
|
+
|
|
1235
|
+
# -- layout
|
|
1236
|
+
|
|
1237
|
+
def r_section(self, node: Node, depth: int) -> list[dict]:
|
|
1238
|
+
title = clean_text(node.f.get("title"))
|
|
1239
|
+
children = self.render_list(node.f.get("elements", []), node.path + ".elements", depth + 1)
|
|
1240
|
+
if not children:
|
|
1241
|
+
return [theme.spaced(theme.heading(title), "Large")] if title else []
|
|
1242
|
+
if not title and len(children) == 1:
|
|
1243
|
+
return children
|
|
1244
|
+
items = ([theme.group_title(self.with_icon(title, node))] if title else []) + children
|
|
1245
|
+
return [theme.spaced({"type": "Container", "items": items}, "Large" if depth == 0 else "Medium", separator=depth == 0 and bool(title))]
|
|
1246
|
+
|
|
1247
|
+
def column_record(self, record: dict, path: str) -> dict:
|
|
1248
|
+
"""A flat product/plan record -> {title, elements}."""
|
|
1249
|
+
title_key, title = pick(record, _keys("title name label heading product plan tier header"))
|
|
1250
|
+
elements = []
|
|
1251
|
+
for key, value in record.items():
|
|
1252
|
+
t = token(key)
|
|
1253
|
+
if key == title_key or value in (None, "", [], {}):
|
|
1254
|
+
continue
|
|
1255
|
+
if t in _keys("image img photo picture logo thumbnail image_url imageurl") and as_url(value):
|
|
1256
|
+
elements.insert(0, {"type": "image", "url": value, "alt": display_text(title or "")})
|
|
1257
|
+
elif t in _keys("badge tag label ribbon highlight status"):
|
|
1258
|
+
elements.append({"type": "badge", "text": value})
|
|
1259
|
+
elif t in _keys("description summary details detail text body content blurb about overview"):
|
|
1260
|
+
elements.append({"type": "text", "text": value})
|
|
1261
|
+
elif t in _keys("rating stars score") and as_number(value) is not None:
|
|
1262
|
+
elements.append({"type": "rating", "value": value})
|
|
1263
|
+
elif t in _keys("url link href website cta") and as_url(value):
|
|
1264
|
+
elements.append({"type": "buttons", "buttons": [{"label": f"View {display_text(title)}" if title else "View details", "url": value}]})
|
|
1265
|
+
elif isinstance(value, list) and all(isinstance(v, str) for v in value):
|
|
1266
|
+
elements.append({"type": "list", "title": humanize(key), "items": value})
|
|
1267
|
+
elif isinstance(value, dict):
|
|
1268
|
+
elements.append({"type": "facts", "title": humanize(key), "facts": value})
|
|
1269
|
+
else:
|
|
1270
|
+
if elements and elements[-1].get("type") == "facts" and elements[-1].get("_auto"):
|
|
1271
|
+
elements[-1]["facts"][humanize(key)] = value
|
|
1272
|
+
else:
|
|
1273
|
+
elements.append({"type": "facts", "facts": {humanize(key): value}, "_auto": True})
|
|
1274
|
+
for e in elements:
|
|
1275
|
+
e.pop("_auto", None)
|
|
1276
|
+
self.ctx.fixed(path, "record_as_column")
|
|
1277
|
+
return {"title": title, "elements": elements}
|
|
1278
|
+
|
|
1279
|
+
def r_columns(self, node: Node, depth: int) -> list[dict]:
|
|
1280
|
+
raw_columns = node.f.get("columns")
|
|
1281
|
+
if isinstance(raw_columns, dict):
|
|
1282
|
+
raw_columns = [{"title": k, "elements": v if isinstance(v, list) else [v]} for k, v in raw_columns.items()]
|
|
1283
|
+
if not isinstance(raw_columns, list) or not raw_columns:
|
|
1284
|
+
self.ctx.warn(node.path, "empty_element", "Columns had no content and were left out.")
|
|
1285
|
+
return []
|
|
1286
|
+
specs = []
|
|
1287
|
+
for i, raw in enumerate(raw_columns):
|
|
1288
|
+
p = f"{node.path}.columns[{i}]"
|
|
1289
|
+
if isinstance(raw, list):
|
|
1290
|
+
specs.append((None, raw))
|
|
1291
|
+
elif isinstance(raw, dict):
|
|
1292
|
+
keys = {token(k): k for k in raw}
|
|
1293
|
+
element_key = next((keys[t] for t in _keys("elements items body children content components blocks widgets") if t in keys
|
|
1294
|
+
and isinstance(raw[keys[t]], list)), None)
|
|
1295
|
+
if element_key:
|
|
1296
|
+
_, title = pick({k: v for k, v in raw.items() if k != element_key}, _keys("title heading header label name"))
|
|
1297
|
+
specs.append((title, raw[element_key]))
|
|
1298
|
+
elif any(t in TYPE_KEYS for t in keys) and token(raw[next(keys[t] for t in keys if t in TYPE_KEYS)]) not in {"column", "col"}:
|
|
1299
|
+
specs.append((None, [raw]))
|
|
1300
|
+
else:
|
|
1301
|
+
record = self.column_record({k: v for k, v in raw.items() if token(k) not in TYPE_KEYS}, p)
|
|
1302
|
+
specs.append((record["title"], record["elements"]))
|
|
1303
|
+
elif raw not in (None, ""):
|
|
1304
|
+
specs.append((None, [raw]))
|
|
1305
|
+
if len(specs) == 1:
|
|
1306
|
+
title, elements = specs[0]
|
|
1307
|
+
return self.r_section(Node("section", {"title": title, "elements": elements}, node.path), depth)
|
|
1308
|
+
outer = (self.min_width, self.floor_percent, self.force_overflow)
|
|
1309
|
+
rendered, widths = [], []
|
|
1310
|
+
self.column_depth += 1
|
|
1311
|
+
for i, (title, elements) in enumerate(specs):
|
|
1312
|
+
self.min_width, self.floor_percent, self.force_overflow = 160, 0, False
|
|
1313
|
+
items = [theme.group_title(clean_text(title))] if clean_text(title) else []
|
|
1314
|
+
items += self.render_list(elements, f"{node.path}.columns[{i}].elements", depth + 1)
|
|
1315
|
+
if not items:
|
|
1316
|
+
continue
|
|
1317
|
+
widths.append(self.column_minimum(items))
|
|
1318
|
+
only_inputs = all(_is_input(item) for item in items if item.get("type") != "TextBlock")
|
|
1319
|
+
rendered.append(theme.column(items, **({} if only_inputs else {"style": "emphasis"})))
|
|
1320
|
+
self.column_depth -= 1
|
|
1321
|
+
self.min_width, self.floor_percent, self.force_overflow = outer
|
|
1322
|
+
if not rendered:
|
|
1323
|
+
return []
|
|
1324
|
+
count = len(rendered)
|
|
1325
|
+
minimum = max(widths) * count + 16 * (count - 1) + 32
|
|
1326
|
+
floor = 50 if count == 1 else 60 if count == 2 else 80 if count <= 4 else 100
|
|
1327
|
+
self.need(minimum, floor, f"{count} side-by-side columns preserve all their content.", overflow=count > 6)
|
|
1328
|
+
return [theme.spaced(theme.row(*rendered), "Medium")]
|
|
1329
|
+
|
|
1330
|
+
def column_minimum(self, items: list[dict]) -> int:
|
|
1331
|
+
minimum = 160
|
|
1332
|
+
for item in items:
|
|
1333
|
+
kind = item.get("type", "")
|
|
1334
|
+
if kind.startswith("Input."):
|
|
1335
|
+
minimum = max(minimum, 240)
|
|
1336
|
+
elif kind == "Media" or kind.startswith("Chart."):
|
|
1337
|
+
minimum = max(minimum, 420)
|
|
1338
|
+
elif kind == "Table":
|
|
1339
|
+
minimum = max(minimum, 96 * len(item["columns"]) + 16)
|
|
1340
|
+
elif kind == "Container":
|
|
1341
|
+
minimum = max(minimum, self.column_minimum(item["items"]))
|
|
1342
|
+
elif kind == "ColumnSet":
|
|
1343
|
+
widths = [self.column_minimum(c["items"]) for c in item["columns"]]
|
|
1344
|
+
minimum = max(minimum, sum(widths) + 8 * (len(widths) - 1) if len(widths) > 2 else max(widths) * len(widths))
|
|
1345
|
+
elif kind == "ActionSet":
|
|
1346
|
+
minimum = max(minimum, max((min(320, len(a["title"]) * 7 + 32) for a in item["actions"]), default=160))
|
|
1347
|
+
return minimum
|
|
1348
|
+
|
|
1349
|
+
# -- buttons
|
|
1350
|
+
|
|
1351
|
+
def button(self, raw, path: str, primary_available: bool) -> dict | None:
|
|
1352
|
+
if isinstance(raw, str):
|
|
1353
|
+
raw = {"label": raw}
|
|
1354
|
+
if not isinstance(raw, dict):
|
|
1355
|
+
return None
|
|
1356
|
+
b = {}
|
|
1357
|
+
for k, v in raw.items():
|
|
1358
|
+
name = BUTTON_MAP.get(token(k))
|
|
1359
|
+
if name and name not in b and v is not None:
|
|
1360
|
+
b[name] = v
|
|
1361
|
+
label = clean_text(b.get("label"))
|
|
1362
|
+
action = token(b.get("action") or "")
|
|
1363
|
+
link_raw = b.get("url")
|
|
1364
|
+
if action and action not in SUBMIT_WORDS and action not in LINK_WORDS and as_url(b.get("action")):
|
|
1365
|
+
link_raw = link_raw or b.get("action")
|
|
1366
|
+
action = "openurl"
|
|
1367
|
+
wants_link = action in LINK_WORDS or (link_raw is not None and action not in SUBMIT_WORDS)
|
|
1368
|
+
if action in _keys("showcard actionshowcard togglevisibility actiontogglevisibility"):
|
|
1369
|
+
self.ctx.warn(path, "unsupported_action", "Show-card and toggle actions are not supported; shown as a regular button.")
|
|
1370
|
+
icon = b.get("icon")
|
|
1371
|
+
if isinstance(icon, str) and 0 < len(icon.strip()) <= 4 and not icon.isascii() and label:
|
|
1372
|
+
label = f"{icon.strip()} {label}"
|
|
1373
|
+
style_word = token(b.get("style") or "")
|
|
1374
|
+
explicit = ("positive" if style_word in _keys("primary positive success green confirm cta main") else
|
|
1375
|
+
"destructive" if style_word in _keys("danger destructive red negative delete critical warning") else
|
|
1376
|
+
"default" if style_word else None)
|
|
1377
|
+
if wants_link:
|
|
1378
|
+
link = as_url(link_raw)
|
|
1379
|
+
if not link:
|
|
1380
|
+
self.ctx.warn(path, "invalid_url", f"Button '{label or 'link'}' had no valid http(s) URL and was left out.")
|
|
1381
|
+
return None
|
|
1382
|
+
result = {"type": "Action.OpenUrl", "title": label or "Open link", "url": link}
|
|
1383
|
+
else:
|
|
1384
|
+
label = label or "Submit"
|
|
1385
|
+
data = b.get("data")
|
|
1386
|
+
if data is None and action and action not in SUBMIT_WORDS:
|
|
1387
|
+
data = b.get("action")
|
|
1388
|
+
if isinstance(data, str) and data.strip()[:1] == "{":
|
|
1389
|
+
try:
|
|
1390
|
+
data = json.loads(data)
|
|
1391
|
+
except ValueError:
|
|
1392
|
+
pass
|
|
1393
|
+
if data is None:
|
|
1394
|
+
data = {"action": slug(label, "action")}
|
|
1395
|
+
self.ctx.fixed(path, "generated_submit_data")
|
|
1396
|
+
elif not isinstance(data, dict):
|
|
1397
|
+
data = {"action": submit_text(data)}
|
|
1398
|
+
result = {"type": "Action.Submit", "title": label, "data": data}
|
|
1399
|
+
style = theme.button_style(result["title"], explicit, primary_available)
|
|
1400
|
+
if style:
|
|
1401
|
+
result["style"] = style
|
|
1402
|
+
return result
|
|
1403
|
+
|
|
1404
|
+
def buttons(self, raw_list, path: str) -> list[dict]:
|
|
1405
|
+
raw_list = raw_list if isinstance(raw_list, list) else [raw_list]
|
|
1406
|
+
out, primary = [], True
|
|
1407
|
+
for i, raw in enumerate(raw_list):
|
|
1408
|
+
action = self.button(raw, f"{path}[{i}]", primary)
|
|
1409
|
+
if action:
|
|
1410
|
+
primary = primary and action.get("style") != "positive"
|
|
1411
|
+
out.append(action)
|
|
1412
|
+
return out
|
|
1413
|
+
|
|
1414
|
+
def r_buttons(self, node: Node, depth: int) -> list[dict]:
|
|
1415
|
+
actions = []
|
|
1416
|
+
for item in [node, *node.f.get("_merged", [])]:
|
|
1417
|
+
raw = item.f.get("buttons")
|
|
1418
|
+
if raw is None:
|
|
1419
|
+
single = {k: v for k, v in item.f.items() if k in {"label", "url", "data", "action", "style"}}
|
|
1420
|
+
raw = [single] if single else []
|
|
1421
|
+
actions += self.buttons(raw, item.path + ".buttons")
|
|
1422
|
+
if not actions:
|
|
1423
|
+
return []
|
|
1424
|
+
return [theme.spaced({"type": "ActionSet", "actions": actions[i:i + 5]}, "Medium") for i in range(0, len(actions), 5)]
|
|
1425
|
+
|
|
1426
|
+
# -- inputs
|
|
1427
|
+
|
|
1428
|
+
def generated_id(self, base: str) -> str:
|
|
1429
|
+
result, n = base, 2
|
|
1430
|
+
while result in self.used_ids or result in self.reserved_ids:
|
|
1431
|
+
result, n = f"{base}_{n}", n + 1
|
|
1432
|
+
return result
|
|
1433
|
+
|
|
1434
|
+
def input_base(self, node: Node, default_label: str) -> dict:
|
|
1435
|
+
f = node.f
|
|
1436
|
+
label = clean_text(f.get("label"))
|
|
1437
|
+
explicit = f.get("id")
|
|
1438
|
+
ident = as_id(explicit) if explicit is not None else None
|
|
1439
|
+
if explicit is not None and ident is None:
|
|
1440
|
+
self.ctx.warn(node.path + ".id", "invalid_id", "The id could not be used; an id was generated from the label.")
|
|
1441
|
+
if ident and ident != explicit:
|
|
1442
|
+
self.ctx.fixed(node.path + ".id", "id_sanitized", source=display_text(explicit), target=ident)
|
|
1443
|
+
if ident and ident in self.used_ids:
|
|
1444
|
+
new = self.generated_id(ident)
|
|
1445
|
+
self.ctx.warn(node.path + ".id", "duplicate_id", f"Input id '{ident}' was already used; this input submits as '{new}'.")
|
|
1446
|
+
ident = new
|
|
1447
|
+
if not ident:
|
|
1448
|
+
ident = self.generated_id(slug(label or default_label))
|
|
1449
|
+
self.used_ids.add(ident)
|
|
1450
|
+
self.input_ids.append(ident)
|
|
1451
|
+
if not label:
|
|
1452
|
+
label = humanize(display_text(explicit)) if explicit is not None and is_slug_like(explicit) else default_label
|
|
1453
|
+
required = as_bool(f.get("required"), None)
|
|
1454
|
+
if required is None and "optional" in node.hints:
|
|
1455
|
+
required = not as_bool(node.hints["optional"], False)
|
|
1456
|
+
if f.get("required") is not None and as_bool(f.get("required"), None) is None:
|
|
1457
|
+
self.ctx.fixed(node.path + ".required", "unreadable_boolean_default_false")
|
|
1458
|
+
out = {"id": ident, "label": label}
|
|
1459
|
+
if required:
|
|
1460
|
+
out["isRequired"] = True
|
|
1461
|
+
out["errorMessage"] = clean_text(f.get("error_message")) or f"Please complete {label}."
|
|
1462
|
+
elif clean_text(f.get("error_message")):
|
|
1463
|
+
out["errorMessage"] = clean_text(f["error_message"])
|
|
1464
|
+
self.need(480, 50, "Form inputs need readable labels and entry space.")
|
|
1465
|
+
return out
|
|
1466
|
+
|
|
1467
|
+
def input_spacing(self, element: dict) -> dict:
|
|
1468
|
+
return theme.spaced(element, "Medium")
|
|
1469
|
+
|
|
1470
|
+
def r_text_input(self, node: Node, depth: int) -> list[dict]:
|
|
1471
|
+
f = node.f
|
|
1472
|
+
out = self.input_base(node, "Your answer")
|
|
1473
|
+
out = {"type": "Input.Text", **out}
|
|
1474
|
+
hint = token(f.get("format") or "")
|
|
1475
|
+
fmt = next((name for name, words in (("email", ("email", "mail")), ("tel", ("tel", "phone", "mobile")),
|
|
1476
|
+
("url", ("url", "link", "website", "uri")), ("password", ("password", "secret")))
|
|
1477
|
+
if any(w in hint for w in words)), None)
|
|
1478
|
+
if fmt is None and not hint:
|
|
1479
|
+
probe = token(out["label"] + " " + out["id"])
|
|
1480
|
+
fmt = next((name for name, words in (("email", ("email",)), ("tel", ("phone", "mobile", "telephone")),
|
|
1481
|
+
("url", ("website", "url")), ("password", ("password",))) if any(w in probe for w in words)), None)
|
|
1482
|
+
if fmt:
|
|
1483
|
+
self.ctx.fixed(node.path, "format_inferred_from_label", target=fmt)
|
|
1484
|
+
if fmt:
|
|
1485
|
+
out["style"] = fmt
|
|
1486
|
+
placeholder = clean_text(f.get("placeholder"))
|
|
1487
|
+
if placeholder:
|
|
1488
|
+
out["placeholder"] = placeholder
|
|
1489
|
+
multiline = as_bool(f.get("multiline"), None)
|
|
1490
|
+
if multiline is None and isinstance(f.get("multiline"), (int, float)):
|
|
1491
|
+
multiline = f["multiline"] > 1
|
|
1492
|
+
if multiline is None and not fmt:
|
|
1493
|
+
multiline = any(w in out["label"].casefold() for w in ("comment", "feedback", "description", "notes", "message", "explain", "reason", "justification", "details"))
|
|
1494
|
+
if multiline:
|
|
1495
|
+
out["isMultiline"] = True
|
|
1496
|
+
self.need(560, 60, "Multiline text entry benefits from more horizontal space.")
|
|
1497
|
+
if f.get("value") is not None:
|
|
1498
|
+
out["value"] = display_text(f["value"])
|
|
1499
|
+
if f.get("max_length") is not None:
|
|
1500
|
+
limit = as_number(f["max_length"])
|
|
1501
|
+
if limit is None or limit < 1:
|
|
1502
|
+
self.ctx.warn(node.path + ".max_length", "invalid_value", "max_length was not a positive number and was left out.")
|
|
1503
|
+
elif len(out.get("value", "").encode("utf-16-le")) // 2 > limit:
|
|
1504
|
+
self.ctx.warn(node.path + ".max_length", "invalid_value", "The initial text is longer than max_length, so the limit was left out.")
|
|
1505
|
+
else:
|
|
1506
|
+
out["maxLength"] = int(limit)
|
|
1507
|
+
if isinstance(f.get("regex"), str) and f["regex"]:
|
|
1508
|
+
out["regex"] = f["regex"]
|
|
1509
|
+
if f.get("_unsupported"):
|
|
1510
|
+
self.ctx.warn(node.path, "unsupported_input", f"{f['_unsupported'].capitalize()} is not available in Adaptive Cards; a URL text box is shown instead.")
|
|
1511
|
+
return [self.input_spacing(out)]
|
|
1512
|
+
|
|
1513
|
+
def r_number_input(self, node: Node, depth: int) -> list[dict]:
|
|
1514
|
+
f = node.f
|
|
1515
|
+
out = {"type": "Input.Number", **self.input_base(node, "Number")}
|
|
1516
|
+
unit = clean_text(f.get("unit"))
|
|
1517
|
+
if unit and unit.casefold() not in out["label"].casefold():
|
|
1518
|
+
out["label"] = f"{out['label']} ({unit})"
|
|
1519
|
+
bounds = {k: as_number(f.get(k)) for k in ("min", "max") if f.get(k) is not None}
|
|
1520
|
+
for k in ("min", "max"):
|
|
1521
|
+
if f.get(k) is not None and bounds.get(k) is None:
|
|
1522
|
+
self.ctx.warn(f"{node.path}.{k}", "invalid_value", f"{k} was not a number and was left out.")
|
|
1523
|
+
bounds.pop(k, None)
|
|
1524
|
+
if "min" in bounds and "max" in bounds and bounds["min"] > bounds["max"]:
|
|
1525
|
+
bounds["min"], bounds["max"] = bounds["max"], bounds["min"]
|
|
1526
|
+
self.ctx.fixed(node.path, "swapped_min_max")
|
|
1527
|
+
out.update(bounds)
|
|
1528
|
+
if f.get("value") is not None:
|
|
1529
|
+
value = as_number(f["value"])
|
|
1530
|
+
if value is None or ("min" in bounds and value < bounds["min"]) or ("max" in bounds and value > bounds["max"]):
|
|
1531
|
+
self.ctx.warn(node.path + ".value", "invalid_value", "Initial value was not a number within min–max and was left out.")
|
|
1532
|
+
else:
|
|
1533
|
+
out["value"] = value
|
|
1534
|
+
if clean_text(f.get("placeholder")):
|
|
1535
|
+
out["placeholder"] = clean_text(f["placeholder"])
|
|
1536
|
+
return [self.input_spacing(out)]
|
|
1537
|
+
|
|
1538
|
+
def temporal(self, node: Node, kind: str) -> dict:
|
|
1539
|
+
f = node.f
|
|
1540
|
+
out = {"type": "Input.Date" if kind == "date" else "Input.Time", **self.input_base(node, "Date" if kind == "date" else "Time")}
|
|
1541
|
+
parse = (lambda v: as_date(v, self.config.day_first)) if kind == "date" else (lambda v: (as_time(v), False))
|
|
1542
|
+
for key in ("min", "max", "value"):
|
|
1543
|
+
raw = f.get(key)
|
|
1544
|
+
if raw is None or raw == "":
|
|
1545
|
+
continue
|
|
1546
|
+
value, ambiguous = parse(raw)
|
|
1547
|
+
if value is None:
|
|
1548
|
+
self.ctx.warn(f"{node.path}.{key}", "invalid_value", f"{key} '{display_text(raw)}' is not a {kind} that can be read exactly; it was left out.")
|
|
1549
|
+
continue
|
|
1550
|
+
if ambiguous:
|
|
1551
|
+
self.ctx.warn(f"{node.path}.{key}", "ambiguous_date", f"'{raw}' read as {value} ({'day' if self.config.day_first else 'month'} first).")
|
|
1552
|
+
elif value != raw:
|
|
1553
|
+
self.ctx.fixed(f"{node.path}.{key}", f"{kind}_normalized", source=display_text(raw), target=value)
|
|
1554
|
+
out[key] = value
|
|
1555
|
+
if "min" in out and "max" in out and out["min"] > out["max"]:
|
|
1556
|
+
out["min"], out["max"] = out["max"], out["min"]
|
|
1557
|
+
self.ctx.fixed(node.path, "swapped_min_max")
|
|
1558
|
+
if "value" in out and (("min" in out and out["value"] < out["min"]) or ("max" in out and out["value"] > out["max"])):
|
|
1559
|
+
self.ctx.warn(node.path + ".value", "invalid_value", "Initial value was outside min–max and was left out.")
|
|
1560
|
+
del out["value"]
|
|
1561
|
+
return out
|
|
1562
|
+
|
|
1563
|
+
def r_date_input(self, node: Node, depth: int) -> list[dict]:
|
|
1564
|
+
return [self.input_spacing(self.temporal(node, "date"))]
|
|
1565
|
+
|
|
1566
|
+
def r_time_input(self, node: Node, depth: int) -> list[dict]:
|
|
1567
|
+
return [self.input_spacing(self.temporal(node, "time"))]
|
|
1568
|
+
|
|
1569
|
+
def r_datetime_input(self, node: Node, depth: int) -> list[dict]:
|
|
1570
|
+
f = dict(node.f)
|
|
1571
|
+
date_part = time_part = None
|
|
1572
|
+
raw = f.pop("value", None)
|
|
1573
|
+
if isinstance(raw, str):
|
|
1574
|
+
m = re.match(r"(.*?)[T\s,]+(\d{1,2}(?::\d{2})?(?::\d{2})?\s*(?:[ap]\.?m\.?)?)\s*(?:Z|[+-]\d{2}:?\d{2})?$", raw.strip(), re.I)
|
|
1575
|
+
date_part, time_part = (m.group(1), m.group(2)) if m else (raw, None)
|
|
1576
|
+
label = clean_text(f.get("label")) or "Date"
|
|
1577
|
+
base_id = as_id(f.get("id")) or slug(label)
|
|
1578
|
+
date_node = Node("date_input", {**f, "id": base_id + "_date", "label": label, "value": date_part,
|
|
1579
|
+
"min": f.get("min"), "max": f.get("max")}, node.path)
|
|
1580
|
+
time_node = Node("time_input", {"id": base_id + "_time", "label": "Time", "value": time_part,
|
|
1581
|
+
"required": f.get("required")}, node.path)
|
|
1582
|
+
date_input, time_input = self.temporal(date_node, "date"), self.temporal(time_node, "time")
|
|
1583
|
+
self.ctx.fixed(node.path, "datetime_split", ids=[date_input["id"], time_input["id"]])
|
|
1584
|
+
return [self.input_spacing(theme.row(theme.column([date_input]), theme.column([time_input])))]
|
|
1585
|
+
|
|
1586
|
+
def choice_options(self, raw, node: Node) -> list[tuple[str, str | None]]:
|
|
1587
|
+
"""(title, explicit value or None)."""
|
|
1588
|
+
if isinstance(raw, str):
|
|
1589
|
+
parts = raw.splitlines() if "\n" in raw else re.split(r"\s*[;|]\s*", raw) if re.search(r"[;|]", raw) else re.split(r"\s*,\s*", raw)
|
|
1590
|
+
raw = [p for p in parts if p.strip()]
|
|
1591
|
+
self.ctx.fixed(node.path + ".options", "options_split_from_text")
|
|
1592
|
+
if isinstance(raw, dict):
|
|
1593
|
+
items = list(raw.items())
|
|
1594
|
+
keys_slug = all(re.fullmatch(r"[a-z0-9_\-]+", str(k)) for k, _ in items)
|
|
1595
|
+
values_display = any(isinstance(v, str) and (" " in v or v[:1].isupper()) for _, v in items)
|
|
1596
|
+
if keys_slug and values_display:
|
|
1597
|
+
self.ctx.fixed(node.path + ".options", "options_value_title_order")
|
|
1598
|
+
return [(display_text(v), display_text(k)) for k, v in items]
|
|
1599
|
+
return [(display_text(k), submit_text(v) if not isinstance(v, dict) else None) for k, v in items]
|
|
1600
|
+
options = []
|
|
1601
|
+
for item in raw if isinstance(raw, list) else [raw]:
|
|
1602
|
+
if isinstance(item, dict):
|
|
1603
|
+
if len(item) == 1 and token(next(iter(item))) not in LABEL_KEYS | VALUE_KEYS:
|
|
1604
|
+
(k, v), = item.items()
|
|
1605
|
+
options.append((display_text(k), submit_text(v)))
|
|
1606
|
+
continue
|
|
1607
|
+
tk, title = pick(item, _keys("title label text name display display_name displayname caption option choice"))
|
|
1608
|
+
_, value = pick({k: v for k, v in item.items() if k != tk}, _keys("value id key code val data"))
|
|
1609
|
+
if title is None and value is None:
|
|
1610
|
+
continue
|
|
1611
|
+
options.append((display_text(title if title is not None else value), submit_text(value) if value is not None else None))
|
|
1612
|
+
elif isinstance(item, list) and len(item) == 2:
|
|
1613
|
+
options.append((display_text(item[0]), submit_text(item[1])))
|
|
1614
|
+
elif item is not None and clean_text(item):
|
|
1615
|
+
options.append((clean_text(item), None))
|
|
1616
|
+
return options
|
|
1617
|
+
|
|
1618
|
+
def r_choice(self, node: Node, depth: int) -> list[dict]:
|
|
1619
|
+
f = node.f
|
|
1620
|
+
options = self.choice_options(f.get("options", []), node)
|
|
1621
|
+
if not options:
|
|
1622
|
+
label = clean_text(f.get("label"))
|
|
1623
|
+
if re.match(r"(do|does|did|is|are|was|were|will|would|can|could|should|have|has|may)\b.*\?\s*$", label, re.I):
|
|
1624
|
+
options = [("Yes", None), ("No", None)]
|
|
1625
|
+
self.ctx.fixed(node.path, "yes_no_options_inferred")
|
|
1626
|
+
else:
|
|
1627
|
+
self.ctx.warn(node.path + ".options", "missing_options", "Choice had no options; a text box is shown instead.")
|
|
1628
|
+
return self.r_text_input(Node("text_input", {k: v for k, v in f.items() if k in SPECS["text_input"].fields}, node.path, node.hints), depth)
|
|
1629
|
+
multiple = as_bool(f.get("multiple"), False)
|
|
1630
|
+
out = {"type": "Input.ChoiceSet", **self.input_base(node, "Choose an option")}
|
|
1631
|
+
seen_titles, choices = set(), []
|
|
1632
|
+
for title, value in options:
|
|
1633
|
+
if title in seen_titles and value is None:
|
|
1634
|
+
self.ctx.fixed(node.path + ".options", "duplicate_option_removed", option=title)
|
|
1635
|
+
continue
|
|
1636
|
+
seen_titles.add(title)
|
|
1637
|
+
choices.append([title, value])
|
|
1638
|
+
used = set()
|
|
1639
|
+
for pair in choices:
|
|
1640
|
+
title, value = pair
|
|
1641
|
+
if value is None or value.strip() == "":
|
|
1642
|
+
value = title.strip()
|
|
1643
|
+
if multiple and "," in value:
|
|
1644
|
+
value = re.sub(r"\s*,\s*", " ", value)
|
|
1645
|
+
elif multiple and "," in value:
|
|
1646
|
+
value = value.replace(",", "_")
|
|
1647
|
+
self.ctx.warn(node.path + ".options", "value_changed", f"Option value '{pair[1]}' contained a comma, which breaks multi-select; submitted as '{value}'.")
|
|
1648
|
+
base, n = value or "option", 2
|
|
1649
|
+
while value in used or not value:
|
|
1650
|
+
value, n = f"{base}_{n}", n + 1
|
|
1651
|
+
used.add(value)
|
|
1652
|
+
pair[1] = value
|
|
1653
|
+
out["choices"] = [{"title": t, "value": v} for t, v in choices]
|
|
1654
|
+
self.choice_values[out["id"]] = out["choices"]
|
|
1655
|
+
display = token(f.get("display") or "")
|
|
1656
|
+
if display in {"radio", "expanded", "buttons", "list", "checkbox", "checkboxes", "check", "radiobuttons", "radiogroup", "segmented"}:
|
|
1657
|
+
expanded = True
|
|
1658
|
+
elif display in {"dropdown", "compact", "select", "menu", "combo", "combobox", "filtered", "picker"}:
|
|
1659
|
+
expanded = multiple and len(choices) <= 12
|
|
1660
|
+
else:
|
|
1661
|
+
expanded = (multiple and len(choices) <= 12) or (len(choices) <= 4 and max(len(t) for t, _ in choices) <= 60)
|
|
1662
|
+
out.update(isMultiSelect=multiple, style="expanded" if expanded else "compact", wrap=True)
|
|
1663
|
+
placeholder = clean_text(f.get("placeholder"))
|
|
1664
|
+
if placeholder and not expanded:
|
|
1665
|
+
out["placeholder"] = placeholder
|
|
1666
|
+
if f.get("value") not in (None, "", []):
|
|
1667
|
+
selected = self.match_choices(f["value"], choices, multiple, node.path)
|
|
1668
|
+
if selected:
|
|
1669
|
+
out["value"] = ",".join(selected) if multiple else selected[0]
|
|
1670
|
+
return [self.input_spacing(out)]
|
|
1671
|
+
|
|
1672
|
+
def match_choices(self, raw, choices, multiple: bool, path: str) -> list[str]:
|
|
1673
|
+
def find(wanted) -> str | None:
|
|
1674
|
+
text = display_text(wanted).strip()
|
|
1675
|
+
for test in (lambda t, v: v == text, lambda t, v: t == text,
|
|
1676
|
+
lambda t, v: v.casefold() == text.casefold() or t.casefold() == text.casefold(),
|
|
1677
|
+
lambda t, v: token(v) == token(text) or token(t) == token(text)):
|
|
1678
|
+
hits = [v for t, v in choices if test(t, v)]
|
|
1679
|
+
if len(hits) == 1:
|
|
1680
|
+
return hits[0]
|
|
1681
|
+
hits = [v for t, v in choices if token(text) and (token(t).startswith(token(text)) or token(text) in token(t))]
|
|
1682
|
+
return hits[0] if len(hits) == 1 else None
|
|
1683
|
+
|
|
1684
|
+
if isinstance(raw, list):
|
|
1685
|
+
wanted = raw
|
|
1686
|
+
elif multiple and isinstance(raw, str) and find(raw) is None:
|
|
1687
|
+
wanted = [p for p in re.split(r"\s*[,;|]\s*", raw) if p]
|
|
1688
|
+
else:
|
|
1689
|
+
wanted = [raw]
|
|
1690
|
+
found = []
|
|
1691
|
+
for w in wanted:
|
|
1692
|
+
value = find(w)
|
|
1693
|
+
if value is None:
|
|
1694
|
+
self.ctx.warn(path + ".value", "unmatched_default", f"Default '{display_text(w)}' does not match any option and was left out.")
|
|
1695
|
+
elif value not in found:
|
|
1696
|
+
found.append(value)
|
|
1697
|
+
if value != display_text(w):
|
|
1698
|
+
self.ctx.fixed(path + ".value", "default_matched_to_option", source=display_text(w), target=value)
|
|
1699
|
+
return found if multiple else found[:1]
|
|
1700
|
+
|
|
1701
|
+
def r_checkbox(self, node: Node, depth: int) -> list[dict]:
|
|
1702
|
+
f = node.f
|
|
1703
|
+
base = self.input_base(node, "Yes")
|
|
1704
|
+
on = submit_text(f["on_value"]) if f.get("on_value") not in (None, "") else "true"
|
|
1705
|
+
off = submit_text(f["off_value"]) if f.get("off_value") not in (None, "") else "false"
|
|
1706
|
+
if on == off:
|
|
1707
|
+
self.ctx.warn(node.path, "invalid_value", "on_value and off_value were equal; true/false are submitted instead.")
|
|
1708
|
+
on, off = "true", "false"
|
|
1709
|
+
out = {"type": "Input.Toggle", "id": base["id"], "title": base["label"], "valueOn": on, "valueOff": off, "wrap": True}
|
|
1710
|
+
for key in ("isRequired", "errorMessage"):
|
|
1711
|
+
if key in base:
|
|
1712
|
+
out[key] = base[key]
|
|
1713
|
+
if f.get("value") is not None:
|
|
1714
|
+
raw = f["value"]
|
|
1715
|
+
checked = raw == on if isinstance(raw, str) and raw in (on, off) else as_bool(raw, None)
|
|
1716
|
+
if checked is None:
|
|
1717
|
+
self.ctx.warn(node.path + ".value", "invalid_value", "Checkbox default was not true/false and was left out.")
|
|
1718
|
+
else:
|
|
1719
|
+
out["value"] = on if checked else off
|
|
1720
|
+
return [self.input_spacing(out)]
|
|
1721
|
+
|
|
1722
|
+
def r_rating_input(self, node: Node, depth: int) -> list[dict]:
|
|
1723
|
+
f = node.f
|
|
1724
|
+
out = self.input_base(node, "Rating")
|
|
1725
|
+
maximum = as_number(f.get("max")) or 5
|
|
1726
|
+
maximum = int(max(1, min(10, round(maximum))))
|
|
1727
|
+
half = as_bool(f.get("half_steps"), False)
|
|
1728
|
+
step = 0.5 if half else 1
|
|
1729
|
+
value = as_number(f.get("value")) if f.get("value") is not None else None
|
|
1730
|
+
if value is not None:
|
|
1731
|
+
snapped = round(value / step) * step
|
|
1732
|
+
if not 0 < snapped <= maximum:
|
|
1733
|
+
self.ctx.warn(node.path + ".value", "invalid_value", "Initial rating was outside the allowed range and was left out.")
|
|
1734
|
+
value = None
|
|
1735
|
+
elif snapped != value:
|
|
1736
|
+
self.ctx.fixed(node.path + ".value", "rating_snapped_to_step")
|
|
1737
|
+
value = snapped
|
|
1738
|
+
count = int(maximum / step)
|
|
1739
|
+
titles = [f"{'★' * int(i * step)}{'☆' * (maximum - int(i * step))} {i * step:g}" if not half else f"{i * step:g} / {maximum}"
|
|
1740
|
+
for i in range(1, count + 1)]
|
|
1741
|
+
fallback = {"type": "Input.ChoiceSet", **out, "style": "compact",
|
|
1742
|
+
"choices": [{"title": t, "value": f"{i * step:g}"} for i, t in zip(range(1, count + 1), titles)]}
|
|
1743
|
+
if value is not None:
|
|
1744
|
+
fallback["value"] = f"{value:g}"
|
|
1745
|
+
self.choice_values[out["id"]] = fallback["choices"]
|
|
1746
|
+
if maximum <= 5:
|
|
1747
|
+
native = {"type": "Input.Rating", **out, "max": maximum, "allowHalfSteps": half}
|
|
1748
|
+
if value is not None:
|
|
1749
|
+
native["value"] = value
|
|
1750
|
+
element = self.extension(native, fallback, node.path)
|
|
1751
|
+
else:
|
|
1752
|
+
element = fallback
|
|
1753
|
+
if element is not fallback and "fallback" in element:
|
|
1754
|
+
self.ctx.warn(node.path, "rating_submission_type", "Native rating submits a number; the fallback submits the same rating as a string.")
|
|
1755
|
+
return [self.input_spacing(element)]
|
|
1756
|
+
|
|
1757
|
+
def r_divider(self, node: Node, depth: int) -> list[dict]:
|
|
1758
|
+
return []
|
|
1759
|
+
|
|
1760
|
+
def r_column(self, node: Node, depth: int) -> list[dict]:
|
|
1761
|
+
return self.r_section(node, depth)
|
|
1762
|
+
|
|
1763
|
+
def r_document(self, node: Node, depth: int) -> list[dict]:
|
|
1764
|
+
return self.r_section(node, depth)
|
|
1765
|
+
|
|
1766
|
+
# ------------------------------------------------------------ assembly
|
|
1767
|
+
|
|
1768
|
+
def reserve(self, value, depth=0):
|
|
1769
|
+
"""Reserve explicit IDs anywhere in the request so generated IDs never take them."""
|
|
1770
|
+
if depth > self.config.max_depth + 8:
|
|
1771
|
+
return
|
|
1772
|
+
if isinstance(value, dict):
|
|
1773
|
+
for k, v in value.items():
|
|
1774
|
+
if token(k) in {"id", "inputid", "fieldid"} and isinstance(v, str) and as_id(v):
|
|
1775
|
+
self.reserved_ids.add(as_id(v))
|
|
1776
|
+
self.reserve(v, depth + 1)
|
|
1777
|
+
elif isinstance(value, list):
|
|
1778
|
+
for v in value:
|
|
1779
|
+
self.reserve(v, depth + 1)
|
|
1780
|
+
|
|
1781
|
+
def header(self, doc: dict) -> list[dict]:
|
|
1782
|
+
title = clean_text(doc.get("title"))
|
|
1783
|
+
subtitle = clean_text(doc.get("subtitle"))
|
|
1784
|
+
status = doc.get("status")
|
|
1785
|
+
badge = None
|
|
1786
|
+
if status not in (None, ""):
|
|
1787
|
+
if isinstance(status, dict):
|
|
1788
|
+
node = self.canonicalize({"type": "badge", **status}, "$.status")
|
|
1789
|
+
badge = self.r_badge(node[0], 0) if node else None
|
|
1790
|
+
badge = badge[0] if badge else None
|
|
1791
|
+
elif clean_text(status):
|
|
1792
|
+
badge = self.badge_element(clean_text(status), tone_of(status), "$.status")
|
|
1793
|
+
blocks = ([theme.card_title(title)] if title else []) + ([theme.subtitle(subtitle)] if subtitle else [])
|
|
1794
|
+
if badge and blocks:
|
|
1795
|
+
return [theme.row(theme.column(blocks), theme.column([badge], "auto", verticalContentAlignment="Top"))]
|
|
1796
|
+
return blocks + ([badge] if badge else [])
|
|
1797
|
+
|
|
1798
|
+
def has_submit(self, node) -> bool:
|
|
1799
|
+
if isinstance(node, dict):
|
|
1800
|
+
return node.get("type") == "Action.Submit" or any(self.has_submit(v) for k, v in node.items() if k not in {"data", "fallback"})
|
|
1801
|
+
return isinstance(node, list) and any(self.has_submit(v) for v in node)
|
|
1802
|
+
|
|
1803
|
+
def clean_submit_data(self, node, path="$card"):
|
|
1804
|
+
if isinstance(node, dict):
|
|
1805
|
+
if node.get("type") == "Action.Submit" and isinstance(node.get("data"), dict):
|
|
1806
|
+
clash = set(node["data"]) & self.used_ids
|
|
1807
|
+
if clash:
|
|
1808
|
+
node["data"] = {k: v for k, v in node["data"].items() if k not in clash}
|
|
1809
|
+
self.ctx.warn(path, "submit_data_collision", f"Button data keys {sorted(clash)} match input ids and were removed so they cannot overwrite user answers.")
|
|
1810
|
+
for k, v in node.items():
|
|
1811
|
+
if k != "data":
|
|
1812
|
+
self.clean_submit_data(v, f"{path}.{k}")
|
|
1813
|
+
elif isinstance(node, list):
|
|
1814
|
+
for i, v in enumerate(node):
|
|
1815
|
+
self.clean_submit_data(v, f"{path}[{i}]")
|
|
1816
|
+
|
|
1817
|
+
def width(self) -> dict:
|
|
1818
|
+
target = self.config.available_width_px
|
|
1819
|
+
raw = max(self.floor_percent, math.ceil(self.min_width / target * 100))
|
|
1820
|
+
overflow = self.force_overflow or raw > 100
|
|
1821
|
+
percent = max(110, math.ceil(raw / 10) * 10) if overflow else next(n for n in (50, 60, 80, 100) if n >= raw)
|
|
1822
|
+
return {"suggestedWidth": "100%+" if overflow else f"{percent}%", "widthPercent": percent,
|
|
1823
|
+
"estimatedMinWidthPx": self.min_width, "referenceWidthPx": target,
|
|
1824
|
+
"overflow": "scroll" if overflow else "none", "isEstimate": True, "reasons": self.width_reasons}
|
|
1825
|
+
|
|
1826
|
+
def build(self, request) -> dict:
|
|
1827
|
+
raw = load_request(request, self.ctx)
|
|
1828
|
+
doc = self.document(raw)
|
|
1829
|
+
self.reserve(doc["elements"])
|
|
1830
|
+
body = self.header(doc)
|
|
1831
|
+
content = self.render_list(doc["elements"], "$.elements", 0)
|
|
1832
|
+
if body and content:
|
|
1833
|
+
content[0] = {**content[0], "spacing": "Medium"}
|
|
1834
|
+
body += content
|
|
1835
|
+
explicit_buttons = "buttons" in doc
|
|
1836
|
+
actions = self.buttons(doc.get("buttons") or [], "$.buttons")
|
|
1837
|
+
submit_label = clean_text(doc.get("submit_label"))
|
|
1838
|
+
if submit_label:
|
|
1839
|
+
defaults = [a for a in actions if a["type"] == "Action.Submit" and a["title"] == "Submit"]
|
|
1840
|
+
if len(defaults) == 1:
|
|
1841
|
+
defaults[0]["title"] = submit_label
|
|
1842
|
+
elif not any(a["type"] == "Action.Submit" for a in actions) and not self.has_submit(body):
|
|
1843
|
+
actions.insert(0, {"type": "Action.Submit", "title": submit_label, "style": "positive"})
|
|
1844
|
+
else:
|
|
1845
|
+
self.ctx.fixed("$.submit_label", "submit_label_ignored_buttons_given")
|
|
1846
|
+
elif self.input_ids and not explicit_buttons and not self.has_submit(body) and not any(a["type"] == "Action.Submit" for a in actions):
|
|
1847
|
+
actions.insert(0, {"type": "Action.Submit", "title": "Submit", "style": "positive"})
|
|
1848
|
+
if not body:
|
|
1849
|
+
raise CardError("$.elements", 'Nothing to display. Send elements such as '
|
|
1850
|
+
'{"title": "Summary", "elements": [{"type": "text", "text": "..."}]}.', "empty_card")
|
|
1851
|
+
card = {"type": "AdaptiveCard", "$schema": "https://adaptivecards.io/schemas/adaptive-card.json", "version": "1.5", "body": body}
|
|
1852
|
+
if len(actions) > 5:
|
|
1853
|
+
body.extend(theme.spaced({"type": "ActionSet", "actions": actions[i:i + 5]}, "Medium") for i in range(0, len(actions), 5))
|
|
1854
|
+
elif actions:
|
|
1855
|
+
card["actions"] = actions
|
|
1856
|
+
self.clean_submit_data(card)
|
|
1857
|
+
if len(json.dumps(card, ensure_ascii=False).encode("utf-8")) > self.config.max_output_bytes:
|
|
1858
|
+
raise CardError("$", "Generated card exceeds the output size limit; split the content into several cards.", "limit_exceeded")
|
|
1859
|
+
layout = self.width()
|
|
1860
|
+
warnings = self.ctx.warnings
|
|
1861
|
+
message = "Card built and ready to display. Do not repeat its contents or call the tool again for the same card."
|
|
1862
|
+
if warnings:
|
|
1863
|
+
notes = "; ".join(f"{w['path']}: {w['message']}" for w in warnings[:3])
|
|
1864
|
+
message = (f"Card built and ready to display, with {len(warnings)} note(s): {notes}"
|
|
1865
|
+
" The card is usable as is; call again only if important content was left out.")
|
|
1866
|
+
return {"status": "successful", "suggestedWidth": layout.pop("suggestedWidth"), "response": card, "message": message,
|
|
1867
|
+
"metadata": {"profile": self.config.profile, "schemaVersion": "1.5", "inputIds": self.input_ids,
|
|
1868
|
+
"choiceValues": self.choice_values, "layout": layout, "warnings": warnings,
|
|
1869
|
+
"normalizations": self.ctx.normalizations}}
|
|
1870
|
+
|
|
1871
|
+
|
|
1872
|
+
NATIVE_CHARTS = {"pie": "Chart.Pie", "donut": "Chart.Donut", "bar": "Chart.VerticalBar", "horizontal_bar": "Chart.HorizontalBar",
|
|
1873
|
+
"grouped_bar": "Chart.VerticalBar.Grouped", "stacked_bar": "Chart.VerticalBar.Grouped",
|
|
1874
|
+
"stacked_horizontal_bar": "Chart.HorizontalBar.Stacked", "line": "Chart.Line", "gauge": "Chart.Gauge"}
|
|
1875
|
+
|
|
1876
|
+
|
|
1877
|
+
def render_card(request, *, config: BuilderConfig | None = None) -> dict:
|
|
1878
|
+
"""Accept an element, list, document, or JSON string and return the tool envelope.
|
|
1879
|
+
|
|
1880
|
+
Invalid or unusual input is repaired where it can be done without inventing
|
|
1881
|
+
facts; the repairs are listed in metadata. status="error" is returned only
|
|
1882
|
+
for unreadable requests, empty results and safety limits.
|
|
1883
|
+
"""
|
|
1884
|
+
builder = CardBuilder(config or BuilderConfig())
|
|
1885
|
+
try:
|
|
1886
|
+
return builder.build(request)
|
|
1887
|
+
except CardError as exc:
|
|
1888
|
+
detail = exc.detail
|
|
1889
|
+
message = f"{detail['path']}: {detail['message']}"
|
|
1890
|
+
return {"status": "error", "suggestedWidth": None, "response": message, "message": message + " Fix this and call the tool once more.",
|
|
1891
|
+
"errors": [detail], "metadata": {"warnings": builder.ctx.warnings, "normalizations": builder.ctx.normalizations}}
|