Spaces:
Sleeping
Sleeping
File size: 9,286 Bytes
45dc2a1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | """
Per-recipient message rendering: {Field} placeholders, {Field|fallback}, list fields as bullets
or an inline "A, B and C", and {if Field has "x"}...{else}...{/if} conditional blocks.
One engine serves both the real send and the Campaigns page's preview, so what is previewed is
what is sent. Row values are HTML-escaped when rendering HTML (a value containing "<" must not
become markup in someone's inbox); the subject is rendered as plain text and is not escaped.
A template that cannot be rendered safely (unclosed {if}, stray {else}, a condition that does
not parse) is reported by validate_template so a send is refused up front instead of going out
half-rendered to every recipient.
"""
from __future__ import annotations
import html as html_lib
import re
from dataclasses import dataclass, field
LIST_SPLIT_MIXED = re.compile(r"[;,|\n]")
_BLOCK_TOKEN = re.compile(r"\{(?:if\s+([^{}]*)|(else)|(/if))\}")
_PLACEHOLDER = re.compile(r"\{([A-Za-z0-9_][A-Za-z0-9_ ]*?)(?:\|([^{}]*))?\}")
_PARAGRAPH_TOKEN = re.compile(r"<p(?:\s[^>]*)?>\s*(\{(?:if\s[^{}]*|else|/if)\})\s*</p>", re.IGNORECASE)
_PARAGRAPH_LIST = re.compile(r"<p(?:\s[^>]*)?>\s*\{([A-Za-z0-9_][A-Za-z0-9_ ]*?)(?:\|(bullets|inline))?\}\s*</p>", re.IGNORECASE)
_CONDITION = re.compile(
r"""^(?P<field>[A-Za-z0-9_][A-Za-z0-9_ ]*?)\s+
(?P<op>is\ not\ empty|is\ empty|is\ true|is\ false|does\ not\ contain|is\ not|is|has\ not|lacks|has|contains)
(?:\s+["“”'](?P<value>.*?)["“”'])?\s*$""",
re.VERBOSE | re.IGNORECASE,
)
_NEEDS_VALUE = {"is", "is not", "has", "has not", "lacks", "contains", "does not contain"}
_UL_STYLE = "margin:0 0 14px;padding-left:22px;"
_LI_STYLE = "margin:0 0 4px;"
class TemplateError(ValueError):
pass
@dataclass
class _Cond:
field: str
op: str
value: str
@dataclass
class _If:
cond: _Cond
then: list = field(default_factory=list)
other: list = field(default_factory=list)
def split_items(text: str, delimiter: str | None) -> list[str]:
"""Splits a list-style cell into trimmed, non-empty items. delimiter "mixed" accepts any of
; , | or a newline in the same cell (people are inconsistent), anything else is literal."""
if not text:
return []
if delimiter == "mixed":
parts = LIST_SPLIT_MIXED.split(text)
else:
parts = text.split(delimiter or ";")
return [p.strip() for p in parts if p.strip()]
def _parse_condition(raw: str) -> _Cond:
m = _CONDITION.match(html_lib.unescape(raw).strip())
if not m:
raise TemplateError(f'Could not read the condition "{{if {raw.strip()}}}"')
op = re.sub(r"\s+", " ", m.group("op").lower())
value = m.group("value")
if op in _NEEDS_VALUE and value is None:
raise TemplateError(f'The condition "{{if {raw.strip()}}}" needs a value in quotes')
return _Cond(m.group("field").strip(), op, (value or "").strip())
def _parse(template: str) -> list:
"""Template text -> tree of str and _If nodes. Raises TemplateError on unbalanced blocks."""
root: list = []
stack: list[tuple[_If, bool]] = [] # (node, currently_in_else)
current = root
pos = 0
for m in _BLOCK_TOKEN.finditer(template):
if m.start() > pos:
current.append(template[pos:m.start()])
pos = m.end()
if m.group(1) is not None:
node = _If(_parse_condition(m.group(1)))
current.append(node)
stack.append((node, False))
current = node.then
elif m.group(2):
if not stack or stack[-1][1]:
raise TemplateError("Found {else} without a matching {if ...}")
node, _ = stack[-1]
stack[-1] = (node, True)
current = node.other
else:
if not stack:
raise TemplateError("Found {/if} without a matching {if ...}")
stack.pop()
current = stack[-1][0].other if stack and stack[-1][1] else (stack[-1][0].then if stack else root)
if stack:
raise TemplateError("An {if ...} block is missing its {/if}")
if pos < len(template):
current.append(template[pos:])
return root
def _column_defs(columns: list[dict] | None) -> dict[str, dict]:
return {c["name"]: c for c in (columns or [])}
def _evaluate(cond: _Cond, row: dict, cols: dict[str, dict]) -> bool:
col = cols.get(cond.field)
if col is None and cols:
return False # a condition on a field the audience does not have never matches
text = str(row.get(cond.field, "") or "")
col_type = (col or {}).get("type", "text")
needle = cond.value.strip().lower()
op = cond.op
if op == "is empty":
return text.strip() == ""
if op == "is not empty":
return text.strip() != ""
if op in ("is true", "is false"):
truthy = text.strip().lower() in {"true", "yes", "y", "1"}
return truthy if op == "is true" else not truthy
if col_type == "tag":
items = {i.lower() for i in split_items(text, col.get("delimiter"))}
if op in ("has", "is"):
return needle in items
if op in ("lacks", "has not", "is not"):
return needle not in items
hay = text.strip().lower()
if op == "is":
return hay == needle
if op == "is not":
return hay != needle
if op in ("has", "lacks", "has not"):
# On a plain column "has" means the whole word/phrase appears, so "yes" is not
# matched by "yesterday".
found = re.search(r"(?<!\w)" + re.escape(needle) + r"(?!\w)", hay) is not None
return found if op == "has" else not found
if op == "contains":
return needle in hay
if op == "does not contain":
return needle not in hay
return False
def _inline_join(items: list[str]) -> str:
if len(items) <= 1:
return "".join(items)
return ", ".join(items[:-1]) + " and " + items[-1]
def _bullets(items: list[str], escape: bool) -> str:
esc = html_lib.escape if escape else (lambda s: s)
lis = "".join(f'<li style="{_LI_STYLE}">{esc(i)}</li>' for i in items)
return f'<ul style="{_UL_STYLE}">{lis}</ul>'
def _render_text(text: str, row: dict, cols: dict[str, dict], html: bool) -> str:
esc = html_lib.escape if html else (lambda s: s)
if html:
# A list field alone in its paragraph becomes a real bullet list (replacing the paragraph).
def paragraph_list(m: re.Match) -> str:
name, mode = m.group(1).strip(), (m.group(2) or "").lower()
col = cols.get(name)
if not col or col.get("type") != "tag" or mode == "inline":
return m.group(0)
items = split_items(str(row.get(name, "") or ""), col.get("delimiter"))
return _bullets(items, True) if items else ""
text = _PARAGRAPH_LIST.sub(paragraph_list, text)
def placeholder(m: re.Match) -> str:
name, option = m.group(1).strip(), m.group(2)
col = cols.get(name)
raw = str(row.get(name, "") or "")
if col and col.get("type") == "tag":
items = split_items(raw, col.get("delimiter"))
if items:
if option and option.lower() == "bullets" and html:
return _bullets(items, True)
return esc(_inline_join(items))
elif raw.strip():
return esc(raw)
if option and option.lower() not in ("bullets", "inline"):
return option # fallback text written by the author
return ""
return _PLACEHOLDER.sub(placeholder, text)
def _render(nodes: list, row: dict, cols: dict[str, dict], html: bool) -> str:
out = []
for node in nodes:
if isinstance(node, str):
out.append(_render_text(node, row, cols, html))
else:
branch = node.then if _evaluate(node.cond, row, cols) else node.other
out.append(_render(branch, row, cols, html))
return "".join(out)
def _prepare(template: str) -> str:
# {if}/{else}/{/if} typed on their own line become their own <p>; drop that wrapper so the
# block leaves no empty paragraphs behind.
return _PARAGRAPH_TOKEN.sub(r"\1", template)
def validate_template(template: str, columns: list[dict] | None = None) -> list[str]:
"""Problems that would make the template render wrongly. Empty list means safe to send."""
problems: list[str] = []
try:
tree = _parse(_prepare(template))
except TemplateError as exc:
return [str(exc)]
names = {c["name"] for c in (columns or [])}
if names:
def walk(nodes: list) -> None:
for n in nodes:
if isinstance(n, _If):
if n.cond.field not in names:
problems.append(f'A condition uses the field "{n.cond.field}", which this audience does not have')
walk(n.then)
walk(n.other)
walk(tree)
return list(dict.fromkeys(problems))
def render(template: str, row: dict, columns: list[dict] | None = None, html: bool = True) -> str:
"""Renders one recipient's copy. Raises TemplateError if the template is malformed."""
tree = _parse(_prepare(template) if html else template)
return _render(tree, row, _column_defs(columns), html)
|