formwork-sp 0.7.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.
- formwork/__init__.py +7 -0
- formwork/bundle.py +91 -0
- formwork/canvas.py +288 -0
- formwork/catalogue.py +708 -0
- formwork/cli.py +532 -0
- formwork/dsl.py +792 -0
- formwork/findings.py +412 -0
- formwork/generator.py +101 -0
- formwork/multipage.py +419 -0
- formwork/preview.py +100 -0
- formwork/provenance.py +217 -0
- formwork/refs.py +282 -0
- formwork/sections.py +152 -0
- formwork/spec_templates.py +149 -0
- formwork/templates/_prelude.js.j2 +227 -0
- formwork/templates/_probe_legs.js.j2 +598 -0
- formwork/templates/_probe_pagestate.js.j2 +263 -0
- formwork/templates/_probe_setup.js.j2 +71 -0
- formwork/templates/apply.js.j2 +208 -0
- formwork/templates/discover.js.j2 +182 -0
- formwork/templates/extract.js.j2 +59 -0
- formwork/templates/findprobe.js.j2 +423 -0
- formwork/templates/preview.html.j2 +60 -0
- formwork/templating.py +53 -0
- formwork/text.py +263 -0
- formwork_sp-0.7.0.dist-info/METADATA +633 -0
- formwork_sp-0.7.0.dist-info/RECORD +31 -0
- formwork_sp-0.7.0.dist-info/WHEEL +5 -0
- formwork_sp-0.7.0.dist-info/entry_points.txt +2 -0
- formwork_sp-0.7.0.dist-info/licenses/LICENSE +21 -0
- formwork_sp-0.7.0.dist-info/top_level.txt +1 -0
formwork/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Formwork: declarative SharePoint modern pages, and a page copier."""
|
|
2
|
+
|
|
3
|
+
#: The single version literal for the package. pyproject.toml reads it via
|
|
4
|
+
#: tool.setuptools.dynamic, and release-please updates this line (the
|
|
5
|
+
#: x-release-please-version annotation); the paste-in goldens embed it.
|
|
6
|
+
__version__ = "0.7.0" # x-release-please-version
|
|
7
|
+
BUNDLE_SCHEMA = "formwork.bundle/v1"
|
formwork/bundle.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Extraction bundle parsing and validation.
|
|
2
|
+
|
|
3
|
+
A bundle is the JSON file the extract paste-in downloads from the source
|
|
4
|
+
page: schema envelope, source identity, page fields and section plan.
|
|
5
|
+
Everything downstream (processing, apply) reads the bundle through the
|
|
6
|
+
typed view built here. The page's web parts are not a bundle field: they
|
|
7
|
+
are read from the canvas itself (:mod:`formwork.refs`), which is the one
|
|
8
|
+
place they exist.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from formwork import BUNDLE_SCHEMA
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Source:
|
|
22
|
+
"""Identity of the page the bundle was extracted from."""
|
|
23
|
+
|
|
24
|
+
web_url: str
|
|
25
|
+
web_path: str
|
|
26
|
+
page_path: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Bundle:
|
|
31
|
+
"""Typed view over an extraction bundle."""
|
|
32
|
+
|
|
33
|
+
schema: str
|
|
34
|
+
extracted_at: str
|
|
35
|
+
source: Source
|
|
36
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
page: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
sections: list[dict[str, Any]] = field(default_factory=list)
|
|
39
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def canvas_html(self) -> str | None:
|
|
43
|
+
"""Classic page layout HTML, when the source page carries one."""
|
|
44
|
+
return self.page.get("CanvasContent1")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _require(data: dict[str, Any], key: str) -> None:
|
|
48
|
+
if key not in data:
|
|
49
|
+
raise ValueError(f"bundle is missing required key: {key}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parse_bundle(data: dict[str, Any] | str) -> Bundle:
|
|
53
|
+
"""Validate an extraction bundle and return its typed view.
|
|
54
|
+
|
|
55
|
+
Accepts an already-parsed dict or a raw JSON string (as downloaded
|
|
56
|
+
by the extract paste-in).
|
|
57
|
+
"""
|
|
58
|
+
if isinstance(data, str):
|
|
59
|
+
data = json.loads(data)
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
# ValueError, not TypeError: this is data content, and the tests pin it.
|
|
62
|
+
raise ValueError("bundle must be a JSON object")
|
|
63
|
+
|
|
64
|
+
_require(data, "schema")
|
|
65
|
+
if data["schema"] != BUNDLE_SCHEMA:
|
|
66
|
+
raise ValueError(
|
|
67
|
+
f"unsupported bundle schema: {data['schema']!r} (expected {BUNDLE_SCHEMA!r})"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
_require(data, "page")
|
|
71
|
+
source = data.get("source", {})
|
|
72
|
+
for key in ("webUrl", "webPath", "pagePath"):
|
|
73
|
+
if key not in source:
|
|
74
|
+
raise ValueError(f"bundle source is missing required key: {key}")
|
|
75
|
+
|
|
76
|
+
# Bundles from 0.3.0 and earlier carry a "webParts" key (always the empty
|
|
77
|
+
# list the extract paste-in wrote). It is ignored: the canvas is the
|
|
78
|
+
# source of the web parts (architecture review P1-2, 2026-09-06).
|
|
79
|
+
return Bundle(
|
|
80
|
+
schema=data["schema"],
|
|
81
|
+
extracted_at=data.get("extractedAt", ""),
|
|
82
|
+
source=Source(
|
|
83
|
+
web_url=source["webUrl"],
|
|
84
|
+
web_path=source["webPath"],
|
|
85
|
+
page_path=source["pagePath"],
|
|
86
|
+
),
|
|
87
|
+
meta=data.get("meta", {}),
|
|
88
|
+
page=data["page"],
|
|
89
|
+
sections=data.get("sections", []),
|
|
90
|
+
raw=data,
|
|
91
|
+
)
|
formwork/canvas.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""CanvasContent1 parsing and rendering.
|
|
2
|
+
|
|
3
|
+
SharePoint stores a modern page's layout in CanvasContent1 as HTML-encoded
|
|
4
|
+
canvas markup. Each control is a ``<div data-sp-canvascontrol ...>`` whose
|
|
5
|
+
``data-sp-controldata`` attribute holds entity-escaped JSON giving the
|
|
6
|
+
control's position; web-part controls additionally carry a
|
|
7
|
+
``data-sp-webpartdata`` attribute with the web part's id, title, properties
|
|
8
|
+
and serverProcessedContent.
|
|
9
|
+
|
|
10
|
+
Measured escaping style (CollabHome.aspx, shauntestazure sandbox, 2026-09-05):
|
|
11
|
+
``&`` -> ``&`` first, then ``< > "`` as named entities and ``{ } :`` as
|
|
12
|
+
numeric entities. Slashes and single quotes are NOT escaped. Because the
|
|
13
|
+
parser keeps every control's raw attribute text, rendering a parsed, untouched
|
|
14
|
+
canvas is byte-exact; only controls marked dirty are re-serialised.
|
|
15
|
+
|
|
16
|
+
This module is the one serialiser for canvas attributes (architecture
|
|
17
|
+
review 2026-09-06 P2-7): :func:`encode_attribute` is the JSON-then-escape
|
|
18
|
+
the renderer and the compiler both emit, :func:`decode_attribute` the
|
|
19
|
+
unescape-then-JSON the parser and the catalogue both read, and
|
|
20
|
+
:meth:`Control.web_part` / :meth:`Control.text` the two control shapes the
|
|
21
|
+
compiler writes. Nothing else in the package spells the attribute grammar.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import html
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
_CONTROL_OPEN = re.compile(r'<div data-sp-canvascontrol=""[^>]*>', re.DOTALL)
|
|
31
|
+
_CONTROLDATA = re.compile(r'data-sp-controldata="([^"]*)"')
|
|
32
|
+
_WEBPARTDATA = re.compile(r'data-sp-webpartdata="([^"]*)"')
|
|
33
|
+
|
|
34
|
+
#: One ``data-sp-htmlproperties`` child whose text is a verbatim copy of a
|
|
35
|
+
#: ``serverProcessedContent.searchablePlainTexts`` value. Measured on
|
|
36
|
+
#: CollabHome.aspx (shauntestazure sandbox, 2026-09-05): a list title persists
|
|
37
|
+
#: beside the webpartdata attribute as
|
|
38
|
+
#: ``<div data-sp-prop-name="listTitle" data-sp-searchableplaintext="true">``.
|
|
39
|
+
#: The mirrors SharePoint derives rather than copies — a ``links`` href is
|
|
40
|
+
#: server-relative where the JSON value is absolute, and a ``links`` URL or a
|
|
41
|
+
#: ``componentDependencies`` GUID is re-spelled into an attribute — do not
|
|
42
|
+
#: match this shape and are never rewritten.
|
|
43
|
+
_MIRROR_TEXT = re.compile(
|
|
44
|
+
r'(<div data-sp-prop-name="(?P<name>[^"]+)" data-sp-searchableplaintext="true">)'
|
|
45
|
+
r'(?P<text>[^<]*)</div>'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _plain_texts(data: Any) -> dict[str, Any]:
|
|
50
|
+
"""The ``serverProcessedContent.searchablePlainTexts`` mapping, or empty."""
|
|
51
|
+
if not isinstance(data, dict):
|
|
52
|
+
return {}
|
|
53
|
+
processed = data.get("serverProcessedContent")
|
|
54
|
+
if not isinstance(processed, dict):
|
|
55
|
+
return {}
|
|
56
|
+
texts = processed.get("searchablePlainTexts")
|
|
57
|
+
return texts if isinstance(texts, dict) else {}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: How SharePoint spells a colon inside canvas markup: in every attribute it
|
|
61
|
+
#: escapes (below) and, measured 2026-09-06, in the inner HTML of a text
|
|
62
|
+
#: control's ``data-sp-rte`` child on the item MERGE path
|
|
63
|
+
#: (:func:`formwork.dsl.stored_text_html`; folded back on the read side by
|
|
64
|
+
#: :mod:`formwork.catalogue`).
|
|
65
|
+
COLON_ENTITY = ":"
|
|
66
|
+
|
|
67
|
+
#: The opening of every canvas control the compiler writes: the shape the
|
|
68
|
+
#: discover probe sends and the live canvas stores (data version 1.0).
|
|
69
|
+
_CONTROL_OPEN_PREFIX = '<div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" '
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def escape_attribute(value: str) -> str:
|
|
73
|
+
"""Escape a JSON string the way SharePoint escapes canvas attributes."""
|
|
74
|
+
return (
|
|
75
|
+
value.replace("&", "&")
|
|
76
|
+
.replace("<", "<")
|
|
77
|
+
.replace(">", ">")
|
|
78
|
+
.replace('"', """)
|
|
79
|
+
.replace("{", "{")
|
|
80
|
+
.replace("}", "}")
|
|
81
|
+
.replace(":", COLON_ENTITY)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def encode_attribute(data: Any) -> str:
|
|
86
|
+
"""A controldata/webpartdata attribute value: compact JSON, then escaped.
|
|
87
|
+
|
|
88
|
+
Non-ASCII stays literal (``ensure_ascii=False``): the live canvas stores
|
|
89
|
+
a curly apostrophe as the character, not ``\\u2019``, and the attribute
|
|
90
|
+
is entity-escaped anyway.
|
|
91
|
+
"""
|
|
92
|
+
return escape_attribute(json.dumps(data, separators=(",", ":"), ensure_ascii=False))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def decode_attribute(raw: str) -> Any:
|
|
96
|
+
"""The JSON value of a controldata/webpartdata attribute's raw text.
|
|
97
|
+
|
|
98
|
+
The inverse of :func:`encode_attribute` and of SharePoint's own
|
|
99
|
+
escaping; the same unescape-then-parse the discover probe does with
|
|
100
|
+
DOMParser and ``JSON.parse``. Raises ``ValueError`` (``JSONDecodeError``)
|
|
101
|
+
for an attribute that does not hold JSON.
|
|
102
|
+
"""
|
|
103
|
+
return json.loads(html.unescape(raw))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass
|
|
107
|
+
class Control:
|
|
108
|
+
"""One canvas control, holding both its raw and decoded forms."""
|
|
109
|
+
|
|
110
|
+
open_tag: str
|
|
111
|
+
control_data: dict[str, Any]
|
|
112
|
+
web_part_data: dict[str, Any] | None
|
|
113
|
+
controldata_raw: str
|
|
114
|
+
webpartdata_raw: str | None
|
|
115
|
+
body: str = ""
|
|
116
|
+
dirty: bool = field(default=False, repr=False)
|
|
117
|
+
# Mirror field names a render could not sync because the new value needs
|
|
118
|
+
# HTML escaping the mirror format cannot carry verbatim (review
|
|
119
|
+
# 2026-09-11 P2-2). Empty when every changed value was mirrored or the
|
|
120
|
+
# control has no mirror.
|
|
121
|
+
unsynced_mirrors: list[str] = field(default_factory=list, repr=False)
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def web_part(cls, control_data: dict[str, Any], web_part_data: dict[str, Any]) -> "Control":
|
|
125
|
+
"""A web-part control: the control div wrapping a webpartdata child.
|
|
126
|
+
|
|
127
|
+
The same shape the live canvas writes and the discover probe sends
|
|
128
|
+
(discover.js.j2 step 3): the web-part child carries an empty
|
|
129
|
+
``data-sp-htmlproperties``, then the control div closes. The raw
|
|
130
|
+
attributes are the encoded dicts, so the control renders byte-for-
|
|
131
|
+
byte as a parsed-then-dirtied one would, without being dirty.
|
|
132
|
+
"""
|
|
133
|
+
controldata = encode_attribute(control_data)
|
|
134
|
+
webpartdata = encode_attribute(web_part_data)
|
|
135
|
+
# The web-part child div, then the close of the control div itself.
|
|
136
|
+
body = f'<div data-sp-webpartdata="{webpartdata}" data-sp-htmlproperties=""></div></div>'
|
|
137
|
+
return cls(
|
|
138
|
+
open_tag=f'{_CONTROL_OPEN_PREFIX}data-sp-controldata="{controldata}">',
|
|
139
|
+
control_data=control_data,
|
|
140
|
+
web_part_data=web_part_data,
|
|
141
|
+
controldata_raw=controldata,
|
|
142
|
+
webpartdata_raw=webpartdata,
|
|
143
|
+
body=body,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def text(cls, control_data: dict[str, Any], inner_html: str) -> "Control":
|
|
148
|
+
"""A text control: the control div wrapping a ``data-sp-rte`` child.
|
|
149
|
+
|
|
150
|
+
``inner_html`` is stored as given; the caller spells it the way
|
|
151
|
+
SharePoint stores it (:func:`formwork.dsl.stored_text_html`).
|
|
152
|
+
"""
|
|
153
|
+
controldata = encode_attribute(control_data)
|
|
154
|
+
return cls(
|
|
155
|
+
open_tag=f'{_CONTROL_OPEN_PREFIX}data-sp-controldata="{controldata}">',
|
|
156
|
+
control_data=control_data,
|
|
157
|
+
web_part_data=None,
|
|
158
|
+
controldata_raw=controldata,
|
|
159
|
+
webpartdata_raw=None,
|
|
160
|
+
body=f'<div data-sp-rte="">{inner_html}</div></div>',
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def web_part_title(self) -> str | None:
|
|
165
|
+
if self.web_part_data is None:
|
|
166
|
+
return None
|
|
167
|
+
title = self.web_part_data.get("title")
|
|
168
|
+
return title if isinstance(title, str) else None
|
|
169
|
+
|
|
170
|
+
def mark_dirty(self) -> None:
|
|
171
|
+
self.dirty = True
|
|
172
|
+
|
|
173
|
+
def render(self) -> str:
|
|
174
|
+
if not self.dirty:
|
|
175
|
+
return self.open_tag + self.body
|
|
176
|
+
# The controldata attribute sits on the control's own tag; the
|
|
177
|
+
# webpartdata attribute sits on a child div. Substitute over the
|
|
178
|
+
# whole block so both are covered.
|
|
179
|
+
#
|
|
180
|
+
# The replacement is a CALLABLE, not an f-string: re.sub parses
|
|
181
|
+
# backslash escapes in a string template, so re-escaped JSON
|
|
182
|
+
# (json.dumps emits \uXXXX for non-ASCII, \n, \\ …) either crashed
|
|
183
|
+
# the render with re.error: bad escape or silently corrupted the
|
|
184
|
+
# attribute bytes (found by the 2026-09-06 P1-fix re-review; a
|
|
185
|
+
# curly apostrophe in a Quick links title was enough).
|
|
186
|
+
full = self.open_tag + self.body
|
|
187
|
+
controldata = encode_attribute(self.control_data)
|
|
188
|
+
full = _CONTROLDATA.sub(lambda _m: f'data-sp-controldata="{controldata}"', full, count=1)
|
|
189
|
+
if self.web_part_data is not None:
|
|
190
|
+
webpartdata = encode_attribute(self.web_part_data)
|
|
191
|
+
full = _WEBPARTDATA.sub(
|
|
192
|
+
lambda _m: f'data-sp-webpartdata="{webpartdata}"', full, count=1
|
|
193
|
+
)
|
|
194
|
+
full = self._sync_mirrors(full)
|
|
195
|
+
return full
|
|
196
|
+
|
|
197
|
+
def _sync_mirrors(self, full: str) -> str:
|
|
198
|
+
"""Rewrite the plain-text ``data-sp-htmlproperties`` mirrors this render changed.
|
|
199
|
+
|
|
200
|
+
SharePoint keeps a web part's values twice: in the ``data-sp-webpartdata``
|
|
201
|
+
attribute, and in a ``data-sp-htmlproperties`` child that mirrors them for
|
|
202
|
+
the page model (measured on CollabHome.aspx, 2026-09-05). :meth:`render`
|
|
203
|
+
rewrites the attribute; the mirror is inner content and would otherwise
|
|
204
|
+
keep the value the control was parsed with, so a page rewritten for
|
|
205
|
+
another site carried the source site's list title and hrefs (raised as P2
|
|
206
|
+
by the 2026-09-06 P1-fix re-review and left unactioned). A mirror is
|
|
207
|
+
rewritten only when it provably carried the old value verbatim and the new
|
|
208
|
+
value needs no HTML escaping, so a mirror whose spelling SharePoint
|
|
209
|
+
derives rather than copies (the ``links`` hrefs) and the byte-exact render
|
|
210
|
+
of an unchanged dirty control are both left exactly as they were.
|
|
211
|
+
"""
|
|
212
|
+
if self.webpartdata_raw is None:
|
|
213
|
+
return full
|
|
214
|
+
old_texts = _plain_texts(decode_attribute(self.webpartdata_raw))
|
|
215
|
+
changed = {
|
|
216
|
+
name: value
|
|
217
|
+
for name, value in _plain_texts(self.web_part_data).items()
|
|
218
|
+
if isinstance(value, str) and old_texts.get(name) != value
|
|
219
|
+
}
|
|
220
|
+
if not changed:
|
|
221
|
+
return full
|
|
222
|
+
|
|
223
|
+
def mirror(match: re.Match[str]) -> str:
|
|
224
|
+
value = changed.get(match.group("name"))
|
|
225
|
+
if value is None or match.group("text") != old_texts.get(match.group("name")):
|
|
226
|
+
return match.group(0)
|
|
227
|
+
if any(character in value for character in "&<>"):
|
|
228
|
+
# The mirror is plain text: writing this value verbatim would
|
|
229
|
+
# need escaping whose exact form SharePoint derives, not
|
|
230
|
+
# copies (review 2026-09-11 P2-2). Leaving it stale in
|
|
231
|
+
# silence is the defect #26 was filed for, so report it.
|
|
232
|
+
self.unsynced_mirrors.append(match.group("name"))
|
|
233
|
+
return match.group(0)
|
|
234
|
+
return f"{match.group(1)}{value}</div>"
|
|
235
|
+
|
|
236
|
+
return _MIRROR_TEXT.sub(mirror, full)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass
|
|
240
|
+
class Canvas:
|
|
241
|
+
"""A parsed CanvasContent1 string."""
|
|
242
|
+
|
|
243
|
+
controls: list[Control]
|
|
244
|
+
preamble: str = ""
|
|
245
|
+
|
|
246
|
+
@classmethod
|
|
247
|
+
def parse(cls, canvas_content: str) -> "Canvas":
|
|
248
|
+
controls: list[Control] = []
|
|
249
|
+
first = _CONTROL_OPEN.search(canvas_content)
|
|
250
|
+
preamble = canvas_content[: first.start()] if first else canvas_content
|
|
251
|
+
if first is None:
|
|
252
|
+
return cls(controls=[], preamble=preamble)
|
|
253
|
+
for match in _CONTROL_OPEN.finditer(canvas_content):
|
|
254
|
+
open_tag = match.group(0)
|
|
255
|
+
# Control extends to the next control's opening tag, or the end.
|
|
256
|
+
next_match = _CONTROL_OPEN.search(canvas_content, match.end())
|
|
257
|
+
end = next_match.start() if next_match else len(canvas_content)
|
|
258
|
+
body = canvas_content[match.end() : end]
|
|
259
|
+
block = open_tag + body
|
|
260
|
+
|
|
261
|
+
controldata_match = _CONTROLDATA.search(block)
|
|
262
|
+
webpartdata_match = _WEBPARTDATA.search(block)
|
|
263
|
+
control_data = (
|
|
264
|
+
decode_attribute(controldata_match.group(1)) if controldata_match else {}
|
|
265
|
+
)
|
|
266
|
+
web_part_data = (
|
|
267
|
+
decode_attribute(webpartdata_match.group(1)) if webpartdata_match else None
|
|
268
|
+
)
|
|
269
|
+
controls.append(
|
|
270
|
+
Control(
|
|
271
|
+
open_tag=open_tag,
|
|
272
|
+
control_data=control_data,
|
|
273
|
+
web_part_data=web_part_data,
|
|
274
|
+
controldata_raw=controldata_match.group(1) if controldata_match else "",
|
|
275
|
+
webpartdata_raw=webpartdata_match.group(1) if webpartdata_match else None,
|
|
276
|
+
body=body,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
return cls(controls=controls, preamble=preamble)
|
|
280
|
+
|
|
281
|
+
def render(self) -> str:
|
|
282
|
+
parts = [control.render() for control in self.controls]
|
|
283
|
+
if self.preamble:
|
|
284
|
+
parts.insert(0, self.preamble)
|
|
285
|
+
return "".join(parts)
|
|
286
|
+
|
|
287
|
+
def web_part_controls(self) -> list[Control]:
|
|
288
|
+
return [control for control in self.controls if control.web_part_data is not None]
|