>,kept:array>,counts:array} */ public static function build(string $month, bool $everyChange = false): array { [$from, $to, $label] = self::bounds($month); $changes = []; foreach (Journal::between($from, $to) as $entry) { if ($entry['ability'] === 'sarcio/changes-undo' || (!$everyChange && !JournalAbilities::visible($entry))) { continue; } $changes[] = [ 'at' => $entry['createdAt'], 'by' => self::userName($entry['userId']), 'id' => $entry['id'], 'label' => Catalog::label($entry['ability']), 'summary' => $entry['summary'], 'undoneAt' => $entry['undoneAt'], ]; } $kept = []; $fromTs = (int) strtotime($from . ' UTC'); $toTs = (int) strtotime($to . ' UTC'); foreach (KeptFixes::all() as $id => $fix) { $keptAt = (int) ($fix['keptAt'] ?? 0); if ($keptAt >= $fromTs && $keptAt < $toTs) { $kept[] = [ 'at' => gmdate('Y-m-d H:i:s', $keptAt), 'enabled' => !isset($fix['enabled']) || (bool) $fix['enabled'], 'id' => (string) $id, 'title' => (string) ($fix['title'] ?? $fix['summary'] ?? $id), ]; } } $published = get_posts(['date_query' => [['after' => $from, 'before' => $to, 'column' => 'post_date_gmt']], 'numberposts' => 500, 'post_status' => 'publish', 'post_type' => ['page', 'post', 'product']]); $moderated = 0; $updated = 0; foreach ($changes as $change) { if ($change['label'] === Catalog::label('sarcio/comments-moderate')) { $moderated++; } if (in_array($change['label'], [Catalog::label('sarcio/plugin-update'), Catalog::label('sarcio/theme-update')], true)) { $updated++; } } $health = HealthAbilities::summary(); $log = ActivityAbilities::counts($from, $to); return [ 'activity' => ['commentsModerated' => $moderated, 'log' => $log, 'published' => is_array($published) ? count($published) : 0, 'updates' => $updated], 'changes' => $changes, 'counts' => [ 'changes' => count($changes), 'kept' => count($kept), 'undone' => count(array_filter($changes, static fn (array $change): bool => $change['undoneAt'] !== null)), ], 'health' => $health, 'from' => $from, 'kept' => $kept, 'label' => $label, 'month' => $month, 'to' => $to, ]; } /** The month that just ended, in the site's time zone, as YYYY-MM. */ public static function previousMonth(): string { $now = new \DateTimeImmutable('now', self::timezone()); return $now->modify('first day of last month')->format('Y-m'); } public static function currentMonth(): string { return (new \DateTimeImmutable('now', self::timezone()))->format('Y-m'); } /** The months a report can be shown for, newest first. */ public static function months(int $count = 12): array { $out = []; $cursor = new \DateTimeImmutable('first day of this month', self::timezone()); for ($i = 0; $i < $count; $i++) { $out[] = ['label' => self::label($cursor), 'month' => $cursor->format('Y-m')]; $cursor = $cursor->modify('first day of last month'); } return $out; } /** The report as an email body: plain HTML a mail client renders the same everywhere. */ public static function html(array $report, string $siteName): string { $esc = static fn (string $text): string => esc_html($text); $rows = ''; foreach ($report['changes'] as $change) { $rows .= '' . $esc(substr((string) $change['at'], 0, 10)) . '' . '' . $esc((string) $change['summary']) . '' . '' . $esc((string) $change['by']) . '' . '' . $esc($change['undoneAt'] !== null ? __('Undone', 'sarcio') : __('Applied', 'sarcio')) . ''; } $keptRows = ''; foreach ($report['kept'] as $fix) { $keptRows .= '' . $esc(substr((string) $fix['at'], 0, 10)) . '' . '' . $esc((string) $fix['title']) . '' . '' . $esc($fix['enabled'] ? __('On', 'sarcio') : __('Off', 'sarcio')) . ''; } $head = '

' . $esc(sprintf( /* translators: 1: the site name, 2: the month. */ __('%1$s in %2$s', 'sarcio'), $siteName, $report['label'], )) . '

'; $summary = '

' . $esc(sprintf( /* translators: 1: changes made, 2: changes undone, 3: fixes kept. */ __('%1$d changes made through the assistant, %2$d of them undone. %3$d fixes kept on the site.', 'sarcio'), $report['counts']['changes'], $report['counts']['undone'], $report['counts']['kept'], )) . '

'; $table = static fn (string $title, string $body, string $empty): string => '

' . $esc($title) . '

' . ($body !== '' ? '' . $body . '
' : '

' . $esc($empty) . '

'); $logo = \Sarcio\WordPress\Brand::logo(); $footer = '

' . $esc(sprintf( /* translators: %s: the brand name. */ __('Sent by %s.', 'sarcio'), \Sarcio\WordPress\Brand::name(), )) . '

'; $activity = $report['activity'] ?? ['commentsModerated' => 0, 'published' => 0, 'updates' => 0]; $activityRow = '' . $esc(sprintf( /* translators: 1: pages published, 2: comments moderated, 3: updates. */ __('%1$d pages, posts or products published · %2$d comments moderated · %3$d plugin or theme updates', 'sarcio'), (int) $activity['published'], (int) $activity['commentsModerated'], (int) $activity['updates'], )) . ''; $log = is_array($activity['log'] ?? null) ? $activity['log'] : ['byType' => [], 'source' => 'wordpress']; if ($log['source'] !== 'wordpress' && $log['byType'] !== []) { $activityRow .= '' . $esc(sprintf( /* translators: 1: the log plugin, 2: counts by type. */ __('From %1$s: %2$s', 'sarcio'), $log['source'] === 'simple-history' ? 'Simple History' : 'Stream', implode(', ', array_map(static fn (string $type, int $count): string => $type . ' ' . $count, array_keys($log['byType']), $log['byType'])), )) . ''; } $health = $report['health'] ?? ['counts' => ['critical' => 0, 'good' => 0, 'recommended' => 0], 'issues' => []]; $healthRows = '' . $esc(sprintf( /* translators: 1: passed, 2: recommended, 3: critical. */ __('Site Health: %1$d checks passed, %2$d recommended, %3$d critical', 'sarcio'), (int) $health['counts']['good'], (int) $health['counts']['recommended'], (int) $health['counts']['critical'], )) . ''; foreach (array_slice((array) $health['issues'], 0, 8) as $issue) { $healthRows .= '' . $esc((string) $issue['label']) . ''; } return '
' . ($logo !== '' ? '' : '') . $head . $summary . $table(__('Changes', 'sarcio'), $rows, __('No changes this month.', 'sarcio')) . $table(__('Fixes kept on the site', 'sarcio'), $keptRows, __('No fixes were kept this month.', 'sarcio')) . $table(__('On the site', 'sarcio'), $activityRow, '') . $table(__('Health today', 'sarcio'), $healthRows, '') . $footer . '
'; } /** Called daily by WP-Cron: on the first of the month, mails last month's report once. */ public static function sendIfDue(?\DateTimeImmutable $now = null): bool { $recipients = self::recipients(); if ($recipients === []) { return false; } $now = ($now ?? new \DateTimeImmutable('now'))->setTimezone(self::timezone()); $month = $now->modify('first day of last month')->format('Y-m'); if ((string) get_option(self::SENT_OPTION, '') === $month || (int) $now->format('j') !== 1) { return false; } return self::send($month, $recipients); } /** @param array $recipients */ public static function send(string $month, array $recipients): bool { $report = self::build($month, true); $siteName = html_entity_decode((string) get_bloginfo('name'), ENT_QUOTES | ENT_HTML5, 'UTF-8'); $sent = wp_mail( $recipients, sprintf( /* translators: 1: the site name, 2: the month. */ __('%1$s in %2$s: what changed', 'sarcio'), $siteName, $report['label'], ), self::html($report, $siteName), ['Content-Type: text/html; charset=UTF-8'], ); if ($sent) { update_option(self::SENT_OPTION, $month, false); } return (bool) $sent; } /** @return array */ public static function recipients(): array { $raw = (string) Options::get('report_email'); return array_values(array_filter(array_map('trim', explode(',', $raw)), static fn (string $email): bool => $email !== '' && is_email($email) !== false)); } public static function schedule(): void { if (!wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_event(time() + 3600, 'daily', self::CRON_HOOK); } } public static function unschedule(): void { $next = wp_next_scheduled(self::CRON_HOOK); if ($next) { wp_unschedule_event((int) $next, self::CRON_HOOK); } } /** @return array{0:string,1:string,2:string} UTC bounds and the month's label */ private static function bounds(string $month): array { $start = \DateTimeImmutable::createFromFormat('Y-m-d H:i:s', $month . '-01 00:00:00', self::timezone()); if ($start === false) { $start = new \DateTimeImmutable('first day of this month 00:00:00', self::timezone()); } $end = $start->modify('first day of next month'); $utc = new \DateTimeZone('UTC'); return [$start->setTimezone($utc)->format('Y-m-d H:i:s'), $end->setTimezone($utc)->format('Y-m-d H:i:s'), self::label($start)]; } private static function label(\DateTimeImmutable $month): string { return function_exists('wp_date') ? (string) wp_date('F Y', $month->getTimestamp(), self::timezone()) : $month->format('F Y'); } private static function timezone(): \DateTimeZone { if (function_exists('wp_timezone')) { return wp_timezone(); } return new \DateTimeZone('UTC'); } private static function userName(int $userId): string { $user = function_exists('get_userdata') ? get_userdata($userId) : null; return is_object($user) && isset($user->display_name) ? (string) $user->display_name : ''; } }