PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
thinkrank / includes / seo / class-email-report-config.php

class-email-report-config.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/seo/class-email-report-config.php

289 lines 9.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report Config
4 *
5 * Persistence layer for the per-site Email Reporting state. Stores a single
6 * associative array under the `thinkrank_email_report_config` option.
7 *
8 * The free report is fixed: every 30 days, to the site admin email, with every
9 * section. What is stored is only whether it is on and when it last and next
10 * runs. ThinkRank Pro owns the schedule, recipient and branding settings and
11 * supplies them through the `thinkrank_email_report_config` filter (#673).
12 *
13 * Keys a save does not own are left in the stored array untouched, so values an
14 * earlier release wrote there (recipients, branding) survive for Pro to pick up.
15 *
16 * @package ThinkRank
17 * @subpackage SEO
18 * @since 1.9.0
19 */
20
21 declare(strict_types=1);
22
23 namespace ThinkRank\SEO;
24
25 if (!defined('ABSPATH')) {
26 exit;
27 }
28
29 /**
30 * Email_Report_Config
31 *
32 * @since 1.9.0
33 */
34 final class Email_Report_Config {
35
36 private const OPTION_KEY = 'thinkrank_email_report_config';
37
38 /**
39 * Days between reports when nothing filters the schedule.
40 */
41 public const FREQUENCY_DAYS = 30;
42
43 /**
44 * Load the procedural defaults file, which lives outside the autoloader.
45 */
46 private function load_defaults_file(): void {
47 if (!function_exists('thinkrank_get_default_email_report_config')) {
48 require_once THINKRANK_PLUGIN_DIR . 'includes/config/email-report-settings-config.php';
49 }
50 }
51
52 /**
53 * The stored option, as an array.
54 */
55 private function stored(): array {
56 $stored = get_option(self::OPTION_KEY, []);
57
58 return is_array($stored) ? $stored : [];
59 }
60
61 /**
62 * The resolved config every consumer reads.
63 *
64 * @return array{enabled: bool, frequency_days: int, recipients: string[], sections_enabled: string[], next_scheduled_at: ?string, last_sent_at: ?string, last_skip: ?array}
65 */
66 public function get(): array {
67 $this->load_defaults_file();
68
69 $stored = $this->stored();
70 $state = [
71 'enabled' => !empty($stored['enabled']),
72 'next_scheduled_at' => $stored['next_scheduled_at'] ?? null,
73 'last_sent_at' => $stored['last_sent_at'] ?? null,
74 // Why the last scheduled run sent nothing, or null. Read by the
75 // panel so a paused report is never mistaken for a healthy one.
76 'last_skip' => is_array($stored['last_skip'] ?? null) ? $stored['last_skip'] : null,
77 ];
78
79 $report = [
80 'frequency_days' => self::FREQUENCY_DAYS,
81 'recipients' => [(string) get_option('admin_email')],
82 'sections_enabled' => array_keys(thinkrank_get_email_report_default_sections()),
83 ];
84
85 /**
86 * Filter what the report covers and who receives it.
87 *
88 * ThinkRank Pro returns its own schedule, recipients and sections here.
89 * Extra keys are passed through to the renderer and mailer filters.
90 * The on/off switch and schedule timestamps are not filterable.
91 *
92 * @since 2.6.0
93 *
94 * @param array $report {
95 * @type int $frequency_days Days between reports.
96 * @type string[] $recipients Recipient addresses.
97 * @type string[] $sections_enabled Section keys, in render order.
98 * }
99 * @param array $state Stored on/off switch and schedule timestamps.
100 */
101 $filtered = apply_filters('thinkrank_email_report_config', $report, $state);
102 $filtered = is_array($filtered) ? $filtered : $report;
103
104 return array_merge(
105 $filtered,
106 [
107 'frequency_days' => max(1, (int) ($filtered['frequency_days'] ?? self::FREQUENCY_DAYS)),
108 'recipients' => $this->normalize_recipients($filtered['recipients'] ?? []),
109 'sections_enabled' => $this->normalize_section_keys($filtered['sections_enabled'] ?? []),
110 ],
111 $state
112 );
113 }
114
115 /**
116 * Save the on/off switch. Returns the resolved config after the write.
117 *
118 * The first enable seeds `next_scheduled_at` so the UI shows a real "Next
119 * report" date immediately. The scheduler still re-seeds on its first tick
120 * for any other path that flips enable on.
121 */
122 public function save(array $input): array {
123 $this->load_defaults_file();
124
125 $stored = $this->stored() + thinkrank_get_default_email_report_config();
126
127 if (array_key_exists('enabled', $input)) {
128 $stored['enabled'] = (bool) filter_var($input['enabled'], FILTER_VALIDATE_BOOLEAN);
129 }
130
131 if ($stored['enabled'] && empty($stored['next_scheduled_at'])) {
132 $next = strtotime('+' . $this->get()['frequency_days'] . ' days');
133
134 $stored['next_scheduled_at'] = wp_date('Y-m-d H:i:s', max($next ?: time(), time()));
135 }
136
137 update_option(self::OPTION_KEY, $stored, false);
138
139 $config = $this->get();
140
141 /**
142 * Fires after Email Report config is saved.
143 *
144 * @since 1.9.0
145 *
146 * @param array $config The resolved config.
147 */
148 do_action('thinkrank_email_report_settings_saved', $config);
149
150 return $config;
151 }
152
153 /**
154 * Move the next send after the report's frequency changed.
155 *
156 * Called by whatever changed the frequency (ThinkRank Pro) with the value
157 * it had before. Carrying the old timestamp through meant switching 30 → 7
158 * days still waited out the original 30-day window. The new date anchors
159 * off the last send when there is one, so shortening the cadence brings the
160 * next report forward instead of adding a full period on top of time
161 * already elapsed.
162 *
163 * @param int $previous_frequency_days Frequency before the change.
164 * @return array The resolved config.
165 */
166 public function reschedule(int $previous_frequency_days): array {
167 $config = $this->get();
168
169 if (empty($config['enabled']) || $previous_frequency_days === (int) $config['frequency_days']) {
170 return $config;
171 }
172
173 // last_sent_at is a site-local wall clock (current_time('mysql')).
174 // strtotime() would read it as UTC and skew the whole cadence by the
175 // site's offset, so resolve it in the site timezone instead.
176 $anchor = !empty($config['last_sent_at'])
177 ? (int) get_gmt_from_date((string) $config['last_sent_at'], 'U')
178 : time();
179 $anchor = $anchor ?: time();
180
181 $next = strtotime('+' . (int) $config['frequency_days'] . ' days', $anchor);
182
183 // Never schedule into the past — a big cadence cut on an old
184 // last_sent_at means "due now", which the next tick picks up.
185 return $this->update_schedule(
186 $config['last_sent_at'],
187 wp_date('Y-m-d H:i:s', max($next ?: time(), time()))
188 );
189 }
190
191 /**
192 * Note why a scheduled run sent nothing (#742).
193 *
194 * `search_console_not_connected` leaves the schedule alone so the next
195 * hourly tick tries again; `no_data` is recorded by the generator after
196 * it has already pushed the schedule out a period. Either way the panel
197 * shows the reason and when it was last seen.
198 *
199 * @param string $reason Machine-readable reason.
200 */
201 public function record_skip(string $reason): void {
202 $stored = $this->stored();
203 $stored['last_skip'] = [
204 'reason' => sanitize_key($reason),
205 'at' => current_time('mysql'),
206 ];
207 update_option(self::OPTION_KEY, $stored, false);
208 }
209
210 /**
211 * A report went out — whatever paused it earlier no longer applies.
212 */
213 public function clear_skip(): void {
214 $stored = $this->stored();
215 if (!array_key_exists('last_skip', $stored)) {
216 return;
217 }
218 unset($stored['last_skip']);
219 update_option(self::OPTION_KEY, $stored, false);
220 }
221
222 /**
223 * Update only the schedule timestamps. Called from the scheduler after
224 * a send.
225 */
226 public function update_schedule(?string $last_sent_at, ?string $next_scheduled_at): array {
227 $stored = $this->stored();
228 $stored['last_sent_at'] = $last_sent_at;
229 $stored['next_scheduled_at'] = $next_scheduled_at;
230 update_option(self::OPTION_KEY, $stored, false);
231
232 return $this->get();
233 }
234
235 /**
236 * Normalize a recipient list that might arrive as a string
237 * ("[email protected], [email protected]") or as an array.
238 *
239 * @param mixed $raw
240 * @return string[]
241 */
242 private function normalize_recipients($raw): array {
243 if (is_string($raw)) {
244 $raw = preg_split('/[\s,;]+/', $raw) ?: [];
245 }
246 if (!is_array($raw)) {
247 return [];
248 }
249 $emails = [];
250 foreach ($raw as $candidate) {
251 if (!is_string($candidate)) {
252 continue;
253 }
254 $candidate = sanitize_email(trim($candidate));
255 if ($candidate !== '' && is_email($candidate)) {
256 $emails[] = strtolower($candidate);
257 }
258 }
259 return array_values(array_unique($emails));
260 }
261
262 /**
263 * Keep known section keys, in the order given.
264 *
265 * @param mixed $raw
266 * @return string[]
267 */
268 private function normalize_section_keys($raw): array {
269 if (!is_array($raw)) {
270 return [];
271 }
272 $allowed = array_unique(array_merge(
273 array_keys(thinkrank_get_email_report_default_sections()),
274 (array) apply_filters('thinkrank_email_report_section_keys', [])
275 ));
276 $clean = [];
277 foreach ($raw as $key) {
278 if (!is_string($key)) {
279 continue;
280 }
281 $key = sanitize_key($key);
282 if (in_array($key, $allowed, true)) {
283 $clean[] = $key;
284 }
285 }
286 return array_values(array_unique($clean));
287 }
288 }
289