schedule) ) { wp_clear_scheduled_hook( self::HOOK ); // altes 30-Tage-Intervall ablösen $next = strtotime( 'tomorrow 08:00', current_time( 'timestamp' ) ) - (current_time( 'timestamp' ) - time()); wp_schedule_event( $next, 'daily', self::HOOK ); } elseif ( !$enabled && $event ) { wp_clear_scheduled_hook( self::HOOK ); } } /** Reine Entscheidung (testbar): heute senden? */ public static function is_due( $day_of_month, $month, $last_sent ) { return (int) $day_of_month >= self::SEND_DAY && (string) $last_sent !== (string) $month; } /** Vom täglichen Cron aufgerufen: sendet am SEND_DAY (oder danach nachholend). */ public static function maybe_send() { $s = get_option( 'adsec_settings', array() ); if ( empty( $s['report_enabled'] ) ) { return; } $month = wp_date( 'Y-m' ); $last = get_option( self::LAST_SENT, false ); // Allererster Lauf nach dem Einschalten: nur den Startpunkt merken. Sonst käme // der erste Bericht sofort am nächsten Morgen statt am kommenden SEND_DAY. if ( false === $last ) { update_option( self::LAST_SENT, ( (int) wp_date( 'j' ) >= self::SEND_DAY ? $month : '' ), false ); return; } if ( !self::is_due( wp_date( 'j' ), $month, $last ) ) { return; } $to = trim( (string) ($s['report_email'] ?? '') ); // Nur ein tatsächlich verschickter Bericht zählt. Wartet die Adresse noch auf // Bestätigung, versucht es der nächste Tageslauf erneut (der Dienst schickt die // Bestätigung höchstens alle 3 Tage und den Bericht selbst direkt nach dem Klick). if ( '' !== $to && is_email( $to ) && 'sent' === self::send_now( $to ) ) { update_option( self::LAST_SENT, $month, false ); } } /** * Bericht sofort an $to senden (auch für „Bericht jetzt senden"). * * Seit 0.41.0 über den Sectivio-Dienst und unseren eigenen Mailserver: vom * Kundenserver mit Absender @sectivio.com fehlte der Echtheitsnachweis (Spam). * Neue Empfänger bestätigen einmal per Link (Schutz vor Missbrauch). * * @return string sent | confirm_sent | confirm_pending | stopped | error */ public static function send_now( $to ) { $site = (string) wp_parse_url( home_url(), PHP_URL_HOST ); /* translators: %s: website domain. */ $subject = sprintf( __( 'Your Sectivio monthly report for %s', 'sectivio' ), $site ); $html = self::build_html( self::collect(), $site ); $base = ADSEC_AI::service_url(); // Ohne Zustimmung zum AV-Vertrag nicht über unseren Mailserver (Art. 28 DSGVO). if ( '' === $base || !ADSEC_Plugin::terms_accepted() ) { return ( self::send_local( $to, $subject, $html ) ? 'sent' : 'error' ); } $res = wp_remote_post( trailingslashit( $base ) . 'v1/report/send', array( 'timeout' => 30, 'headers' => array( 'content-type' => 'application/json', 'x-adsec-mailkey' => self::mail_key(), ), 'body' => wp_json_encode( array( 'site' => ADSEC_AI::site_id(), 'host' => preg_replace( '#^www\\.#', '', strtolower( $site ) ), 'to' => strtolower( $to ), 'subject' => $subject, 'html' => $html, 'lang' => ( 0 === strpos( (string) get_locale(), 'de' ) ? 'de' : 'en' ), ) ), ) ); if ( is_wp_error( $res ) ) { return 'error'; } $body = json_decode( (string) wp_remote_retrieve_body( $res ), true ); $status = ( is_array( $body ) ? (string) ($body['status'] ?? '') : '' ); if ( 'not_configured' === $status ) { // Dienst ohne Mailserver-Zugang (Übergangszeit): wie bisher über die Website selbst. return ( self::send_local( $to, $subject, $html ) ? 'sent' : 'error' ); } return ( in_array( $status, array( 'sent', 'confirm_sent', 'confirm_pending', 'stopped' ), true ) ? $status : 'error' ); } /** * Geheimer Schlüssel dieser Website für den Berichtsversand. Bindet die * Empfänger-Bestätigung an genau diese Installation: Wer nur Domain und Adresse * kennt, kann über den Dienst keinen Bericht an sie schicken. */ private static function mail_key() { $key = (string) get_option( 'adsec_mail_key', '' ); if ( !preg_match( '/^[A-Za-z0-9]{32}$/', $key ) ) { $key = wp_generate_password( 32, false, false ); update_option( 'adsec_mail_key', $key, false ); } return $key; } /** Nur ohne Sectivio-Dienst: Versand über die Website selbst, Absender auf der eigenen Domain. */ private static function send_local( $to, $subject, $html ) { $domain = preg_replace( '/^www\\./', '', (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $logo = ADSEC_PLUGIN_DIR . 'assets/img/sectivio-logo-light-2x.png'; $embed = static function ( $mailer ) use($logo) { if ( is_readable( $logo ) ) { $mailer->AddEmbeddedImage( $logo, 'sectivio-logo', 'sectivio-logo.png', 'base64', 'image/png' ); } }; add_action( 'phpmailer_init', $embed ); $ok = wp_mail( $to, $subject, $html, array('Content-Type: text/html; charset=UTF-8', 'From: Sectivio ') ); remove_action( 'phpmailer_init', $embed ); return (bool) $ok; } /** Rückwärtskompatibel (älterer Cron-Hook rief send() auf). */ public static function send() { self::maybe_send(); } /** Alle Zahlen für den Bericht: Status, letzte 30 Tage, Monatsverlauf und Empfehlungen. */ private static function collect() { global $wpdb; $t = ADSEC_Database::events_table(); $sec = ADSEC_Database::sections_table(); $d = ADSEC_Dashboard::quick_summary(); $d['all'] = ADSEC_Dashboard::group_metrics( null, gmdate( 'Y-m-d', time() - 30 * DAY_IN_SECONDS ) ); $d['goals'] = (int) $d['all']['goal_sessions']; $d['first'] = ADSEC_Daily::first_day(); $d['months'] = ADSEC_Daily::monthly( 6 ); $d['hints'] = ADSEC_Hints::for_context( 'report', 20 ); // alle Empfehlungen für diese Website // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $d['pages'] = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(DISTINCT page_id) FROM %i WHERE excluded = 0', $sec ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $d['secs'] = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE excluded = 0', $sec ) ); $d['top'] = array(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $rows = $wpdb->get_results( $wpdb->prepare( // Durchschnitt als eigene Spalte: MariaDB erlaubt keine Rechnung mit // Aggregat-Aliasen in ORDER BY („Reference 'dwell' not supported"). "SELECT section_id, page_id, SUM(value) AS dwell, COUNT(DISTINCT session_key) AS seen,\n\t\t\t SUM(value) / COUNT(DISTINCT session_key) AS avg_dwell\n\t\t\t FROM %i WHERE event_type = 'dwell'\n\t\t\t GROUP BY section_id, page_id HAVING seen >= 3\n\t\t\t ORDER BY avg_dwell DESC LIMIT 3", $t ), ARRAY_A ); foreach ( (array) $rows as $r ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table. $info = $wpdb->get_row( $wpdb->prepare( 'SELECT heading, ai_label, excerpt FROM %i WHERE uid = %s AND page_id = %d', $sec, $r['section_id'], $r['page_id'] ), ARRAY_A ); $name = ''; foreach ( array('heading', 'ai_label', 'excerpt') as $k ) { if ( !empty( $info[$k] ) ) { $name = wp_html_excerpt( $info[$k], 60, '…' ); break; } } $d['top'][] = array( 'name' => ( '' !== $name ? $name : __( 'Section without heading', 'sectivio' ) ), 'page' => ( (int) $r['page_id'] ? get_the_title( (int) $r['page_id'] ) : __( 'Home page', 'sectivio' ) ), 'secs' => (float) $r['dwell'] / max( 1, (int) $r['seen'] ), ); } return $d; } /** Sekunden lesbar: „45 s", „2 Min 10 s". */ private static function duration( $sec ) { $sec = (int) round( $sec ); if ( $sec < 60 ) { /* translators: %d: number of seconds. */ return sprintf( __( '%d s', 'sectivio' ), $sec ); } $m = intdiv( $sec, 60 ); $r = $sec % 60; /* translators: 1: minutes, 2: seconds (or %d: minutes). */ return ( $r ? sprintf( __( '%1$d min %2$d s', 'sectivio' ), $m, $r ) : sprintf( __( '%d min', 'sectivio' ), $m ) ); } /** Farbiges Delta (▲ +x % grün / ▼ −x % rot / „neu" / „—"). */ private static function delta_badge( $before, $after ) { if ( $before > 0 ) { $pct = ($after / $before - 1) * 100; $up = $pct >= 0; $color = ( $up ? '#0a7d4d' : '#b42318' ); $txt = (( $up ? '▲ +' : '▼ −' )) . ADSEC_Plugin::pct( number_format_i18n( abs( $pct ), 1 ) ); } elseif ( $after > 0 ) { $color = '#0a7d4d'; $txt = __( 'new', 'sectivio' ); } else { $color = '#9aa3b2'; $txt = '—'; } return '' . esc_html( $txt ) . ''; } /** Vergleichszeile vorher → jetzt + Delta. */ private static function compare_row( $label, $before, $after, $badge ) { return '' . esc_html( $label ) . '' . '' . esc_html( $before ) . ' → ' . esc_html( $after ) . '' . '' . $badge . ''; } /** Kachel für die Kennzahlen-Übersicht. */ private static function tile( $value, $label, $left ) { return '' . '
' . '
' . esc_html( $value ) . '
' . '
' . esc_html( $label ) . '
' . '
'; } /** Überschrift eines Berichtsteils. */ private static function h2( $text ) { return '

' . esc_html( $text ) . '

'; } /** Kompletter HTML-Body des Berichts. */ private static function build_html( $d, $site ) { $dash = admin_url( 'admin.php?page=adaptive-sections' ); $settings = admin_url( 'admin.php?page=adsec-settings' ); $all = $d['all']; $from = wp_date( _x( 'F j', 'report date without year', 'sectivio' ), time() - 30 * DAY_IN_SECONDS ); $to = wp_date( _x( 'F j, Y', 'report date', 'sectivio' ) ); // Status in einem Satz + passende Farbe. $status = ( empty( $d['enabled'] ) ? array( '#b45309', '#fff7ed', __( 'Paused', 'sectivio' ), __( 'Measurement is turned off. Turn it on in the settings to see which content your visitors read.', 'sectivio' ) ) : array( '#0a7d4d', '#ecfdf3', __( 'Measuring', 'sectivio' ), __( 'Sectivio measures which content your visitors read and which leads to an enquiry. Your pages stay unchanged.', 'sectivio' ) ) ); $progress = ''; $compare = ''; $html = '
' . '
' . esc_html( $status[2] ) . '
' . '
' . esc_html( $status[3] ) . '
'; $html .= $progress; $html .= '
'; // Kennzahlen der letzten 30 Tage. $html .= self::h2( __( 'Your visitors in the last 30 days', 'sectivio' ) ); $html .= '' . self::tile( number_format_i18n( $all['sessions'] ), __( 'Visits', 'sectivio' ), true ) . self::tile( number_format_i18n( $d['goals'] ), __( 'Enquiries', 'sectivio' ), false ) . '' . self::tile( self::duration( $all['dwell_avg'] ), __( 'Reading time per visit', 'sectivio' ), true ) . self::tile( number_format_i18n( $all['pages_per_session'], 1 ), __( 'Pages per visit', 'sectivio' ), false ) . '
'; $html .= $compare; // Entwicklung über die letzten Monate (aus den Tageszusammenfassungen). if ( count( $d['months'] ) >= 2 ) { $max = max( 1, max( array_map( 'intval', array_column( $d['months'], 'visits' ) ) ) ); $html .= self::h2( __( 'Development over the last few months', 'sectivio' ) ); $html .= '' . ''; $cur = gmdate( 'Y-m' ); foreach ( $d['months'] as $m ) { $label = wp_date( 'F Y', strtotime( $m['ym'] . '-15 12:00 UTC' ) ) . (( $m['ym'] === $cur ? ' ' . __( '(so far)', 'sectivio' ) : '' )); $w = max( 2, (int) round( (int) $m['visits'] / $max * 100 ) ); $html .= '' . '' . ''; } $html .= '
' . esc_html__( 'Month', 'sectivio' ) . '' . esc_html__( 'Visits', 'sectivio' ) . '' . esc_html__( 'Enquiries', 'sectivio' ) . '
' . esc_html( $label ) . '' . '
 ' . esc_html( number_format_i18n( (int) $m['visits'] ) ) . '
' . esc_html( number_format_i18n( (int) $m['goals'] ) ) . '
'; } // Meistgelesene Abschnitte. if ( !empty( $d['top'] ) ) { $html .= self::h2( __( 'Your visitors read this content the longest', 'sectivio' ) ); $html .= ''; // Translator comment on its own statement: Freemius' code formatter drops comments inside expressions. /* translators: %s: page title. */ $on_page = __( 'on the page “%s”', 'sectivio' ); foreach ( $d['top'] as $i => $row ) { $html .= '' . ''; } $html .= '
' . '
' . esc_html( $row['name'] ) . '
' . '
' . esc_html( sprintf( $on_page, $row['page'] ) ) . '
' . esc_html( self::duration( $row['secs'] ) ) . '
'; } $html .= self::h2( __( 'Your content', 'sectivio' ) ); $html .= '

' . esc_html( sprintf( /* translators: 1: number of sections, 2: number of pages. */ __( 'Sectivio knows %1$s sections on %2$s pages of your website.', 'sectivio' ), number_format_i18n( $d['secs'] ), number_format_i18n( $d['pages'] ) ) ) . '

'; // Empfehlungen (dieselben wie im Dashboard, dort per Klick umsetzbar). if ( !empty( $d['hints'] ) ) { $html .= self::h2( __( 'Our recommendations', 'sectivio' ) ); foreach ( $d['hints'] as $h ) { $html .= '
' . esc_html( $h['text'] ) . '
'; } $html .= '

' . esc_html__( 'In the dashboard, you can apply any recommendation with one click.', 'sectivio' ) . '

'; } ob_start(); ?>
Sectivio

', $html, (string) ob_get_clean() ); } }