| 1 |
<?php |
| 2 |
/** |
| 3 |
* Email Report Renderer |
| 4 |
* |
| 5 |
* Walks the registered sections, calls collect() then render() on each, |
| 6 |
* wraps each result in a card and assembles the final HTML using |
| 7 |
* templates/email-report/email.html.php. |
| 8 |
* |
| 9 |
* Failure model: a section that throws, collects nothing or renders to |
| 10 |
* nothing is dropped — the card is simply absent — and the rest of the |
| 11 |
* report keeps rendering. A section may still supply fallback_html(); when |
| 12 |
* it is non-empty it is shown in the section's card, which keeps sections |
| 13 |
* written against 1.9 working unchanged (#742). |
| 14 |
* |
| 15 |
* @package ThinkRank |
| 16 |
* @subpackage SEO |
| 17 |
* @since 1.9.0 |
| 18 |
*/ |
| 19 |
|
| 20 |
declare(strict_types=1); |
| 21 |
|
| 22 |
namespace ThinkRank\SEO; |
| 23 |
|
| 24 |
use ThinkRank\SEO\Email_Report_Sections\Email_Report_Html; |
| 25 |
use Throwable; |
| 26 |
|
| 27 |
if (!defined('ABSPATH')) { |
| 28 |
exit; |
| 29 |
} |
| 30 |
|
| 31 |
/** |
| 32 |
* Email_Report_Renderer |
| 33 |
* |
| 34 |
* @since 1.9.0 |
| 35 |
*/ |
| 36 |
final class Email_Report_Renderer { |
| 37 |
|
| 38 |
private Email_Report_Section_Registry $registry; |
| 39 |
|
| 40 |
/** |
| 41 |
* How many sections produced real data during the last render(). |
| 42 |
* |
| 43 |
* A section that returns an empty payload (or renders to nothing) is |
| 44 |
* dropped. That outcome is only known *inside* render_one_section(), |
| 45 |
* so it is counted here for the caller to read back after rendering — |
| 46 |
* see sections_with_data(). |
| 47 |
*/ |
| 48 |
private int $sections_with_data = 0; |
| 49 |
|
| 50 |
public function __construct(Email_Report_Section_Registry $registry) { |
| 51 |
$this->registry = $registry; |
| 52 |
} |
| 53 |
|
| 54 |
/** |
| 55 |
* Render the full HTML for a report. |
| 56 |
* |
| 57 |
* @param array $config Per-site config. |
| 58 |
* @param array $context { |
| 59 |
* @type string $period_start ISO datetime. |
| 60 |
* @type string $period_end ISO datetime. |
| 61 |
* @type string $period_label Human-readable, e.g. "May 1 – May 30, 2026". |
| 62 |
* @type bool $is_test True when called from "Send Test Email". |
| 63 |
* @type bool $not_connected True to render the "connect Search Console" |
| 64 |
* state instead of the sections. |
| 65 |
* @type array $readiness Data_Provider::readiness() result. |
| 66 |
* } |
| 67 |
*/ |
| 68 |
public function render(array $config, array $context): string { |
| 69 |
// Reset before walking the sections so sections_with_data() always |
| 70 |
// describes this render and never a previous one. |
| 71 |
$this->sections_with_data = 0; |
| 72 |
|
| 73 |
$shared_context = array_merge([ |
| 74 |
'site_url' => (string) home_url(), |
| 75 |
'frequency_days' => (int) ($config['frequency_days'] ?? 30), |
| 76 |
'period_start' => '', |
| 77 |
'period_end' => '', |
| 78 |
'period_label' => '', |
| 79 |
'is_test' => false, |
| 80 |
'not_connected' => false, |
| 81 |
'readiness' => [], |
| 82 |
'shared' => [], |
| 83 |
], $context); |
| 84 |
|
| 85 |
$sections_html = !empty($shared_context['not_connected']) |
| 86 |
? $this->render_not_connected($shared_context) |
| 87 |
: $this->render_sections($config, $shared_context); |
| 88 |
|
| 89 |
$frequency_days = (int) $shared_context['frequency_days']; |
| 90 |
|
| 91 |
$payload = [ |
| 92 |
'config' => $config, |
| 93 |
'context' => $shared_context, |
| 94 |
'sections_html' => $sections_html, |
| 95 |
'header_logo' => $this->default_logo(), |
| 96 |
'header_bg' => '', |
| 97 |
'logo_link' => '', |
| 98 |
'intro_text' => '', |
| 99 |
'footer_text' => $this->default_footer($config), |
| 100 |
'additional_css' => '', |
| 101 |
'cta_url' => $this->dashboard_url(), |
| 102 |
'settings_url' => Email_Report_Html::admin_link('analytics', 'email-reporting'), |
| 103 |
'site_title' => (string) get_bloginfo('name'), |
| 104 |
'site_url' => (string) home_url(), |
| 105 |
'frequency_days' => $frequency_days, |
| 106 |
'frequency_label' => $this->frequency_label($frequency_days), |
| 107 |
]; |
| 108 |
|
| 109 |
/** |
| 110 |
* Filter the assembled payload before the layout template runs. |
| 111 |
* |
| 112 |
* The layout's presentation slots — header logo, logo link, header |
| 113 |
* background, intro, footer, extra CSS and the dashboard link — are |
| 114 |
* filled here. ThinkRank Pro fills them from its branding settings. |
| 115 |
* |
| 116 |
* @since 1.9.0 |
| 117 |
* |
| 118 |
* @param array $payload |
| 119 |
* @param array $config |
| 120 |
* @param array $shared_context |
| 121 |
*/ |
| 122 |
$payload = (array) apply_filters('thinkrank_email_report_payload', $payload, $config, $shared_context); |
| 123 |
|
| 124 |
// Tokens resolve after the filter, so text supplied through it gets |
| 125 |
// the same %site_title% / %period% substitution as the defaults. |
| 126 |
$payload['intro_text'] = $this->apply_text_tokens((string) ($payload['intro_text'] ?? ''), $shared_context); |
| 127 |
$payload['footer_text'] = $this->apply_text_tokens((string) ($payload['footer_text'] ?? ''), $shared_context); |
| 128 |
|
| 129 |
$layout = $this->locate_layout(); |
| 130 |
if (!is_readable($layout)) { |
| 131 |
return $this->emergency_fallback_html($payload); |
| 132 |
} |
| 133 |
|
| 134 |
ob_start(); |
| 135 |
// The layout file expects $payload in scope. |
| 136 |
include $layout; |
| 137 |
return (string) ob_get_clean(); |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Whether this config resolves to at least one section. |
| 142 |
* |
| 143 |
* A report with everything switched off still produced a valid email — |
| 144 |
* header, footer, and nothing in between — and reported it as a |
| 145 |
* successful send. Callers use this to skip the send instead. |
| 146 |
* |
| 147 |
* This is a *configuration* check: it answers "is anything enabled?", |
| 148 |
* not "did anything have data?". A section that is enabled but returns |
| 149 |
* nothing still counts here, because emptiness is only discovered later, |
| 150 |
* during render(). For the data question use sections_with_data() after |
| 151 |
* rendering. |
| 152 |
*/ |
| 153 |
public function has_renderable_sections(array $config): bool { |
| 154 |
return $this->registry->resolve_for($config) !== []; |
| 155 |
} |
| 156 |
|
| 157 |
/** |
| 158 |
* Walk the resolved sections, render each, return concatenated HTML. |
| 159 |
*/ |
| 160 |
private function render_sections(array $config, array $context): string { |
| 161 |
$sections = $this->registry->resolve_for($config); |
| 162 |
$html = ''; |
| 163 |
|
| 164 |
foreach ($sections as $section) { |
| 165 |
$section_html = $this->render_one_section($section, $context); |
| 166 |
if ($section_html === '') { |
| 167 |
continue; |
| 168 |
} |
| 169 |
$html .= $this->wrap_section($section, $section_html); |
| 170 |
} |
| 171 |
|
| 172 |
return $html; |
| 173 |
} |
| 174 |
|
| 175 |
private function render_one_section($section, array $context): string { |
| 176 |
try { |
| 177 |
$payload = $section->collect($context); |
| 178 |
if (empty($payload)) { |
| 179 |
return $this->fallback_for($section); |
| 180 |
} |
| 181 |
$rendered = $section->render($payload); |
| 182 |
if ($rendered === '') { |
| 183 |
return $this->fallback_for($section); |
| 184 |
} |
| 185 |
|
| 186 |
// A non-empty payload is not the same as data. Key Metrics |
| 187 |
// collects whenever the provider reports itself "available", |
| 188 |
// which it does even with no Search Console connection — and |
| 189 |
// then renders a row of zeroes. That is a placeholder, not a |
| 190 |
// report, so it must not make an empty send look non-empty. |
| 191 |
if (!$this->payload_has_data($section, $payload)) { |
| 192 |
return $this->fallback_for($section); |
| 193 |
} |
| 194 |
|
| 195 |
$this->sections_with_data++; |
| 196 |
|
| 197 |
return $rendered; |
| 198 |
} catch (Throwable $e) { |
| 199 |
// Don't let one section break the report. |
| 200 |
return $this->fallback_for($section); |
| 201 |
} |
| 202 |
} |
| 203 |
|
| 204 |
/** |
| 205 |
* What a section shows when it has nothing: its fallback_html(), which |
| 206 |
* the built-in sections leave empty so their card is dropped, and a |
| 207 |
* section written for 1.9 may still fill with a notice. |
| 208 |
*/ |
| 209 |
private function fallback_for($section): string { |
| 210 |
try { |
| 211 |
return (string) $section->fallback_html(); |
| 212 |
} catch (Throwable $e) { |
| 213 |
return ''; |
| 214 |
} |
| 215 |
} |
| 216 |
|
| 217 |
/** |
| 218 |
* Whether a section's payload carries anything worth reporting. |
| 219 |
* |
| 220 |
* A section may override the default judgement by implementing |
| 221 |
* `has_data(array $payload): bool` — the interface does not require it, |
| 222 |
* so Pro sections with their own notion of emptiness can opt in without |
| 223 |
* every existing section having to change. |
| 224 |
* |
| 225 |
* The default is a structural read: any non-zero number, any non-empty |
| 226 |
* string, any true flag counts. An all-zero payload — six metrics at 0, |
| 227 |
* no keyword rows — does not. Sections that already return an empty |
| 228 |
* payload when they have nothing never reach this check. |
| 229 |
* |
| 230 |
* @param object $section |
| 231 |
*/ |
| 232 |
private function payload_has_data($section, array $payload): bool { |
| 233 |
if (method_exists($section, 'has_data')) { |
| 234 |
return (bool) $section->has_data($payload); |
| 235 |
} |
| 236 |
|
| 237 |
return $this->array_has_value($payload); |
| 238 |
} |
| 239 |
|
| 240 |
private function array_has_value(array $values): bool { |
| 241 |
foreach ($values as $value) { |
| 242 |
if (is_array($value)) { |
| 243 |
if ($this->array_has_value($value)) { |
| 244 |
return true; |
| 245 |
} |
| 246 |
continue; |
| 247 |
} |
| 248 |
if (is_bool($value)) { |
| 249 |
if ($value) { |
| 250 |
return true; |
| 251 |
} |
| 252 |
continue; |
| 253 |
} |
| 254 |
if (is_int($value) || is_float($value)) { |
| 255 |
if (abs((float) $value) > 0.0) { |
| 256 |
return true; |
| 257 |
} |
| 258 |
continue; |
| 259 |
} |
| 260 |
if (is_string($value)) { |
| 261 |
if (trim($value) !== '') { |
| 262 |
return true; |
| 263 |
} |
| 264 |
continue; |
| 265 |
} |
| 266 |
if ($value !== null) { |
| 267 |
return true; |
| 268 |
} |
| 269 |
} |
| 270 |
|
| 271 |
return false; |
| 272 |
} |
| 273 |
|
| 274 |
/** |
| 275 |
* How many sections produced real data in the most recent render(). |
| 276 |
* |
| 277 |
* Zero means every section was dropped, so the report is a header and a |
| 278 |
* footer with nothing between. Callers use this to decide whether that |
| 279 |
* is worth sending. |
| 280 |
*/ |
| 281 |
public function sections_with_data(): int { |
| 282 |
return $this->sections_with_data; |
| 283 |
} |
| 284 |
|
| 285 |
/** |
| 286 |
* One white card. A section that draws its own heading (the built-in |
| 287 |
* ones, via renders_own_heading()) gets the bare shell; any other gets |
| 288 |
* its label as the card title, so a section written for 1.9 still reads |
| 289 |
* as one card among the rest. |
| 290 |
*/ |
| 291 |
private function wrap_section($section, string $body_html): string { |
| 292 |
$heading = ''; |
| 293 |
$own = method_exists($section, 'renders_own_heading') && $section->renders_own_heading(); |
| 294 |
if (!$own) { |
| 295 |
$heading = Email_Report_Html::heading((string) $section->label()) . '<div style="height:14px;line-height:14px;font-size:0;"> </div>'; |
| 296 |
} |
| 297 |
|
| 298 |
return self::card($heading . $body_html, 'tr-email-section tr-email-section-' . sanitize_html_class((string) $section->key())); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* The card shell used by sections, the connect state and the layout. |
| 303 |
*/ |
| 304 |
public static function card(string $inner_html, string $css_class = 'tr-email-section', string $padding = '24px 28px'): string { |
| 305 |
return '<tr><td class="' . esc_attr($css_class) . '" style="padding:0 0 16px 0;">' |
| 306 |
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#ffffff;border:1px solid ' . Email_Report_Html::LINE . ';border-radius:14px;">' |
| 307 |
. '<tr><td style="padding:' . esc_attr($padding) . ';">' . $inner_html . '</td></tr>' |
| 308 |
. '</table></td></tr>'; |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* The one-card email a test send delivers while Search Console is not |
| 313 |
* connected: what the report will contain, how to connect, and that |
| 314 |
* scheduled reports are paused until then (#742). |
| 315 |
*/ |
| 316 |
private function render_not_connected(array $context): string { |
| 317 |
$font = Email_Report_Html::FONT; |
| 318 |
$site_title = (string) get_bloginfo('name'); |
| 319 |
$connect = Email_Report_Html::admin_link('integrations', 'google-services'); |
| 320 |
$settings = Email_Report_Html::admin_link('analytics', 'email-reporting'); |
| 321 |
|
| 322 |
$intro = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>' |
| 323 |
. '<td width="56" valign="top"><img src="' . esc_url(Email_Report_Html::asset('icon-connect.png')) . '" width="52" height="52" alt="" style="display:block;border:0;border-radius:26px;" /></td>' |
| 324 |
. '<td style="padding-left:16px;' . $font . '">' |
| 325 |
. '<div style="font-size:20px;font-weight:800;color:' . Email_Report_Html::INK . ';letter-spacing:-0.01em;line-height:1.3;">' |
| 326 |
. esc_html__('Connect Google Search Console to get your first report', 'thinkrank') . '</div>' |
| 327 |
. '<div style="font-size:14px;color:' . Email_Report_Html::MUTED . ';line-height:1.6;margin-top:8px;">' |
| 328 |
. esc_html(sprintf( |
| 329 |
/* translators: %s: site title. */ |
| 330 |
__('This report is built from Search Console data: clicks, impressions, growing pages and queries, and where your keywords rank. %s isn’t connected yet, so there is nothing to report.', 'thinkrank'), |
| 331 |
$site_title |
| 332 |
)) . '</div>' |
| 333 |
. '<div style="font-size:14px;color:' . Email_Report_Html::MUTED . ';line-height:1.6;margin-top:8px;">' |
| 334 |
. '<strong style="color:' . Email_Report_Html::INK . ';">' . esc_html__('Scheduled reports are paused', 'thinkrank') . '</strong> ' |
| 335 |
. esc_html__('until Search Console is connected. Nothing will be sent to your recipients in the meantime.', 'thinkrank') . '</div>' |
| 336 |
. '<div style="margin-top:18px;">' |
| 337 |
. '<a href="' . esc_url($connect) . '" style="display:inline-block;background:' . Email_Report_Html::PRIMARY . ';color:#ffffff;font-size:14px;font-weight:700;text-decoration:none;padding:12px 22px;border-radius:10px;">' |
| 338 |
. esc_html__('Connect Search Console', 'thinkrank') . '</a>' |
| 339 |
. ' <a href="' . esc_url($settings) . '" style="font-size:14px;font-weight:600;color:' . Email_Report_Html::PRIMARY . ';text-decoration:none;">' |
| 340 |
. esc_html__('Report settings', 'thinkrank') . ' ›</a></div>' |
| 341 |
. '</td></tr></table>'; |
| 342 |
|
| 343 |
$items = [ |
| 344 |
__('Clicks, impressions, CTR and average position, with the change vs the previous period', 'thinkrank'), |
| 345 |
__('Top growing pages and queries, and the ones losing ground', 'thinkrank'), |
| 346 |
__('Where your keywords rank: top 3, page one, page two and beyond', 'thinkrank'), |
| 347 |
__('Site traffic from Google Analytics 4 and visits from AI assistants, when those are connected', 'thinkrank'), |
| 348 |
]; |
| 349 |
$list = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-top:12px;' . $font . 'font-size:14px;color:' . Email_Report_Html::INK . ';line-height:1.6;">'; |
| 350 |
foreach ($items as $item) { |
| 351 |
$list .= '<tr><td width="22" valign="top" style="padding:6px 0;color:' . Email_Report_Html::UP . ';font-weight:700;">✓</td>' |
| 352 |
. '<td style="padding:6px 0;">' . esc_html($item) . '</td></tr>'; |
| 353 |
} |
| 354 |
$list .= '</table>'; |
| 355 |
|
| 356 |
return self::card($intro, 'tr-email-section tr-email-connect', '28px') |
| 357 |
. self::card(Email_Report_Html::heading(__('What you’ll get once connected', 'thinkrank')) . $list, 'tr-email-section tr-email-connect-list'); |
| 358 |
} |
| 359 |
|
| 360 |
private function default_logo(): string { |
| 361 |
/** |
| 362 |
* Filter the default email logo URL. |
| 363 |
* |
| 364 |
* The bundled ThinkRank lockup by default (#742). Pro's branding |
| 365 |
* logo replaces it through the payload filter, not here. |
| 366 |
* |
| 367 |
* @since 1.9.0 |
| 368 |
* |
| 369 |
* @param string $default Default logo URL. |
| 370 |
*/ |
| 371 |
return (string) apply_filters('thinkrank_email_report_default_logo', Email_Report_Html::asset('thinkrank-logo.png')); |
| 372 |
} |
| 373 |
|
| 374 |
private function dashboard_url(): string { |
| 375 |
return Email_Report_Html::admin_link('analytics', 'dashboard'); |
| 376 |
} |
| 377 |
|
| 378 |
/** |
| 379 |
* "Monthly SEO report" / "Weekly SEO report" / "SEO report every N days" |
| 380 |
* for the header's right-hand label. |
| 381 |
*/ |
| 382 |
private function frequency_label(int $days): string { |
| 383 |
if ($days === 1) { |
| 384 |
return __('Daily SEO report', 'thinkrank'); |
| 385 |
} |
| 386 |
if ($days === 7) { |
| 387 |
return __('Weekly SEO report', 'thinkrank'); |
| 388 |
} |
| 389 |
if ($days >= 28 && $days <= 31) { |
| 390 |
return __('Monthly SEO report', 'thinkrank'); |
| 391 |
} |
| 392 |
return sprintf( |
| 393 |
/* translators: %d: number of days between reports. */ |
| 394 |
_n('SEO report every %d day', 'SEO report every %d days', $days, 'thinkrank'), |
| 395 |
$days |
| 396 |
); |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Substitute %site_title%, %site_url%, %date% and %period% |
| 401 |
* in free-text fields like the intro and footer. Previously only the subject |
| 402 |
* line ran token substitution, so these tokens rendered literally in the body. |
| 403 |
* |
| 404 |
* @param string $text Raw text, possibly containing %tokens%. |
| 405 |
* @param array $shared_context Render context (supplies the period label). |
| 406 |
* @return string Text with tokens replaced. |
| 407 |
*/ |
| 408 |
private function apply_text_tokens(string $text, array $shared_context): string { |
| 409 |
if ($text === '' || strpos($text, '%') === false) { |
| 410 |
return $text; |
| 411 |
} |
| 412 |
|
| 413 |
$tokens = [ |
| 414 |
'%site_title%' => (string) get_bloginfo('name'), |
| 415 |
'%site_url%' => (string) home_url(), |
| 416 |
'%date%' => wp_date(get_option('date_format', 'Y-m-d')), |
| 417 |
'%period%' => (string) ($shared_context['period_label'] ?? ''), |
| 418 |
]; |
| 419 |
|
| 420 |
/** |
| 421 |
* Filter the token map used for email report free-text fields. |
| 422 |
* |
| 423 |
* Mirrors the subject-line token filter so Pro can register extra |
| 424 |
* tokens (e.g. %client_name%) that resolve everywhere. |
| 425 |
* |
| 426 |
* @since 1.16.0 |
| 427 |
* |
| 428 |
* @param array $tokens Token => replacement. |
| 429 |
* @param array $shared_context Render context. |
| 430 |
*/ |
| 431 |
$tokens = (array) apply_filters('thinkrank_email_report_tokens', $tokens, $shared_context); |
| 432 |
|
| 433 |
return strtr($text, $tokens); |
| 434 |
} |
| 435 |
|
| 436 |
/** |
| 437 |
* "Sent by ThinkRank for example.com · every 30 days to [email protected], [email protected]" |
| 438 |
*/ |
| 439 |
private function default_footer(array $config): string { |
| 440 |
$days = max(1, (int) ($config['frequency_days'] ?? 30)); |
| 441 |
$recipients = array_values(array_filter((array) ($config['recipients'] ?? []), 'is_string')); |
| 442 |
$host = (string) wp_parse_url((string) home_url(), PHP_URL_HOST); |
| 443 |
|
| 444 |
$line = sprintf( |
| 445 |
/* translators: %s: site host name. */ |
| 446 |
__('Sent by ThinkRank for %s', 'thinkrank'), |
| 447 |
$host !== '' ? $host : (string) get_bloginfo('name') |
| 448 |
); |
| 449 |
$cadence = $days === 1 |
| 450 |
? __('every day', 'thinkrank') |
| 451 |
: sprintf( |
| 452 |
/* translators: %d: number of days between reports. */ |
| 453 |
_n('every %d day', 'every %d days', $days, 'thinkrank'), |
| 454 |
$days |
| 455 |
); |
| 456 |
if ($recipients !== []) { |
| 457 |
$line .= ' · ' . sprintf( |
| 458 |
/* translators: 1: cadence ("every 30 days"), 2: recipient list. */ |
| 459 |
__('%1$s to %2$s', 'thinkrank'), |
| 460 |
$cadence, |
| 461 |
implode(', ', array_slice($recipients, 0, 3)) . (count($recipients) > 3 ? '…' : '') |
| 462 |
); |
| 463 |
} else { |
| 464 |
$line .= ' · ' . $cadence; |
| 465 |
} |
| 466 |
|
| 467 |
return esc_html($line); |
| 468 |
} |
| 469 |
|
| 470 |
private function locate_layout(): string { |
| 471 |
return THINKRANK_PLUGIN_DIR . 'templates/email-report/email.html.php'; |
| 472 |
} |
| 473 |
|
| 474 |
/** |
| 475 |
* If the layout template is missing for some reason, render a minimal |
| 476 |
* but still useful HTML email. Better than a blank message — the user |
| 477 |
* still gets the data they care about. |
| 478 |
*/ |
| 479 |
private function emergency_fallback_html(array $payload): string { |
| 480 |
$title = esc_html($payload['site_title'] ?? ''); |
| 481 |
$sections = $payload['sections_html'] ?? ''; |
| 482 |
return '<!doctype html><html><body style="' . Email_Report_Html::FONT . 'background:' . Email_Report_Html::CANVAS . ';padding:24px;">' |
| 483 |
. '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;margin:0 auto;">' |
| 484 |
. '<tr><td style="padding:0 0 16px 0;"><h1 style="margin:0;font-size:22px;color:' . Email_Report_Html::INK . ';">' . $title . '</h1></td></tr>' |
| 485 |
. $sections |
| 486 |
. '</table></body></html>'; |
| 487 |
} |
| 488 |
} |
| 489 |
|