"""
Jinja styling and style metadata extraction utilities.
"""
import re
from typing import Any, Dict, List, Optional
def extract_style_metadata(text: str) -> Dict[str, List[str]]:
"""
Extract style information from Jinja expressions in text.
Analyzes text for Jinja filter usage, colors, semantic styles, and formatting
to build a style metadata dictionary that can be stored with patterns and
reapplied to new cards.
Args:
text: Text potentially containing Jinja expressions like {{ 'text' | filter }}
Returns:
Dict with keys:
- jinja_filters: All filters used (e.g., ["success_text", "bold"])
- colors: Hex colors found (e.g., ["#00FF00"])
- semantic_styles: Semantic style names (e.g., ["success", "error"])
- formatting: Formatting filters (e.g., ["bold", "italic"])
"""
metadata: Dict[str, List[str]] = {
"jinja_filters": [],
"colors": [],
"semantic_styles": [],
"formatting": [],
}
if not text:
return metadata
# Extract Jinja filter chains: {{ 'text' | filter1 | filter2 }}
# Matches everything after the first pipe until closing braces
jinja_pattern = r"\{\{\s*[^|]+\|([^}]+)\}\}"
for match in re.finditer(jinja_pattern, text):
filters = [f.strip() for f in match.group(1).split("|")]
metadata["jinja_filters"].extend(filters)
# Extract colors: color('#HEX') or color('#hex') or color('success')
color_pattern = r"color\(['\"]?(#[0-9A-Fa-f]{6})['\"]?\)"
metadata["colors"] = re.findall(color_pattern, text)
# Map filters to semantic styles
SEMANTIC_MAP = {
"success_text": "success",
"error_text": "error",
"warning_text": "warning",
"info_text": "info",
}
for f in metadata["jinja_filters"]:
if f in SEMANTIC_MAP:
metadata["semantic_styles"].append(SEMANTIC_MAP[f])
# Extract formatting filters
FORMATTING = {"bold", "italic", "strike", "underline"}
metadata["formatting"] = [f for f in metadata["jinja_filters"] if f in FORMATTING]
# Deduplicate all lists
for key in metadata:
metadata[key] = list(set(metadata[key]))
return metadata
# =============================================================================
# SEMANTIC COLORS (imported from middleware for consistency)
# =============================================================================
# Default semantic colors for Google Chat HTML
# These can be imported from middleware.filters.styling_filters.SEMANTIC_COLORS
# but we provide defaults here for standalone usage
DEFAULT_SEMANTIC_COLORS = {
"success": "#34a853",
"error": "#ea4335",
"warning": "#fbbc05",
"info": "#4285f4",
"muted": "#9aa0a6",
"primary": "#1a73e8",
}
def apply_styles(
text: str,
styles: List[str],
semantic_colors: Dict[str, str] = None,
) -> str:
"""Apply Content DSL styles to text, generating Google Chat HTML.
Args:
text: The text to style
styles: List of style names (e.g., ["bold", "success"])
semantic_colors: Optional color mapping (uses defaults if not provided)
Returns:
HTML-formatted text for Google Chat
Example:
>>> apply_styles("Status", ["bold", "success"])
'<font color="#34a853"><b>Status</b></font>'
"""
if semantic_colors is None:
# Try to import from middleware, fall back to defaults
try:
from middleware.filters.styling_filters import SEMANTIC_COLORS
semantic_colors = SEMANTIC_COLORS
except ImportError:
semantic_colors = DEFAULT_SEMANTIC_COLORS
result = text
color = None
is_bold = False
is_italic = False
is_strike = False
for style in styles:
style_lower = style.lower()
# Check semantic colors
if style_lower in semantic_colors:
color = semantic_colors[style_lower]
elif style_lower == "bold":
is_bold = True
elif style_lower == "italic":
is_italic = True
elif style_lower in ("strike", "strikethrough"):
is_strike = True
elif style_lower in ("success", "ok", "active"):
color = semantic_colors.get("success", "#34a853")
elif style_lower in ("error", "danger", "failed"):
color = semantic_colors.get("error", "#ea4335")
elif style_lower in ("warning", "caution", "pending"):
color = semantic_colors.get("warning", "#fbbc05")
elif style_lower in ("info", "note", "notice"):
color = semantic_colors.get("info", "#4285f4")
# Apply formatting (innermost first)
if is_strike:
result = f"<s>{result}</s>"
if is_italic:
result = f"<i>{result}</i>"
if is_bold:
result = f"<b>{result}</b>"
if color:
result = f'<font color="{color}">{result}</font>'
return result
def style_keyword(
keyword: str,
style: str,
colors: Dict[str, str] = None,
) -> str:
"""Apply HTML styling to a keyword.
Args:
keyword: The word to style (e.g., "content", "layout")
style: Style name (e.g., "bold", "success", "warning", "info", "muted",
"bold_success", "bold_info")
colors: Optional color mapping (uses DEFAULT_SEMANTIC_COLORS if not provided)
Returns:
Styled HTML string
Example:
>>> style_keyword("content", "success")
'<font color="#34a853">content</font>'
>>> style_keyword("important", "bold_success")
'<b><font color="#34a853">important</font></b>'
"""
if colors is None:
colors = DEFAULT_SEMANTIC_COLORS
if style == "bold":
return f"<b>{keyword}</b>"
elif style == "success":
return f'<font color="{colors.get("success", "#34a853")}">{keyword}</font>'
elif style == "warning":
return f'<font color="{colors.get("warning", "#fbbc05")}">{keyword}</font>'
elif style == "info":
return f'<font color="{colors.get("info", "#4285f4")}">{keyword}</font>'
elif style == "muted":
return f'<font color="{colors.get("muted", "#9aa0a6")}">{keyword}</font>'
elif style == "bold_success":
return (
f'<b><font color="{colors.get("success", "#34a853")}">{keyword}</font></b>'
)
elif style == "bold_info":
return f'<b><font color="{colors.get("info", "#4285f4")}">{keyword}</font></b>'
else:
return f"<b>{keyword}</b>" # Default to bold
# =============================================================================
# STYLE APPLICATION (Content-Aware)
# =============================================================================
# Keywords that indicate success status
SUCCESS_KEYWORDS = frozenset(
[
"online",
"success",
"ok",
"active",
"running",
"healthy",
"ready",
"up",
"connected",
"enabled",
"available",
"complete",
"done",
]
)
# Keywords that indicate error status
ERROR_KEYWORDS = frozenset(
[
"error",
"fail",
"offline",
"down",
"unhealthy",
"critical",
"dead",
"disconnected",
"disabled",
"unavailable",
"stopped",
]
)
# Keywords that indicate warning status
WARNING_KEYWORDS = frozenset(
[
"warning",
"pending",
"slow",
"degraded",
"unknown",
"wait",
"timeout",
"retry",
"limited",
]
)
def has_explicit_styles(params: Dict[str, Any]) -> bool:
"""Check if params already have explicit Jinja styles defined.
Returns True if the 'text' field contains Jinja filter expressions,
indicating the user has explicitly specified styles for the content.
"""
text = params.get("text", "")
if isinstance(text, str) and "{{" in text and "|" in text:
return True
return False
def get_style_for_text(
text: str,
style_metadata: Dict[str, List[str]],
) -> Optional[str]:
"""Determine which style to apply based on text content and available styles.
If ANY semantic style is present in the pattern, we enable full semantic
styling based on content keywords. This allows a single "success" pattern
to also style "error" and "warning" content appropriately.
Args:
text: Text content to analyze
style_metadata: Style metadata with keys:
- semantic_styles: ["success", "error", "warning", "info"]
Returns:
The Jinja filter name (e.g., "success_text") or None if no match.
"""
if not text or not isinstance(text, str):
return None
semantic_styles = style_metadata.get("semantic_styles", [])
if not semantic_styles:
return None
text_lower = text.lower()
# If ANY semantic style is present, enable full semantic styling
enable_all_semantic = len(semantic_styles) > 0
# Check for success keywords
if enable_all_semantic or "success" in semantic_styles:
if any(word in text_lower for word in SUCCESS_KEYWORDS):
return "success_text"
# Check for error keywords
if enable_all_semantic or "error" in semantic_styles:
if any(word in text_lower for word in ERROR_KEYWORDS):
return "error_text"
# Check for warning keywords
if enable_all_semantic or "warning" in semantic_styles:
if any(word in text_lower for word in WARNING_KEYWORDS):
return "warning_text"
# Info is fallback only if explicitly in semantic_styles
if "info" in semantic_styles:
return "info_text"
return None
def apply_style_to_text(
text: str,
style_metadata: Dict[str, List[str]],
) -> str:
"""Apply style to a single text string based on its content.
Returns styled text with Jinja template if applicable, otherwise original text.
"""
# Skip if already has Jinja styling
if "{{" in text and "|" in text:
return text
style_to_apply = get_style_for_text(text, style_metadata)
if not style_to_apply:
return text
# Escape single quotes for Jinja template
escaped_text = text.replace("'", "\\'")
styled_text = f"{{{{ '{escaped_text}' | {style_to_apply} }}}}"
# Add formatting filters if present
formatting = style_metadata.get("formatting", [])
for fmt in formatting:
styled_text = styled_text[:-3] + f" | {fmt} }}}}"
return styled_text
def apply_pattern_styles(
params: Dict[str, Any],
style_metadata: Dict[str, List[str]],
) -> Dict[str, Any]:
"""Apply proven styles from a matched pattern to text fields.
Uses content-aware style selection to determine which semantic style
to apply based on the text content.
Args:
params: Card parameters containing text fields
style_metadata: Style metadata from matched pattern with keys:
- semantic_styles: ["success", "error", "warning", "info"]
- formatting: ["bold", "italic", "strike", "underline"]
Returns:
Updated params dict with Jinja styling applied to text field
"""
semantic_styles = style_metadata.get("semantic_styles", [])
formatting = style_metadata.get("formatting", [])
# If no styles to apply, return unchanged
if not semantic_styles and not formatting:
return params
# Get text content for content-aware style selection
text = params.get("text", "") or params.get("description", "")
if not text or not isinstance(text, str):
return params
style_to_apply = get_style_for_text(text, style_metadata)
# Apply style to text field
if style_to_apply and "text" in params:
# Escape single quotes in text for Jinja template
escaped_text = params["text"].replace("'", "\\'")
styled_text = f"{{{{ '{escaped_text}' | {style_to_apply} }}}}"
# Add formatting filters if present
for fmt in formatting:
styled_text = styled_text[:-3] + f" | {fmt} }}}}"
params = {**params, "text": styled_text}
return params
def apply_styles_recursively(
obj: Any,
style_metadata: Dict[str, List[str]],
depth: int = 0,
) -> Any:
"""Recursively walk a card structure and apply styles to all text fields.
Handles:
- decoratedText.text
- textParagraph.text
- button.text
- chip.label
- header.title, header.subtitle
- Any nested structures
Args:
obj: Card dict, list, or value to process
style_metadata: Style metadata with semantic_styles and formatting
depth: Current recursion depth
Returns:
Modified object with styles applied to text fields
"""
if not style_metadata.get("semantic_styles"):
return obj
if isinstance(obj, dict):
result = {}
for key, value in obj.items():
# Text fields that should be styled
if key == "text" and isinstance(value, str):
result[key] = apply_style_to_text(value, style_metadata)
elif key == "title" and isinstance(value, str) and depth > 0:
# Style titles in widgets (not card header)
result[key] = apply_style_to_text(value, style_metadata)
elif key == "label" and isinstance(value, str):
# Chip labels
result[key] = apply_style_to_text(value, style_metadata)
elif key == "topLabel" and isinstance(value, str):
result[key] = apply_style_to_text(value, style_metadata)
elif key == "bottomLabel" and isinstance(value, str):
result[key] = apply_style_to_text(value, style_metadata)
else:
# Recurse into nested structures
result[key] = apply_styles_recursively(value, style_metadata, depth + 1)
return result
elif isinstance(obj, list):
return [apply_styles_recursively(item, style_metadata, depth) for item in obj]
else:
return obj
# =============================================================================
# TEXT FORMATTING FOR GOOGLE CHAT
# =============================================================================
def format_text_for_chat(text: str, jinja_env=None) -> str:
"""
Format text with Jinja2 processing and markdown-to-HTML conversion for Google Chat.
Processing order:
1. Jinja2 template processing (styling filters like success_text, color, etc.)
2. Markdown conversion (**bold**, *italic*)
3. Bullet point formatting
Handles:
- Jinja filters: {{ 'text' | success_text }}, {{ text | color('success') }}
- Raw HTML: <font color="#hex">text</font>
- **bold** -> <b>bold</b>
- *italic* -> <i>italic</i>
- Bullet points (-, •, *) at line start -> proper formatting
Args:
text: Text to format
jinja_env: Optional Jinja2 environment for template processing
Returns:
Formatted text suitable for Google Chat
"""
if not text:
return ""
result = text
# Step 1: Process through Jinja2 for styling filters (if env provided)
if jinja_env and "{{" in text:
try:
template = jinja_env.from_string(text)
result = template.render()
except Exception:
pass # Keep original on error
# Step 2: Convert **bold** to <b>bold</b>
result = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", result)
# Step 3: Convert *italic* to <i>italic</i> (but not bullet points)
result = re.sub(r"(?<!\*)\*([^*\n]+)\*(?!\*)", r"<i>\1</i>", result)
# Step 4: Convert markdown bullet points to HTML
# Handle lines starting with -, •, or * followed by space
lines = result.split("\n")
formatted_lines = []
for line in lines:
stripped = line.strip()
if stripped.startswith(("- ", "• ", "* ")):
# Convert to bullet point with proper formatting
content = stripped[2:].strip()
formatted_lines.append(f"• {content}")
else:
formatted_lines.append(line)
return "\n".join(formatted_lines)
__all__ = [
"extract_style_metadata",
"apply_styles",
"style_keyword",
"DEFAULT_SEMANTIC_COLORS",
# Style application
"has_explicit_styles",
"get_style_for_text",
"apply_style_to_text",
"apply_pattern_styles",
"apply_styles_recursively",
# Keyword sets
"SUCCESS_KEYWORDS",
"ERROR_KEYWORDS",
"WARNING_KEYWORDS",
# Text formatting
"format_text_for_chat",
]