PluginProbe ʕ •ᴥ•ʔ
Post Views Counter / 1.7.13
Post Views Counter v1.7.13
1.7.13 1.7.12 1.7.11 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3 1.3.1 1.3.10 1.3.11 1.3.12 1.3.13 1.3.2 1.3.2.1 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.7.0 1.7.1 1.7.10 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9
post-views-counter / includes / class-emails-template.php
post-views-counter / includes Last commit date
class-admin.php 2 weeks ago class-columns-modal.php 2 weeks ago class-columns.php 2 weeks ago class-counter.php 2 weeks ago class-crawler-detect.php 2 weeks ago class-cron.php 2 weeks ago class-dashboard.php 2 weeks ago class-emails-mailer.php 2 weeks ago class-emails-period.php 2 weeks ago class-emails-query.php 2 weeks ago class-emails-scheduler.php 2 weeks ago class-emails-template.php 2 weeks ago class-emails.php 2 weeks ago class-frontend.php 2 weeks ago class-functions.php 2 weeks ago class-import.php 2 weeks ago class-integration-gutenberg.php 2 weeks ago class-integrations.php 2 weeks ago class-query.php 2 weeks ago class-settings-api.php 2 weeks ago class-settings-display.php 2 weeks ago class-settings-emails.php 2 weeks ago class-settings-general.php 2 weeks ago class-settings-integrations.php 2 weeks ago class-settings-other.php 2 weeks ago class-settings-reports.php 2 weeks ago class-settings.php 2 weeks ago class-toolbar.php 2 weeks ago class-traffic-signals.php 2 weeks ago class-update.php 2 weeks ago class-widgets.php 2 weeks ago functions.php 2 weeks ago
class-emails-template.php
1979 lines
1 <?php
2 // exit if accessed directly
3 if ( ! defined( 'ABSPATH' ) )
4 exit;
5
6 /**
7 * Post_Views_Counter_Emails_Template class.
8 *
9 * @class Post_Views_Counter_Emails_Template
10 */
11 class Post_Views_Counter_Emails_Template {
12
13 /**
14 * @var Post_Views_Counter
15 */
16 private $pvc;
17
18 /**
19 * Class constructor.
20 *
21 * @return void
22 */
23 public function __construct() {
24 $this->pvc = Post_Views_Counter();
25 }
26
27 /**
28 * Get supported subject template tags.
29 *
30 * @param array $data
31 * @param string $summary_type
32 * @return array
33 */
34 public static function get_subject_template_tags( $data = [], $summary_type = 'weekly' ) {
35 $allowed_tags = [
36 '%%site_name%%',
37 '%%report_label%%',
38 '%%period_label%%',
39 '%%total_views%%',
40 '%%views_change%%',
41 '%%views_change_percent%%',
42 '%%viewed_content_count%%',
43 '%%top_post_title%%'
44 ];
45
46 return self::filter_template_tags_by_definitions( $allowed_tags, self::get_content_template_tags( $data, $summary_type ) );
47 }
48
49 /**
50 * Get supported content template tags.
51 *
52 * @param array $data
53 * @param string $summary_type
54 * @return array
55 */
56 public static function get_content_template_tags( $data = [], $summary_type = 'weekly' ) {
57 return array_keys( self::get_template_tag_definitions( $data, $summary_type ) );
58 }
59
60 /**
61 * Get additional template tags reserved for locked UI display.
62 *
63 * @return array
64 */
65 public static function get_pro_template_tags() {
66 $tags = apply_filters(
67 'pvc_email_summary_pro_template_tags',
68 [
69 '%%content_performance_summary%%',
70 '%%monthly_report_summary%%',
71 '%%top_gainers%%',
72 '%%top_decliners%%',
73 '%%rolling_average_summary%%',
74 '%%projection_summary%%',
75 '%%top_authors%%',
76 '%%top_terms%%',
77 '%%top_sources%%',
78 '%%device_summary%%',
79 '%%browser_summary%%',
80 '%%language_summary%%',
81 '%%suggested_actions%%'
82 ]
83 );
84
85 if ( ! is_array( $tags ) )
86 return [];
87
88 return array_values(
89 array_filter(
90 array_map(
91 static function( $tag ) {
92 $tag = sanitize_text_field( (string) $tag );
93
94 return preg_match( '/^%%[a-z0-9_]+%%$/i', $tag ) ? $tag : '';
95 },
96 $tags
97 )
98 )
99 );
100 }
101
102 /**
103 * Get supported Free template tags.
104 *
105 * @param array $data
106 * @param string $summary_type
107 * @return array
108 */
109 public static function get_free_template_tags( $data = [], $summary_type = 'weekly' ) {
110 return self::get_content_template_tags( $data, $summary_type );
111 }
112
113 /**
114 * Find unsupported template tags.
115 *
116 * @param string $template
117 * @param array $data
118 * @param string $summary_type
119 * @param string $context
120 * @return array
121 */
122 public static function find_unknown_template_tags( $template, $data = [], $summary_type = 'weekly', $context = 'content' ) {
123 if ( ! preg_match_all( '/%%[a-z0-9_]+%%/i', (string) $template, $matches ) )
124 return [];
125
126 $allowed = array_map( 'strtolower', self::get_template_tags_for_context( $context, $data, $summary_type ) );
127 $found = array_unique( array_map( 'strtolower', $matches[0] ) );
128
129 return array_values( array_diff( $found, $allowed ) );
130 }
131
132 /**
133 * Remove unsupported template tags for a specific field context.
134 *
135 * @param string $template
136 * @param string $context
137 * @param array $data
138 * @param string $summary_type
139 * @return array
140 */
141 public static function strip_invalid_template_tags( $template, $context = 'content', $data = [], $summary_type = 'weekly' ) {
142 $template = (string) $template;
143 $removed_tags = [];
144 $allowed = array_map( 'strtolower', self::get_template_tags_for_context( $context, $data, $summary_type ) );
145
146 $cleaned_template = preg_replace_callback(
147 '/%%[a-z0-9_]+%%/i',
148 static function( $matches ) use ( $allowed, &$removed_tags ) {
149 $token = isset( $matches[0] ) ? (string) $matches[0] : '';
150 $normalized = strtolower( $token );
151
152 if ( in_array( $normalized, $allowed, true ) )
153 return $token;
154
155 $removed_tags[] = $token;
156
157 return '';
158 },
159 $template
160 );
161
162 return [
163 'template' => self::normalize_cleaned_template( $cleaned_template, $context ),
164 'removed_tags' => array_values( array_unique( $removed_tags ) )
165 ];
166 }
167
168 /**
169 * Render the summary subject and body.
170 *
171 * @param array $data
172 * @param array $settings
173 * @return array
174 */
175 public function render( $data, $settings = [] ) {
176 $data = is_array( $data ) ? $data : [];
177 $summary_type = ! empty( $data['type'] ) ? sanitize_key( $data['type'] ) : 'weekly';
178 $settings = $this->get_settings( $settings );
179 $subject_template = self::strip_invalid_template_tags( $this->get_subject_template( $settings, $summary_type ), 'subject', $data, $summary_type );
180 $body_template = self::strip_invalid_template_tags( $this->get_body_template( $settings ), 'content', $data, $summary_type );
181 $footer_template = self::strip_invalid_template_tags( self::get_default_footer_template(), 'content', $data, $summary_type );
182 $subject_template = $subject_template['template'];
183 $content_template_html = $body_template['template'];
184 $content_template_text = $body_template['template'];
185
186 if ( ! $this->template_contains_html_markup( $content_template_html ) )
187 $content_template_html = $this->render_plain_text_template_html( $content_template_html );
188
189 $content_template_text .= "\n\n" . $footer_template['template'];
190
191 $replacements = $this->get_tag_replacements( $data, $settings, $summary_type );
192 $subject = $this->render_template_text( $subject_template, $replacements, true );
193 $html = $this->render_template_html( $content_template_html, $replacements );
194 $footer_html = $this->render_template_html( $this->render_footer_template_html( $footer_template['template'], $data ), $replacements, true );
195 $plain_text = $this->render_template_text( $content_template_text, $replacements );
196
197 $subject = apply_filters( 'pvc_email_summary_subject', $subject, $data, $summary_type );
198 $html = apply_filters( 'pvc_email_summary_html', $html, $data, $summary_type );
199 $html = $this->sanitize_html_output( $html );
200 $html = $this->render_email_document_html( $html, $footer_html, $data, $settings, $summary_type );
201 $plain_text = apply_filters( 'pvc_email_summary_plain_text', $plain_text, $data, $summary_type );
202
203 return [
204 'subject' => $subject,
205 'html' => $html,
206 'plain_text' => $plain_text,
207 'data' => $data
208 ];
209 }
210
211 /**
212 * Get template tag definitions.
213 *
214 * @param array $data
215 * @param string $summary_type
216 * @return array
217 */
218 public static function get_template_tag_definitions( $data = [], $summary_type = 'weekly' ) {
219 $tags = [
220 '%%site_name%%' => [ 'type' => 'scalar' ],
221 '%%site_url%%' => [ 'type' => 'scalar' ],
222 '%%plugin_name%%' => [ 'type' => 'scalar' ],
223 '%%report_type%%' => [ 'type' => 'scalar' ],
224 '%%report_cadence%%' => [ 'type' => 'scalar' ],
225 '%%report_label%%' => [ 'type' => 'scalar' ],
226 '%%period_start%%' => [ 'type' => 'scalar' ],
227 '%%period_end%%' => [ 'type' => 'scalar' ],
228 '%%period_label%%' => [ 'type' => 'scalar' ],
229 '%%time_basis%%' => [ 'type' => 'scalar' ],
230 '%%generated_at%%' => [ 'type' => 'scalar' ],
231 '%%total_views%%' => [ 'type' => 'scalar' ],
232 '%%previous_total_views%%' => [ 'type' => 'scalar' ],
233 '%%views_change%%' => [ 'type' => 'scalar' ],
234 '%%views_change_percent%%' => [ 'type' => 'scalar' ],
235 '%%viewed_content_count%%' => [ 'type' => 'scalar' ],
236 '%%top_post_title%%' => [ 'type' => 'scalar' ],
237 '%%top_post_url%%' => [ 'type' => 'scalar' ],
238 '%%top_post_views%%' => [ 'type' => 'scalar' ],
239 '%%report_summary%%' => [ 'type' => 'block' ],
240 '%%top_content%%' => [ 'type' => 'block' ],
241 '%%traffic_signals%%' => [ 'type' => 'block' ],
242 '%%manage_emails_url%%' => [ 'type' => 'scalar' ],
243 '%%manage_emails_link%%' => [ 'type' => 'block' ],
244 '%%site_link%%' => [ 'type' => 'block' ]
245 ];
246
247 return self::sanitize_template_tag_definitions( apply_filters( 'pvc_email_summary_template_tags', $tags, $data, $summary_type ) );
248 }
249
250 /**
251 * Sanitize filtered template tag definitions.
252 *
253 * @param array $tags
254 * @return array
255 */
256 private static function sanitize_template_tag_definitions( $tags ) {
257 if ( ! is_array( $tags ) )
258 return [];
259
260 $sanitized = [];
261
262 foreach ( $tags as $tag => $definition ) {
263 if ( ! is_string( $tag ) || ! preg_match( '/^%%[a-z0-9_]+%%$/i', $tag ) )
264 continue;
265
266 if ( ! is_array( $definition ) )
267 $definition = [];
268
269 $definition['type'] = isset( $definition['type'] ) && $definition['type'] === 'block' ? 'block' : 'scalar';
270 $sanitized[$tag] = $definition;
271 }
272
273 return $sanitized;
274 }
275
276 /**
277 * Get merged email settings.
278 *
279 * @param array $settings
280 * @return array
281 */
282 private function get_settings( $settings ) {
283 $stored = isset( $this->pvc->options['emails'] ) && is_array( $this->pvc->options['emails'] ) ? $this->pvc->options['emails'] : [];
284 $defaults = $this->pvc->get_default_emails_settings();
285
286 if ( ! is_array( $settings ) )
287 $settings = [];
288
289 return wp_parse_args( $settings, array_merge( $defaults, $stored ) );
290 }
291
292 /**
293 * Get the default subject template.
294 *
295 * @return string
296 */
297 public static function get_default_subject_template( $summary_type = 'weekly' ) {
298 if ( self::normalize_summary_type_key( $summary_type ) === 'weekly' )
299 return '[%%site_name%%] Weekly content views: %%total_views%% views';
300
301 return '[%%site_name%%] %%report_label%% content views: %%total_views%% views';
302 }
303
304 /**
305 * Get the default body template.
306 *
307 * @return string
308 */
309 public static function get_default_body_template() {
310 return '<p>Hi,</p>' . "\n"
311 . '<p>Here is your %%report_label%% content views summary for %%site_name%%.</p>' . "\n"
312 . '<p>This summary covers %%period_label%% and shows which content received the most views during the report period.</p>' . "\n"
313 . '<p>%%report_summary%%</p>' . "\n"
314 . '<p>%%top_content%%</p>' . "\n"
315 . '<p>%%traffic_signals%%</p>' . "\n"
316 . '<p>That\'s all for this summary.</p>';
317 }
318
319 /**
320 * Sanitize user-editable email template HTML.
321 *
322 * Inline styles remain blocked for saved templates. The optional style support
323 * is only used when sanitizing plugin-generated HTML blocks and the outer email wrapper.
324 *
325 * @param string $html
326 * @param bool $allow_styles
327 * @return string
328 */
329 public static function sanitize_template_html( $html, $allow_styles = false ) {
330 $masked = self::mask_template_tags( $html );
331 $style_filter = null;
332
333 if ( $allow_styles ) {
334 $style_filter = static function( $styles ) {
335 return array_values( array_unique( array_merge( $styles, self::get_email_template_safe_css() ) ) );
336 };
337
338 add_filter( 'safe_style_css', $style_filter );
339 }
340
341 $sanitized = wp_kses( $masked['html'], self::get_email_template_allowed_html( $allow_styles ) );
342 $sanitized = self::restore_masked_template_tags( $sanitized, $masked['replacements'] );
343
344 if ( $allow_styles && $style_filter !== null )
345 remove_filter( 'safe_style_css', $style_filter );
346
347 return $sanitized;
348 }
349
350 /**
351 * Get the default footer template.
352 *
353 * @return string
354 */
355 public static function get_default_footer_template() {
356 return 'Sent from %%site_url%% by Post Views Counter.';
357 }
358
359 /**
360 * Get the configured subject template.
361 *
362 * @param array $settings
363 * @param string $summary_type
364 * @return string
365 */
366 private function get_subject_template( $settings, $summary_type = 'weekly' ) {
367 $template = ! empty( $settings['email_subject_template'] ) ? sanitize_text_field( $settings['email_subject_template'] ) : '';
368 $weekly_default = self::get_default_subject_template( 'weekly' );
369
370 if ( $template === '' )
371 return self::get_default_subject_template( $summary_type );
372
373 if ( self::normalize_summary_type_key( $summary_type ) !== 'weekly' && $template === $weekly_default )
374 return self::get_default_subject_template( $summary_type );
375
376 return $template;
377 }
378
379 /**
380 * Get the configured body template.
381 *
382 * @param array $settings
383 * @return string
384 */
385 private function get_body_template( $settings ) {
386 $template = ! empty( $settings['email_body_template'] ) ? self::sanitize_template_html( $settings['email_body_template'] ) : '';
387
388 return $template !== '' ? $template : self::get_default_body_template();
389 }
390
391 /**
392 * Get template tags allowed for a field context.
393 *
394 * @param string $context
395 * @param array $data
396 * @param string $summary_type
397 * @return array
398 */
399 private static function get_template_tags_for_context( $context, $data = [], $summary_type = 'weekly' ) {
400 switch ( sanitize_key( $context ) ) {
401 case 'subject':
402 return self::get_subject_template_tags( $data, $summary_type );
403
404 case 'content':
405 default:
406 return self::get_content_template_tags( $data, $summary_type );
407 }
408 }
409
410 /**
411 * Normalize a cleaned template string after invalid tags are removed.
412 *
413 * @param string $template
414 * @param string $context
415 * @return string
416 */
417 private static function normalize_cleaned_template( $template, $context ) {
418 $template = (string) $template;
419
420 if ( sanitize_key( $context ) === 'subject' )
421 return trim( preg_replace( '/\s{2,}/', ' ', $template ) );
422
423 return $template;
424 }
425
426 /**
427 * Keep requested tag order while dropping tags that are not currently defined.
428 *
429 * @param array $requested_tags
430 * @param array $available_tags
431 * @return array
432 */
433 private static function filter_template_tags_by_definitions( $requested_tags, $available_tags ) {
434 $available_lookup = array_map( 'strtolower', is_array( $available_tags ) ? $available_tags : [] );
435
436 return array_values(
437 array_filter(
438 is_array( $requested_tags ) ? $requested_tags : [],
439 static function( $tag ) use ( $available_lookup ) {
440 return is_string( $tag ) && in_array( strtolower( $tag ), $available_lookup, true );
441 }
442 )
443 );
444 }
445
446 /**
447 * Resolve HTML and plaintext values for each template tag.
448 *
449 * @param array $data
450 * @param array $settings
451 * @param string $summary_type
452 * @return array
453 */
454 private function get_tag_replacements( $data, $settings, $summary_type ) {
455 $definitions = self::get_template_tag_definitions( $data, $summary_type );
456 $replacements = [];
457
458 foreach ( $definitions as $tag => $definition ) {
459 $replacement = $this->resolve_replacement( $tag, $definition, $data, $settings, $summary_type );
460 $replacement['type'] = $definition['type'];
461 $replacements[$tag] = $replacement;
462 }
463
464 return $replacements;
465 }
466
467 /**
468 * Resolve a template tag replacement.
469 *
470 * @param string $tag
471 * @param array $definition
472 * @param array $data
473 * @param array $settings
474 * @param string $summary_type
475 * @return array
476 */
477 private function resolve_replacement( $tag, $definition, $data, $settings, $summary_type ) {
478 if ( array_key_exists( 'text', $definition ) || array_key_exists( 'html', $definition ) ) {
479 $text = $this->resolve_definition_value( isset( $definition['text'] ) ? $definition['text'] : '', $data, $settings, $summary_type );
480 $html = $this->resolve_definition_value( isset( $definition['html'] ) ? $definition['html'] : $text, $data, $settings, $summary_type );
481
482 return [
483 'text' => $this->sanitize_text_output( $text, $definition['type'] === 'block' ),
484 'html' => $definition['type'] === 'block' ? $this->sanitize_html_output( $html ) : esc_html( $this->sanitize_text_output( $html, false ) )
485 ];
486 }
487
488 switch ( $tag ) {
489 case '%%site_name%%':
490 $value = $this->get_site_name( $data );
491
492 return [
493 'text' => $value,
494 'html' => esc_html( $value )
495 ];
496
497 case '%%site_url%%':
498 $value = $this->get_site_url( $data );
499
500 return [
501 'text' => esc_url_raw( $value ),
502 'html' => esc_url( $value )
503 ];
504
505 case '%%plugin_name%%':
506 $value = __( 'Post Views Counter', 'post-views-counter' );
507
508 return [
509 'text' => $value,
510 'html' => esc_html( $value )
511 ];
512
513 case '%%report_type%%':
514 $value = $this->get_report_type_label( $data, $summary_type );
515
516 return [
517 'text' => $value,
518 'html' => esc_html( $value )
519 ];
520
521 case '%%report_cadence%%':
522 $value = $this->get_report_cadence_key( $data, $summary_type );
523
524 return [
525 'text' => $value,
526 'html' => esc_html( $value )
527 ];
528
529 case '%%report_label%%':
530 $value = $this->get_report_label( $data, $summary_type );
531
532 return [
533 'text' => $value,
534 'html' => esc_html( $value )
535 ];
536
537 case '%%period_start%%':
538 $value = $this->get_period_date( $data, 'start_date' );
539
540 return [
541 'text' => $value,
542 'html' => esc_html( $value )
543 ];
544
545 case '%%period_end%%':
546 $value = $this->get_period_date( $data, 'end_date' );
547
548 return [
549 'text' => $value,
550 'html' => esc_html( $value )
551 ];
552
553 case '%%period_label%%':
554 $value = ! empty( $data['period']['label'] ) ? sanitize_text_field( $data['period']['label'] ) : '';
555
556 return [
557 'text' => $value,
558 'html' => esc_html( $value )
559 ];
560
561 case '%%time_basis%%':
562 $value = ! empty( $data['period']['time_basis'] ) ? sanitize_key( $data['period']['time_basis'] ) : 'local';
563
564 return [
565 'text' => $value,
566 'html' => esc_html( $value )
567 ];
568
569 case '%%generated_at%%':
570 $value = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), null, wp_timezone() );
571
572 return [
573 'text' => $value,
574 'html' => esc_html( $value )
575 ];
576
577 case '%%total_views%%':
578 $value = $this->format_number( $this->get_overview_value( $data, 'total_views' ) );
579
580 return [
581 'text' => $value,
582 'html' => esc_html( $value )
583 ];
584
585 case '%%previous_total_views%%':
586 $value = $this->format_number( $this->get_overview_value( $data, 'previous_total_views' ) );
587
588 return [
589 'text' => $value,
590 'html' => esc_html( $value )
591 ];
592
593 case '%%views_change%%':
594 $value = $this->get_formatted_views_change( $data, $settings );
595
596 return [
597 'text' => $value,
598 'html' => esc_html( $value )
599 ];
600
601 case '%%views_change_percent%%':
602 $value = $this->get_formatted_views_change_percent( $data, $settings );
603
604 return [
605 'text' => $value,
606 'html' => esc_html( $value )
607 ];
608
609 case '%%viewed_content_count%%':
610 $value = $this->format_number( $this->get_overview_value( $data, 'viewed_content_count' ) );
611
612 return [
613 'text' => $value,
614 'html' => esc_html( $value )
615 ];
616
617 case '%%top_post_title%%':
618 $value = $this->get_top_content_value( $data, 'title' );
619
620 return [
621 'text' => $value,
622 'html' => esc_html( $value )
623 ];
624
625 case '%%top_post_url%%':
626 $value = $this->get_top_content_value( $data, 'url' );
627
628 return [
629 'text' => esc_url_raw( $value ),
630 'html' => esc_url( $value )
631 ];
632
633 case '%%top_post_views%%':
634 $value = $this->format_number( $this->get_top_content_value( $data, 'current_views' ) );
635
636 return [
637 'text' => $value,
638 'html' => esc_html( $value )
639 ];
640
641 case '%%report_summary%%':
642 return [
643 'text' => $this->render_report_summary_text( $data, $settings ),
644 'html' => $this->render_report_summary_html( $data, $settings )
645 ];
646
647 case '%%top_content%%':
648 return [
649 'text' => $this->render_top_content_text( $data ),
650 'html' => $this->render_top_content_html( $data )
651 ];
652
653 case '%%traffic_signals%%':
654 return [
655 'text' => $this->render_traffic_signals_text( $data, $settings ),
656 'html' => $this->render_traffic_signals_html( $data, $settings )
657 ];
658
659 case '%%manage_emails_url%%':
660 $value = $this->get_manage_emails_url( $data );
661
662 return [
663 'text' => esc_url_raw( $value ),
664 'html' => esc_url( $value )
665 ];
666
667 case '%%manage_emails_link%%':
668 return [
669 'text' => $this->render_manage_emails_link_text( $data ),
670 'html' => $this->render_manage_emails_link_html( $data )
671 ];
672
673 case '%%site_link%%':
674 return [
675 'text' => $this->render_site_link_text( $data ),
676 'html' => $this->render_site_link_html( $data )
677 ];
678 }
679
680 return [
681 'text' => $tag,
682 'html' => esc_html( $tag )
683 ];
684 }
685
686 /**
687 * Resolve a filtered template tag value.
688 *
689 * @param mixed $value
690 * @param array $data
691 * @param array $settings
692 * @param string $summary_type
693 * @return string
694 */
695 private function resolve_definition_value( $value, $data, $settings, $summary_type ) {
696 if ( is_callable( $value ) )
697 $value = call_user_func( $value, $data, $settings, $summary_type, $this );
698
699 return is_scalar( $value ) ? (string) $value : '';
700 }
701
702 /**
703 * Render a plaintext template.
704 *
705 * @param string $template
706 * @param array $replacements
707 * @param bool $single_line
708 * @return string
709 */
710 private function render_template_text( $template, $replacements, $single_line = false ) {
711 $template = (string) $template;
712 $map = [];
713
714 foreach ( $replacements as $tag => $replacement ) {
715 $map[$tag] = isset( $replacement['text'] ) ? $this->sanitize_text_output( $replacement['text'], ! empty( $replacement['type'] ) && $replacement['type'] === 'block' ) : $tag;
716 }
717
718 $text = strtr( $template, $map );
719 $text = $single_line ? wp_strip_all_tags( $text, true ) : $this->convert_html_to_text( $text );
720 $text = preg_replace( "/\r\n?|\n/", "\n", $text );
721 $text = preg_replace( "/\n{3,}/", "\n\n", trim( $text ) );
722
723 if ( $single_line )
724 $text = preg_replace( '/\s+/', ' ', trim( html_entity_decode( $text, ENT_QUOTES, $this->get_charset() ) ) );
725
726 return trim( $text );
727 }
728
729 /**
730 * Render an HTML template.
731 *
732 * @param string $template
733 * @param array $replacements
734 * @return string
735 */
736 private function render_template_html( $template, $replacements, $allow_template_styles = false ) {
737 $template = self::sanitize_template_html( $template, $allow_template_styles );
738 $placeholder_map = [];
739 $scalar_map = [];
740 $index = 0;
741
742 foreach ( $replacements as $tag => $replacement ) {
743 if ( ! empty( $replacement['type'] ) && $replacement['type'] === 'block' ) {
744 $placeholder = '__PVC_EMAIL_BLOCK_' . $index . '__';
745 $placeholder_map[$placeholder] = isset( $replacement['html'] ) ? $this->sanitize_html_output( $replacement['html'] ) : '';
746 $scalar_map[$tag] = $placeholder;
747 $index++;
748 } else {
749 $scalar_map[$tag] = isset( $replacement['html'] ) ? $replacement['html'] : esc_html( $tag );
750 }
751 }
752
753 $html = strtr( $template, $scalar_map );
754 $html = $this->unwrap_block_placeholders( $html, array_keys( $placeholder_map ) );
755 $html = strtr( $html, $placeholder_map );
756
757 return $this->sanitize_html_output( $html );
758 }
759
760 /**
761 * Check whether a template already contains HTML markup.
762 *
763 * @param string $template
764 * @return bool
765 */
766 private function template_contains_html_markup( $template ) {
767 return preg_match( '/<\/?[a-z][^>]*>/i', (string) $template ) === 1;
768 }
769
770 /**
771 * Convert a plaintext template fragment into simple HTML paragraphs.
772 *
773 * @param string $template
774 * @return string
775 */
776 private function render_plain_text_template_html( $template ) {
777 $template = preg_replace( "/\r\n?|\n/", "\n", sanitize_textarea_field( $template ) );
778 $template = trim( $template );
779
780 if ( $template === '' )
781 return '';
782
783 $paragraphs = preg_split( "/\n\s*\n+/", $template );
784
785 if ( ! is_array( $paragraphs ) )
786 return '';
787
788 $html = [];
789 $paragraph_count = count( $paragraphs );
790
791 foreach ( $paragraphs as $index => $paragraph ) {
792 $paragraph = trim( $paragraph );
793
794 if ( $paragraph === '' )
795 continue;
796
797 $style = $index + 1 === $paragraph_count ? 'body-copy body-copy-last' : 'body-copy';
798 $html[] = '<p style="' . esc_attr( $this->get_email_style( $style ) ) . '">' . nl2br( esc_html( $paragraph ) ) . '</p>';
799 }
800
801 return implode( '', $html );
802 }
803
804 /**
805 * Render the automatic footer as muted HTML.
806 *
807 * @param string $template
808 * @param array $data
809 * @return string
810 */
811 private function render_footer_template_html( $template, $data = [] ) {
812 $template = preg_replace( "/\r\n?|\n/", "\n", sanitize_textarea_field( $template ) );
813 $template = trim( $template );
814
815 if ( $template === '' )
816 return '';
817
818 $template = esc_html( $template );
819 $template = str_replace( '%%site_url%%', '<a href="%%site_url%%" style="' . esc_attr( $this->get_email_style( 'footer-link' ) ) . '">%%site_url%%</a>', $template );
820 $template = str_replace(
821 'Post Views Counter',
822 '<a href="' . esc_url( $this->pvc->get_postviewscounter_url( '/', 'email', 'content-summary', 'email-footer-brand-link', $this->get_email_utm_context( $data ) ) ) . '" style="' . esc_attr( $this->get_email_style( 'footer-link' ) ) . '">Post Views Counter</a>',
823 $template
824 );
825
826 return '<p style="' . esc_attr( $this->get_email_style( 'footer-copy' ) ) . '">' . nl2br( $template ) . '</p>';
827 }
828
829 /**
830 * Get the UTM context for email links to postviewscounter.com.
831 *
832 * @param array $data
833 * @return string
834 */
835 private function get_email_utm_context( $data ) {
836 $context = ! empty( $data['plugin_tier'] ) ? sanitize_key( (string) $data['plugin_tier'] ) : 'free';
837
838 if ( $context === 'pro' )
839 return 'pro-active';
840
841 if ( in_array( $context, [ 'free', 'pro-expired', 'pro-active' ], true ) )
842 return $context;
843
844 return 'free';
845 }
846
847 /**
848 * Wrap the rendered inner content in a full email-safe HTML shell.
849 *
850 * @param string $inner_html
851 * @param string $footer_html
852 * @param array $data
853 * @param array $settings
854 * @param string $summary_type
855 * @return string
856 */
857 private function render_email_document_html( $inner_html, $footer_html, $data, $settings, $summary_type ) {
858 $inner_html = trim( (string) $inner_html );
859 $footer_html = trim( (string) $footer_html );
860 $report_label = $this->get_report_type_label( $data, $summary_type );
861 $period_label = $this->get_period_label( $data );
862 $header_meta = $report_label;
863
864 if ( $period_label !== '' )
865 $header_meta .= ' | ' . $period_label;
866 $title = wp_strip_all_tags( $report_label . ' - ' . $this->get_site_name( $data ), true );
867 $document = '<html>' . "\n";
868 $document .= '<head>' . "\n";
869 $document .= '<meta http-equiv="Content-Type" content="text/html; charset=' . esc_attr( $this->get_charset() ) . '">' . "\n";
870 $document .= '<meta name="viewport" content="width=device-width">' . "\n";
871 $document .= '<title>' . esc_html( $title ) . '</title>' . "\n";
872 $document .= '<style type="text/css">a{color:#2271b1;text-decoration:underline;}@media only screen and (max-width: 599px){table.pvc-email-shell{width:100% !important;}td.pvc-email-wrap{padding:16px !important;}td.pvc-email-panel{padding:24px 20px !important;}}</style>' . "\n";
873 $document .= '</head>' . "\n";
874 $document .= '<body style="' . esc_attr( $this->get_email_style( 'document-body' ) ) . '">' . "\n";
875 $document .= '<table class="pvc-email-body" role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="' . esc_attr( $this->get_email_style( 'document-table' ) ) . '">' . "\n";
876 $document .= '<tbody><tr><td class="pvc-email-wrap" align="center" style="' . esc_attr( $this->get_email_style( 'wrap' ) ) . '">' . "\n";
877 $document .= '<table class="pvc-email-shell" role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="' . esc_attr( $this->get_email_style( 'shell' ) ) . '">' . "\n";
878 $document .= '<tbody>';
879 $document .= '<tr><td class="pvc-email-header" style="' . esc_attr( $this->get_email_style( 'header' ) ) . '">';
880 $document .= '<span style="' . esc_attr( $this->get_email_style( 'header-label' ) ) . '">' . esc_html__( 'Post Views Counter', 'post-views-counter' ) . '</span>';
881 $document .= '<span style="' . esc_attr( $this->get_email_style( 'header-meta' ) ) . '">' . esc_html( $header_meta ) . '</span>';
882 $document .= '</td></tr>';
883 $document .= '<tr><td class="pvc-email-panel" style="' . esc_attr( $this->get_email_style( 'panel' ) ) . '">' . $inner_html . '</td></tr>';
884
885 if ( $footer_html !== '' )
886 $document .= '<tr><td class="pvc-email-footer" style="' . esc_attr( $this->get_email_style( 'footer-wrap' ) ) . '">' . $footer_html . '</td></tr>';
887
888 $document .= '</tbody></table>' . "\n";
889 $document .= '</td></tr></tbody></table>' . "\n";
890 $document .= '</body>' . "\n";
891 $document .= '</html>';
892
893 return $this->sanitize_email_document_html( $document );
894 }
895
896 /**
897 * Get merged inline styles for the email wrapper and shared HTML blocks.
898 *
899 * @param string $selectors
900 * @return string
901 */
902 private function get_email_style( $selectors ) {
903 $available_styles = self::get_email_styles();
904 $selectors = preg_split( '/\s+/', trim( (string) $selectors ) );
905 $styles = [];
906
907 foreach ( $selectors as $selector ) {
908 if ( $selector === '' )
909 continue;
910
911 if ( ! isset( $available_styles[$selector] ) )
912 continue;
913
914 foreach ( $available_styles[$selector] as $property => $value )
915 $styles[$property] = $value;
916 }
917
918 $css = '';
919
920 foreach ( $styles as $property => $value )
921 $css .= $property . ': ' . $value . ';';
922
923 return $css;
924 }
925
926 /**
927 * Get inline style definitions used by the email wrapper and shared blocks.
928 *
929 * @return array
930 */
931 private static function get_email_styles() {
932 return [
933 'document-body' => [
934 'background-color' => '#f8f9fa',
935 'margin' => '0',
936 'padding' => '0',
937 'width' => '100%',
938 'min-width' => '100%',
939 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
940 'font-size' => '14px',
941 'line-height' => '1.6',
942 'color' => '#1f2933',
943 ],
944 'document-table' => [
945 'background-color' => '#f8f9fa',
946 'border-collapse' => 'collapse',
947 'border-spacing' => '0',
948 'margin' => '0',
949 'padding' => '0',
950 'width' => '100%',
951 'min-width' => '100%',
952 ],
953 'wrap' => [
954 'padding' => '24px 12px',
955 ],
956 'shell' => [
957 'border-collapse' => 'collapse',
958 'border-spacing' => '0',
959 'margin' => '0 auto',
960 'max-width' => '600px',
961 'width' => '100%',
962 ],
963 'header' => [
964 'color' => '#6c757d',
965 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
966 'font-size' => '12px',
967 'line-height' => '1.5',
968 'padding' => '0 4px 24px 4px',
969 'text-align' => 'center',
970 ],
971 'header-label' => [
972 'color' => '#212529',
973 'display' => 'block',
974 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
975 'font-size' => '14px',
976 'font-weight' => '700',
977 'line-height' => '1.4',
978 'margin' => '0',
979 'text-align' => 'center',
980 ],
981 'header-meta' => [
982 'color' => '#6c757d',
983 'display' => 'block',
984 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
985 'font-size' => '14px',
986 'line-height' => '1.5',
987 'margin' => '4px 0 0 0',
988 'text-align' => 'center',
989 ],
990 'panel' => [
991 'background-color' => '#ffffff',
992 'border' => '1px solid #dee2e6',
993 'border-radius' => '3px',
994 'color' => '#343a40',
995 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
996 'font-size' => '14px',
997 'line-height' => '1.6',
998 'padding' => '32px 32px 28px 32px',
999 ],
1000 'footer-wrap' => [
1001 'color' => '#6c757d',
1002 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
1003 'font-size' => '12px',
1004 'line-height' => '1.6',
1005 'padding' => '24px 4px 0 4px',
1006 'text-align' => 'center',
1007 ],
1008 'footer-copy' => [
1009 'color' => '#6c757d',
1010 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
1011 'font-size' => '12px',
1012 'line-height' => '1.6',
1013 'margin' => '0',
1014 'text-align' => 'center',
1015 ],
1016 'footer-link' => [
1017 'color' => '#4f5d75',
1018 'text-decoration' => 'underline',
1019 ],
1020 'section' => [
1021 'margin' => '0 0 24px 0',
1022 ],
1023 'body-copy' => [
1024 'color' => '#343a40',
1025 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
1026 'font-size' => '14px',
1027 'line-height' => '1.6',
1028 'margin' => '0 0 24px 0',
1029 ],
1030 'body-copy-last' => [
1031 'margin' => '0',
1032 ],
1033 'section-title' => [
1034 'color' => '#212529',
1035 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
1036 'font-size' => '13px',
1037 'font-weight' => '700',
1038 'line-height' => '1.5',
1039 'margin' => '0 0 8px 0',
1040 ],
1041 'paragraph' => [
1042 'color' => '#343a40',
1043 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
1044 'font-size' => '14px',
1045 'line-height' => '1.6',
1046 'margin' => '0 0 8px 0',
1047 ],
1048 'paragraph-last' => [
1049 'margin' => '0',
1050 ],
1051 'list' => [
1052 'margin' => '0',
1053 'padding-left' => '20px',
1054 ],
1055 'list-item' => [
1056 'color' => '#343a40',
1057 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
1058 'font-size' => '14px',
1059 'line-height' => '1.6',
1060 'margin' => '0 0 8px 0',
1061 ],
1062 'sub-copy' => [
1063 'color' => '#343a40',
1064 'font-family' => '-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif',
1065 'font-size' => '14px',
1066 'line-height' => '1.6',
1067 'margin' => '4px 0 0 0',
1068 ],
1069 ];
1070 }
1071
1072 /**
1073 * Sanitize the plugin-generated full email document.
1074 *
1075 * @param string $html
1076 * @return string
1077 */
1078 private function sanitize_email_document_html( $html ) {
1079 $style_filter = static function( $styles ) {
1080 return array_values( array_unique( array_merge( $styles, self::get_email_document_safe_css() ) ) );
1081 };
1082
1083 add_filter( 'safe_style_css', $style_filter );
1084
1085 try {
1086 return wp_kses( (string) $html, self::get_email_document_allowed_html() );
1087 } finally {
1088 remove_filter( 'safe_style_css', $style_filter );
1089 }
1090 }
1091
1092 /**
1093 * Prevent block replacements from being trapped inside paragraph-like wrappers.
1094 *
1095 * @param string $html
1096 * @param array $placeholders
1097 * @return string
1098 */
1099 private function unwrap_block_placeholders( $html, $placeholders ) {
1100 $html = (string) $html;
1101
1102 foreach ( $placeholders as $placeholder ) {
1103 $quoted = preg_quote( $placeholder, '/' );
1104 $html = preg_replace( '/<(p|div|span|h[1-4])\b([^>]*)>(.*?)<br\s*\/?>\s*' . $quoted . '\s*<\/\1>/is', '<$1$2>$3</$1>' . $placeholder, $html );
1105 $html = preg_replace( '/<(p|div|span|h[1-4])\b[^>]*>\s*' . $quoted . '\s*<\/\1>/i', $placeholder, $html );
1106 }
1107
1108 return $html;
1109 }
1110
1111 /**
1112 * Convert sanitized email HTML into readable plaintext.
1113 *
1114 * @param string $html
1115 * @return string
1116 */
1117 private function convert_html_to_text( $html ) {
1118 $html = $this->sanitize_html_output( $html );
1119
1120 if ( $html === '' )
1121 return '';
1122
1123 $html = preg_replace( '/<\s*br\s*\/?>/i', "\n", $html );
1124 $html = preg_replace( '/<\s*li\b[^>]*>/i', '- ', $html );
1125 $html = preg_replace( '/<\s*\/(?:li)\s*>/i', "\n", $html );
1126 $html = preg_replace( '/<\s*\/(?:p|div|h[1-4]|ul|ol)\s*>/i', "\n\n", $html );
1127
1128 $text = wp_strip_all_tags( $html, true );
1129 $text = html_entity_decode( $text, ENT_QUOTES, $this->get_charset() );
1130 $text = preg_replace( "/\r\n?|\n/", "\n", $text );
1131 $text = preg_replace( "/[ \t]+\n/", "\n", $text );
1132
1133 return preg_replace( "/\n{3,}/", "\n\n", trim( $text ) );
1134 }
1135
1136 /**
1137 * Render the overview block as text.
1138 *
1139 * @param array $data
1140 * @param array $settings
1141 * @return string
1142 */
1143 private function render_report_summary_text( $data, $settings ) {
1144 $summary_type = self::normalize_summary_type_key( ! empty( $data['type'] ) ? $data['type'] : ( ! empty( $data['cadence'] ) ? $data['cadence'] : 'weekly' ) );
1145 $lines = [
1146 __( 'Content views', 'post-views-counter' ),
1147 '',
1148 sprintf( __( '%s total views', 'post-views-counter' ), $this->format_number( $this->get_overview_value( $data, 'total_views' ) ) )
1149 ];
1150
1151 if ( ! empty( $settings['include_period_trend'] ) ) {
1152 $trend = $this->get_trend_summary_text( $data, $settings );
1153
1154 if ( $trend !== '' )
1155 $lines[] = $trend;
1156 }
1157
1158 $lines[] = sprintf( __( '%s content items received views', 'post-views-counter' ), $this->format_number( $this->get_overview_value( $data, 'viewed_content_count' ) ) );
1159
1160 $rendered = implode( "\n", $lines );
1161
1162 return apply_filters( 'pvc_email_summary_report_summary_text', $rendered, $data, $settings, $summary_type, $this );
1163 }
1164
1165 /**
1166 * Render the overview block as HTML.
1167 *
1168 * @param array $data
1169 * @param array $settings
1170 * @return string
1171 */
1172 private function render_report_summary_html( $data, $settings ) {
1173 $summary_type = self::normalize_summary_type_key( ! empty( $data['type'] ) ? $data['type'] : ( ! empty( $data['cadence'] ) ? $data['cadence'] : 'weekly' ) );
1174 $items = [
1175 '<p style="' . esc_attr( $this->get_email_style( 'paragraph' ) ) . '">' . sprintf( esc_html__( '%s total views', 'post-views-counter' ), esc_html( $this->format_number( $this->get_overview_value( $data, 'total_views' ) ) ) ) . '</p>'
1176 ];
1177
1178 if ( ! empty( $settings['include_period_trend'] ) ) {
1179 $trend = $this->get_trend_summary_text( $data, $settings );
1180
1181 if ( $trend !== '' )
1182 $items[] = '<p style="' . esc_attr( $this->get_email_style( 'paragraph' ) ) . '">' . esc_html( $trend ) . '</p>';
1183 }
1184
1185 $items[] = '<p style="' . esc_attr( $this->get_email_style( 'paragraph paragraph-last' ) ) . '">' . sprintf( esc_html__( '%s content items received views', 'post-views-counter' ), esc_html( $this->format_number( $this->get_overview_value( $data, 'viewed_content_count' ) ) ) ) . '</p>';
1186
1187 $rendered = '<div style="' . esc_attr( $this->get_email_style( 'section' ) ) . '">';
1188 $rendered .= '<p style="' . esc_attr( $this->get_email_style( 'section-title' ) ) . '"><strong>' . esc_html__( 'Content views', 'post-views-counter' ) . '</strong></p>';
1189 $rendered .= implode( '', $items );
1190 $rendered .= '</div>';
1191
1192 return apply_filters( 'pvc_email_summary_report_summary_html', $rendered, $data, $settings, $summary_type, $this );
1193 }
1194
1195 /**
1196 * Render the top content block as text.
1197 *
1198 * @param array $data
1199 * @return string
1200 */
1201 private function render_top_content_text( $data ) {
1202 $top_content = isset( $data['top_content'] ) && is_array( $data['top_content'] ) ? $data['top_content'] : [];
1203
1204 if ( empty( $top_content ) )
1205 return __( 'No top content was available for this period.', 'post-views-counter' );
1206
1207 $lines = [ __( 'Top content', 'post-views-counter' ), '' ];
1208
1209 foreach ( $top_content as $index => $item ) {
1210 $title = ! empty( $item['title'] ) ? sanitize_text_field( $item['title'] ) : __( '(Untitled)', 'post-views-counter' );
1211 $views = $this->format_number( isset( $item['current_views'] ) ? $item['current_views'] : 0 );
1212 $lines[] = sprintf( __( '%1$s. %2$s - %3$s views', 'post-views-counter' ), $index + 1, $title, $views );
1213 }
1214
1215 return implode( "\n", $lines );
1216 }
1217
1218 /**
1219 * Render the top content block as HTML.
1220 *
1221 * @param array $data
1222 * @return string
1223 */
1224 private function render_top_content_html( $data ) {
1225 $top_content = isset( $data['top_content'] ) && is_array( $data['top_content'] ) ? $data['top_content'] : [];
1226
1227 if ( empty( $top_content ) )
1228 return '<div style="' . esc_attr( $this->get_email_style( 'section' ) ) . '"><p style="' . esc_attr( $this->get_email_style( 'paragraph paragraph-last' ) ) . '">' . esc_html__( 'No top content was available for this period.', 'post-views-counter' ) . '</p></div>';
1229
1230 $items = [];
1231
1232 foreach ( $top_content as $item ) {
1233 $title = ! empty( $item['title'] ) ? sanitize_text_field( $item['title'] ) : __( '(Untitled)', 'post-views-counter' );
1234 $url = ! empty( $item['url'] ) ? esc_url( $item['url'] ) : '';
1235 $views = $this->format_number( isset( $item['current_views'] ) ? $item['current_views'] : 0 );
1236 $label = $url !== '' ? '<a href="' . $url . '">' . esc_html( $title ) . '</a>' : esc_html( $title );
1237
1238 $items[] = '<li style="' . esc_attr( $this->get_email_style( 'list-item' ) ) . '">' . $label . ' - ' . sprintf( esc_html__( '%s views', 'post-views-counter' ), esc_html( $views ) ) . '</li>';
1239 }
1240
1241 $html = '<div style="' . esc_attr( $this->get_email_style( 'section' ) ) . '">';
1242 $html .= '<p style="' . esc_attr( $this->get_email_style( 'section-title' ) ) . '"><strong>' . esc_html__( 'Top content', 'post-views-counter' ) . '</strong></p>';
1243 $html .= '<ol style="' . esc_attr( $this->get_email_style( 'list' ) ) . '">' . implode( '', $items ) . '</ol>';
1244 $html .= '</div>';
1245
1246 return $html;
1247 }
1248
1249 /**
1250 * Render the traffic signals block as text.
1251 *
1252 * @param array $data
1253 * @param array $settings
1254 * @return string
1255 */
1256 private function render_traffic_signals_text( $data, $settings ) {
1257 $summary_type = self::normalize_summary_type_key( ! empty( $data['type'] ) ? $data['type'] : ( ! empty( $data['cadence'] ) ? $data['cadence'] : 'weekly' ) );
1258 $rendered = '';
1259
1260 if ( empty( $settings['include_traffic_signals'] ) )
1261 return apply_filters( 'pvc_email_summary_traffic_signals_text', $rendered, $data, $settings, $summary_type, $this );
1262
1263 $message = $this->get_traffic_signals_message( $data );
1264
1265 if ( $message === '' )
1266 return apply_filters( 'pvc_email_summary_traffic_signals_text', $rendered, $data, $settings, $summary_type, $this );
1267
1268 $rendered = __( 'Traffic signals', 'post-views-counter' ) . "\n\n" . $message;
1269
1270 return apply_filters( 'pvc_email_summary_traffic_signals_text', $rendered, $data, $settings, $summary_type, $this );
1271 }
1272
1273 /**
1274 * Render the traffic signals block as HTML.
1275 *
1276 * @param array $data
1277 * @param array $settings
1278 * @return string
1279 */
1280 private function render_traffic_signals_html( $data, $settings ) {
1281 $summary_type = self::normalize_summary_type_key( ! empty( $data['type'] ) ? $data['type'] : ( ! empty( $data['cadence'] ) ? $data['cadence'] : 'weekly' ) );
1282 $rendered = '';
1283
1284 if ( empty( $settings['include_traffic_signals'] ) )
1285 return apply_filters( 'pvc_email_summary_traffic_signals_html', $rendered, $data, $settings, $summary_type, $this );
1286
1287 $message = $this->get_traffic_signals_message( $data );
1288 $message_key = ! empty( $data['traffic_signals']['message_key'] ) ? sanitize_key( $data['traffic_signals']['message_key'] ) : 'not_enough_data';
1289 $signal_title = $this->get_traffic_signals_post_value( $data, 'title' );
1290 $signal_url = $this->get_traffic_signals_post_value( $data, 'url' );
1291
1292 if ( $message === '' )
1293 return apply_filters( 'pvc_email_summary_traffic_signals_html', $rendered, $data, $settings, $summary_type, $this );
1294
1295 if ( $message_key === 'anomaly' && $signal_title !== '' && $signal_url !== '' ) {
1296 $message = sprintf(
1297 __( 'Unusual traffic activity was detected for %s.', 'post-views-counter' ),
1298 '<a href="' . esc_url( $signal_url ) . '">' . esc_html( $signal_title ) . '</a>'
1299 );
1300
1301 $rendered = '<div style="' . esc_attr( $this->get_email_style( 'section' ) ) . '"><p style="' . esc_attr( $this->get_email_style( 'section-title' ) ) . '"><strong>' . esc_html__( 'Traffic signals', 'post-views-counter' ) . '</strong></p><p style="' . esc_attr( $this->get_email_style( 'paragraph paragraph-last' ) ) . '">' . $message . '</p></div>';
1302
1303 return apply_filters( 'pvc_email_summary_traffic_signals_html', $rendered, $data, $settings, $summary_type, $this );
1304 }
1305
1306 $rendered = '<div style="' . esc_attr( $this->get_email_style( 'section' ) ) . '"><p style="' . esc_attr( $this->get_email_style( 'section-title' ) ) . '"><strong>' . esc_html__( 'Traffic signals', 'post-views-counter' ) . '</strong></p><p style="' . esc_attr( $this->get_email_style( 'paragraph paragraph-last' ) ) . '">' . esc_html( $message ) . '</p></div>';
1307
1308 return apply_filters( 'pvc_email_summary_traffic_signals_html', $rendered, $data, $settings, $summary_type, $this );
1309 }
1310
1311 /**
1312 * Render the manage Emails link as text.
1313 *
1314 * @param array $data
1315 * @return string
1316 */
1317 private function render_manage_emails_link_text( $data ) {
1318 return sprintf( __( 'Manage Emails in WordPress: %s', 'post-views-counter' ), esc_url_raw( $this->get_manage_emails_url( $data ) ) );
1319 }
1320
1321 /**
1322 * Render the manage Emails link as HTML.
1323 *
1324 * @param array $data
1325 * @return string
1326 */
1327 private function render_manage_emails_link_html( $data ) {
1328 $url = esc_url( $this->get_manage_emails_url( $data ) );
1329
1330 return '<div style="margin:0 0 24px;"><a href="' . $url . '">' . esc_html__( 'Manage Emails in WordPress', 'post-views-counter' ) . '</a></div>';
1331 }
1332
1333 /**
1334 * Render the site link as text.
1335 *
1336 * @param array $data
1337 * @return string
1338 */
1339 private function render_site_link_text( $data ) {
1340 return sprintf( __( 'Visit your site: %s', 'post-views-counter' ), esc_url_raw( $this->get_site_url( $data ) ) );
1341 }
1342
1343 /**
1344 * Render the site link as HTML.
1345 *
1346 * @param array $data
1347 * @return string
1348 */
1349 private function render_site_link_html( $data ) {
1350 $url = esc_url( $this->get_site_url( $data ) );
1351 $label = ! empty( $data['site']['name'] ) ? sanitize_text_field( $data['site']['name'] ) : __( 'your site', 'post-views-counter' );
1352
1353 return '<div style="margin:0 0 24px;"><a href="' . $url . '">' . sprintf( esc_html__( 'Visit %s', 'post-views-counter' ), esc_html( $label ) ) . '</a></div>';
1354 }
1355
1356 /**
1357 * Get the traffic signals message.
1358 *
1359 * @param array $data
1360 * @return string
1361 */
1362 private function get_traffic_signals_message( $data ) {
1363 $message_key = ! empty( $data['traffic_signals']['message_key'] ) ? sanitize_key( $data['traffic_signals']['message_key'] ) : 'not_enough_data';
1364 $signal_title = $this->get_traffic_signals_post_value( $data, 'title' );
1365
1366 switch ( $message_key ) {
1367 case 'no_data':
1368 return '';
1369
1370 case 'no_visible_post_types':
1371 return '';
1372
1373 case 'anomaly':
1374 if ( $signal_title !== '' )
1375 return sprintf( __( 'Unusual traffic activity was detected for %s.', 'post-views-counter' ), $signal_title );
1376
1377 return __( 'Unusual traffic activity was detected during this report period.', 'post-views-counter' );
1378
1379 case 'silence':
1380 return __( 'No unusual traffic activity was detected during this report period.', 'post-views-counter' );
1381 }
1382
1383 return '';
1384 }
1385
1386 /**
1387 * Get the period label.
1388 *
1389 * @param array $data
1390 * @return string
1391 */
1392 private function get_period_label( $data ) {
1393 return ! empty( $data['period']['label'] ) ? sanitize_text_field( $data['period']['label'] ) : '';
1394 }
1395
1396 /**
1397 * Get the normalized report cadence key.
1398 *
1399 * @param array $data
1400 * @param string $summary_type
1401 * @return string
1402 */
1403 private function get_report_cadence_key( $data, $summary_type = 'weekly' ) {
1404 $cadence = ! empty( $data['cadence'] ) ? sanitize_key( $data['cadence'] ) : sanitize_key( $summary_type );
1405
1406 return self::normalize_summary_type_key( $cadence );
1407 }
1408
1409 /**
1410 * Get the comparison period label for trend text.
1411 *
1412 * @param array $data
1413 * @return string
1414 */
1415 private function get_trend_comparison_period_label( $data ) {
1416 switch ( $this->get_report_cadence_key( $data ) ) {
1417 case 'daily':
1418 return __( 'day', 'post-views-counter' );
1419
1420 case 'monthly':
1421 return __( 'month', 'post-views-counter' );
1422 }
1423
1424 return __( 'week', 'post-views-counter' );
1425 }
1426
1427 /**
1428 * Normalize a summary cadence key without depending on the period service load order.
1429 *
1430 * @param string $summary_type
1431 * @return string
1432 */
1433 private static function normalize_summary_type_key( $summary_type ) {
1434 $summary_type = sanitize_key( (string) $summary_type );
1435 $supported = apply_filters( 'pvc_email_summary_supported_types', [ 'weekly' ] );
1436
1437 if ( ! is_array( $supported ) )
1438 $supported = [ 'weekly' ];
1439
1440 $supported = array_values(
1441 array_filter(
1442 array_map(
1443 static function( $type ) {
1444 return sanitize_key( (string) $type );
1445 },
1446 $supported
1447 )
1448 )
1449 );
1450
1451 if ( ! in_array( 'weekly', $supported, true ) )
1452 $supported[] = 'weekly';
1453
1454 if ( in_array( $summary_type, $supported, true ) )
1455 return $summary_type;
1456
1457 return 'weekly';
1458 }
1459
1460 /**
1461 * Get the report label.
1462 *
1463 * @param array $data
1464 * @param string $summary_type
1465 * @return string
1466 */
1467 private function get_report_label( $data, $summary_type = 'weekly' ) {
1468 $cadence = $this->get_report_cadence_key( $data, $summary_type );
1469 $label = __( 'weekly', 'post-views-counter' );
1470 $label = apply_filters( 'pvc_email_summary_report_label', $label, $cadence, $data, $summary_type );
1471
1472 return sanitize_text_field( (string) $label );
1473 }
1474
1475 /**
1476 * Get the report type label.
1477 *
1478 * @param array $data
1479 * @param string $summary_type
1480 * @return string
1481 */
1482 private function get_report_type_label( $data, $summary_type = 'weekly' ) {
1483 $cadence = $this->get_report_cadence_key( $data, $summary_type );
1484 $label = __( 'Weekly Content Views Summary', 'post-views-counter' );
1485 $label = apply_filters( 'pvc_email_summary_report_type_label', $label, $cadence, $data, $summary_type );
1486
1487 return sanitize_text_field( (string) $label );
1488 }
1489
1490 /**
1491 * Get a formatted period date.
1492 *
1493 * @param array $data
1494 * @param string $key
1495 * @return string
1496 */
1497 private function get_period_date( $data, $key ) {
1498 if ( empty( $data['period'][$key] ) )
1499 return '';
1500
1501 $timezone = $this->get_period_timezone( $data );
1502 $datetime = DateTimeImmutable::createFromFormat( 'Y-m-d', $data['period'][$key], $timezone );
1503
1504 if ( ! $datetime )
1505 return sanitize_text_field( $data['period'][$key] );
1506
1507 return wp_date( get_option( 'date_format' ), $datetime->getTimestamp(), $timezone );
1508 }
1509
1510 /**
1511 * Get the period timezone.
1512 *
1513 * @param array $data
1514 * @return DateTimeZone
1515 */
1516 private function get_period_timezone( $data ) {
1517 if ( ! empty( $data['period']['timezone'] ) ) {
1518 try {
1519 return new DateTimeZone( $data['period']['timezone'] );
1520 } catch ( Exception $e ) {
1521 return wp_timezone();
1522 }
1523 }
1524
1525 return wp_timezone();
1526 }
1527
1528 /**
1529 * Get an overview value.
1530 *
1531 * @param array $data
1532 * @param string $key
1533 * @return int|float
1534 */
1535 private function get_overview_value( $data, $key ) {
1536 return isset( $data['overview'][$key] ) ? $data['overview'][$key] : 0;
1537 }
1538
1539 /**
1540 * Get a value from the first top-content item.
1541 *
1542 * @param array $data
1543 * @param string $key
1544 * @return mixed
1545 */
1546 private function get_top_content_value( $data, $key ) {
1547 if ( empty( $data['top_content'][0] ) || ! is_array( $data['top_content'][0] ) )
1548 return '';
1549
1550 return isset( $data['top_content'][0][$key] ) ? $data['top_content'][0][$key] : '';
1551 }
1552
1553 /**
1554 * Get a value for the traffic signals content item.
1555 *
1556 * @param array $data
1557 * @param string $key
1558 * @return mixed
1559 */
1560 private function get_traffic_signals_post_value( $data, $key ) {
1561 $post_id = ! empty( $data['traffic_signals']['post_id'] ) ? (int) $data['traffic_signals']['post_id'] : 0;
1562
1563 if ( $post_id <= 0 )
1564 return '';
1565
1566 if ( ! empty( $data['top_content'] ) && is_array( $data['top_content'] ) ) {
1567 foreach ( $data['top_content'] as $item ) {
1568 if ( ! is_array( $item ) || empty( $item['post_id'] ) || (int) $item['post_id'] !== $post_id )
1569 continue;
1570
1571 return isset( $item[$key] ) ? $item[$key] : '';
1572 }
1573 }
1574
1575 if ( $key === 'title' )
1576 return sanitize_text_field( get_the_title( $post_id ) );
1577
1578 if ( $key === 'url' ) {
1579 $url = get_permalink( $post_id );
1580
1581 return is_string( $url ) ? esc_url_raw( $url ) : '';
1582 }
1583
1584 return '';
1585 }
1586
1587 /**
1588 * Format a number for output.
1589 *
1590 * @param int|float|string $value
1591 * @return string
1592 */
1593 private function format_number( $value ) {
1594 return number_format_i18n( (float) $value, 0 );
1595 }
1596
1597 /**
1598 * Get a formatted signed views change string.
1599 *
1600 * @param array $data
1601 * @param array $settings
1602 * @return string
1603 */
1604 private function get_formatted_views_change( $data, $settings ) {
1605 if ( empty( $settings['include_period_trend'] ) || empty( $data['overview']['trend_reliable'] ) )
1606 return '';
1607
1608 $change = (int) $this->get_overview_value( $data, 'views_change' );
1609
1610 return $this->format_signed_number( $change ) . ' ' . __( 'views', 'post-views-counter' );
1611 }
1612
1613 /**
1614 * Get a formatted signed percentage change string.
1615 *
1616 * @param array $data
1617 * @param array $settings
1618 * @return string
1619 */
1620 private function get_formatted_views_change_percent( $data, $settings ) {
1621 if ( empty( $settings['include_period_trend'] ) || empty( $data['overview']['trend_reliable'] ) || ! isset( $data['overview']['views_change_percent'] ) )
1622 return '';
1623
1624 $percent = (float) $data['overview']['views_change_percent'];
1625
1626 return $this->format_signed_decimal( $percent ) . '%';
1627 }
1628
1629 /**
1630 * Get a trend summary string.
1631 *
1632 * @param array $data
1633 * @param array $settings
1634 * @return string
1635 */
1636 private function get_trend_summary_text( $data, $settings ) {
1637 if ( empty( $settings['include_period_trend'] ) )
1638 return '';
1639
1640 $comparison_period = $this->get_trend_comparison_period_label( $data );
1641
1642 if ( empty( $data['overview']['trend_reliable'] ) )
1643 return sprintf( __( 'No previous %s comparison is available yet', 'post-views-counter' ), $comparison_period );
1644
1645 $change_value = (int) $this->get_overview_value( $data, 'views_change' );
1646 $percent = $this->get_formatted_views_change_percent( $data, $settings );
1647
1648 if ( $change_value === 0 )
1649 return sprintf( __( 'No change from the previous %s', 'post-views-counter' ), $comparison_period );
1650
1651 $change = $this->format_number( abs( $change_value ) );
1652
1653 if ( $change_value > 0 )
1654 $summary = sprintf( __( 'Up %1$s views from the previous %2$s', 'post-views-counter' ), $change, $comparison_period );
1655 else
1656 $summary = sprintf( __( 'Down %1$s views from the previous %2$s', 'post-views-counter' ), $change, $comparison_period );
1657
1658 if ( $percent !== '' )
1659 return $summary . ' (' . $percent . ')';
1660
1661 return $summary;
1662 }
1663
1664 /**
1665 * Get the site name.
1666 *
1667 * @param array $data
1668 * @return string
1669 */
1670 private function get_site_name( $data ) {
1671 if ( ! empty( $data['site']['name'] ) )
1672 return sanitize_text_field( $data['site']['name'] );
1673
1674 return get_bloginfo( 'name' );
1675 }
1676
1677 /**
1678 * Get the site URL.
1679 *
1680 * @param array $data
1681 * @return string
1682 */
1683 private function get_site_url( $data ) {
1684 if ( ! empty( $data['links']['site_url'] ) )
1685 return $data['links']['site_url'];
1686
1687 return home_url( '/' );
1688 }
1689
1690 /**
1691 * Get the Emails settings URL.
1692 *
1693 * @param array $data
1694 * @return string
1695 */
1696 private function get_manage_emails_url( $data ) {
1697 if ( ! empty( $data['links']['emails_settings_url'] ) )
1698 return $data['links']['emails_settings_url'];
1699
1700 return admin_url( 'admin.php?page=post-views-counter&tab=emails' );
1701 }
1702
1703 /**
1704 * Format a signed integer string.
1705 *
1706 * @param int $value
1707 * @return string
1708 */
1709 private function format_signed_number( $value ) {
1710 $formatted = $this->format_number( absint( $value ) );
1711
1712 if ( (int) $value > 0 )
1713 return '+' . $formatted;
1714
1715 if ( (int) $value < 0 )
1716 return '-' . $formatted;
1717
1718 return $formatted;
1719 }
1720
1721 /**
1722 * Format a signed decimal string.
1723 *
1724 * @param float $value
1725 * @return string
1726 */
1727 private function format_signed_decimal( $value ) {
1728 $formatted = number_format_i18n( abs( (float) $value ), 2 );
1729
1730 if ( (float) $value > 0 )
1731 return '+' . $formatted;
1732
1733 if ( (float) $value < 0 )
1734 return '-' . $formatted;
1735
1736 return $formatted;
1737 }
1738
1739 /**
1740 * Sanitize plaintext output.
1741 *
1742 * @param string $text
1743 * @param bool $allow_newlines
1744 * @return string
1745 */
1746 private function sanitize_text_output( $text, $allow_newlines = false ) {
1747 $text = (string) $text;
1748
1749 return $allow_newlines ? sanitize_textarea_field( $text ) : sanitize_text_field( $text );
1750 }
1751
1752 /**
1753 * Sanitize rendered HTML output.
1754 *
1755 * @param string $html
1756 * @return string
1757 */
1758 private function sanitize_html_output( $html ) {
1759 return self::sanitize_template_html( $html, true );
1760 }
1761
1762 /**
1763 * Get the email-safe HTML allowlist for the full wrapper document.
1764 *
1765 * @return array
1766 */
1767 private static function get_email_document_allowed_html() {
1768 $allowed_html = self::get_email_template_allowed_html( true );
1769 $global_attributes = [
1770 'class' => true,
1771 'id' => true,
1772 'title' => true,
1773 'aria-label' => true,
1774 'style' => true,
1775 ];
1776
1777 $allowed_html['html'] = [
1778 'lang' => true,
1779 'dir' => true,
1780 ];
1781 $allowed_html['head'] = [];
1782 $allowed_html['body'] = $global_attributes;
1783 $allowed_html['meta'] = [
1784 'charset' => true,
1785 'content' => true,
1786 'http-equiv' => true,
1787 'name' => true,
1788 ];
1789 $allowed_html['style'] = [
1790 'media' => true,
1791 'type' => true,
1792 ];
1793 $allowed_html['title'] = [];
1794 $allowed_html['table'] = array_merge(
1795 $global_attributes,
1796 [
1797 'align' => true,
1798 'border' => true,
1799 'cellpadding' => true,
1800 'cellspacing' => true,
1801 'role' => true,
1802 'width' => true,
1803 ]
1804 );
1805 $allowed_html['tbody'] = $global_attributes;
1806 $allowed_html['thead'] = $global_attributes;
1807 $allowed_html['tfoot'] = $global_attributes;
1808 $allowed_html['tr'] = $global_attributes;
1809 $allowed_html['td'] = array_merge(
1810 $global_attributes,
1811 [
1812 'align' => true,
1813 'colspan' => true,
1814 'height' => true,
1815 'rowspan' => true,
1816 'valign' => true,
1817 'width' => true,
1818 ]
1819 );
1820 $allowed_html['th'] = $allowed_html['td'];
1821
1822 return $allowed_html;
1823 }
1824
1825 /**
1826 * Get the email-safe HTML allowlist.
1827 *
1828 * @param bool $allow_styles
1829 * @return array
1830 */
1831 private static function get_email_template_allowed_html( $allow_styles = false ) {
1832 $post_allowed_html = wp_kses_allowed_html( 'post' );
1833 $allowed_tags = [ 'a', 'b', 'br', 'div', 'em', 'h1', 'h2', 'h3', 'h4', 'i', 'li', 'ol', 'p', 'span', 'strong', 'u', 'ul' ];
1834 $global_attributes = [
1835 'class' => true,
1836 'id' => true,
1837 'title' => true,
1838 'aria-label' => true
1839 ];
1840 $allowed_html = [];
1841
1842 foreach ( $allowed_tags as $tag ) {
1843 $allowed_html[$tag] = array_intersect_key( isset( $post_allowed_html[$tag] ) ? $post_allowed_html[$tag] : [], $global_attributes );
1844 $allowed_html[$tag] = array_merge( $allowed_html[$tag], $global_attributes );
1845
1846 if ( $allow_styles && $tag !== 'br' )
1847 $allowed_html[$tag]['style'] = true;
1848 }
1849
1850 $allowed_html['a'] = array_merge(
1851 $allowed_html['a'],
1852 [
1853 'href' => true,
1854 'target' => true,
1855 'rel' => true
1856 ]
1857 );
1858
1859 $allowed_html['br'] = [];
1860
1861 return $allowed_html;
1862 }
1863
1864 /**
1865 * Get the safe inline CSS properties for plugin-generated email HTML.
1866 *
1867 * @return array
1868 */
1869 private static function get_email_template_safe_css() {
1870 return [
1871 'background-color',
1872 'border',
1873 'border-collapse',
1874 'border-spacing',
1875 'box-sizing',
1876 'color',
1877 'display',
1878 'font-family',
1879 'font-size',
1880 'font-weight',
1881 'height',
1882 'line-height',
1883 'margin',
1884 'margin-bottom',
1885 'margin-left',
1886 'margin-right',
1887 'margin-top',
1888 'max-width',
1889 'min-width',
1890 'overflow-wrap',
1891 'padding',
1892 'padding-bottom',
1893 'padding-left',
1894 'padding-right',
1895 'padding-top',
1896 'text-align',
1897 'text-decoration',
1898 'vertical-align',
1899 'width',
1900 'word-break',
1901 'word-wrap'
1902 ];
1903 }
1904
1905 /**
1906 * Get the safe inline CSS properties for the full email document wrapper.
1907 *
1908 * @return array
1909 */
1910 private static function get_email_document_safe_css() {
1911 return self::get_email_template_safe_css();
1912 }
1913
1914 /**
1915 * Mask template tags so KSES does not strip them from safe attributes.
1916 *
1917 * @param string $html
1918 * @return array
1919 */
1920 private static function mask_template_tags( $html ) {
1921 $index = 0;
1922 $replacements = [];
1923 $html = preg_replace_callback(
1924 '/%%[a-z0-9_]+%%/i',
1925 static function( $matches ) use ( &$index, &$replacements ) {
1926 $placeholder = 'https://pvc-template-tag.local/' . $index . '/';
1927 $replacements[$placeholder] = $matches[0];
1928 $index++;
1929
1930 return $placeholder;
1931 },
1932 (string) $html
1933 );
1934
1935 return [
1936 'html' => $html,
1937 'replacements' => $replacements
1938 ];
1939 }
1940
1941 /**
1942 * Restore masked template tags after sanitization.
1943 *
1944 * @param string $html
1945 * @param array $replacements
1946 * @return string
1947 */
1948 private static function restore_masked_template_tags( $html, $replacements ) {
1949 if ( empty( $replacements ) || ! is_array( $replacements ) )
1950 return (string) $html;
1951
1952 return strtr( (string) $html, $replacements );
1953 }
1954
1955 /**
1956 * Get the blog charset with a safe fallback.
1957 *
1958 * @return string
1959 */
1960 private function get_charset() {
1961 $charset = get_bloginfo( 'charset' );
1962
1963 return is_string( $charset ) && $charset !== '' ? $charset : 'UTF-8';
1964 }
1965
1966 /**
1967 * Backward-compatible wrapper for deterministic PVC link parameters.
1968 *
1969 * @param string $url
1970 * @param string $medium
1971 * @param string $context
1972 * @param string $content
1973 * @return string
1974 */
1975 public function add_pvc_utm_args( $url, $medium = 'email', $context = 'free', $content = 'content-summary' ) {
1976 return $this->pvc->add_postviewscounter_utm_args( $url, $medium, 'content-summary', $content, $context );
1977 }
1978 }
1979