PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.14.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.14.1
2.14.1 2.14.0 2.13.0 2.12.0 2.11.0 2.10.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 All 56 releases
thinkrank / includes / seo / class-email-report-data-provider.php

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

688 lines 27.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report Data Provider
4 *
5 * Pulls dashboard data for the current period and the immediately
6 * preceding period of equal length, then hands both to sections so they
7 * can diff and rank. Sections never call Analytics_Manager directly —
8 * one fetch per period, shared across the report.
9 *
10 * readiness() answers, before any data is pulled, whether there is a report
11 * to build: Search Console is required, Google Analytics 4 and the AI
12 * traffic tracker each add a card when present (#742). The generator asks
13 * this first so a disconnected site is paused with a reason rather than
14 * fetched, rendered and sent as a column of blanks.
15 *
16 * If Analytics_Manager isn't available the provider returns an
17 * `available => false` result and sections collect nothing.
18 *
19 * @package ThinkRank
20 * @subpackage SEO
21 * @since 1.9.0
22 */
23
24 declare(strict_types=1);
25
26 namespace ThinkRank\SEO;
27
28 use Throwable;
29
30 if (!defined('ABSPATH')) {
31 exit;
32 }
33
34 /**
35 * Email_Report_Data_Provider
36 *
37 * @since 1.9.0
38 */
39 final class Email_Report_Data_Provider {
40
41 /**
42 * Rows fetched per window for the query and page comparisons.
43 *
44 * Search Console orders rows by clicks and stops here, so a list that
45 * comes back this long may be truncated: a row missing from it may sit
46 * just below the cut rather than have no traffic (#906).
47 */
48 private const COMPARISON_ROW_LIMIT = 1000;
49
50 /**
51 * How many apparent drop-outs are confirmed against the current window.
52 *
53 * The losing cards show five rows, so the biggest previous-period
54 * candidates are all that can reach them. Anything past this cap is left
55 * out of the comparison rather than presumed lost.
56 */
57 private const DROPOUT_CONFIRM_LIMIT = 25;
58
59 /**
60 * Longest regular expression sent in one confirmation request. Search
61 * Console rejects longer expressions, so candidates are batched under it.
62 */
63 private const DROPOUT_REGEX_MAX_LENGTH = 3500;
64
65 /**
66 * Which data sources the report can draw on right now.
67 *
68 * Cheap on purpose — no dashboard fetch, no Search Console query. It
69 * runs on every hourly tick while a site is paused and on every load of
70 * the Email Reporting panel.
71 *
72 * @return array{
73 * ready:bool,
74 * search_console:bool,
75 * analytics:bool,
76 * ai_traffic:bool,
77 * reason:?string
78 * }
79 */
80 public function readiness(): array {
81 // Credentials, not client objects. Analytics_Manager constructs a
82 // Search Console client whether or not a token exists, so "is there
83 // a client?" is always yes. The predicates below are the ones
84 // get-integrations-status reports, so the panel, the ability and
85 // the report can never disagree about the same site.
86 $settings = $this->settings();
87
88 $oauth_present = '' !== (string) $settings->get('google_access_token', '');
89 $search_console = $oauth_present
90 || '' !== (string) $settings->get('google_search_console_api_key', '');
91
92 // GA4: the Data API client is built only when a token AND a selected
93 // property both exist — mirror that exactly.
94 $ga_property = (string) $settings->get('seo_analytics_google_analytics_property_id', '');
95 $analytics = ($oauth_present && '' !== $ga_property)
96 || '' !== (string) $settings->get('google_analytics_api_key', '');
97
98 $readiness = [
99 'ready' => $search_console,
100 'search_console' => $search_console,
101 'analytics' => $analytics,
102 'ai_traffic' => $this->ai_tracker_has_data(),
103 'reason' => $search_console ? null : 'search_console_not_connected',
104 ];
105
106 /**
107 * Filter the report's readiness.
108 *
109 * Lets a host that feeds the report from somewhere other than the
110 * Google integrations declare itself ready, and lets tests force
111 * either state.
112 *
113 * @since 2.8.0
114 *
115 * @param array $readiness See readiness().
116 */
117 $filtered = apply_filters('thinkrank_email_report_readiness', $readiness);
118
119 return is_array($filtered) ? array_merge($readiness, $filtered) : $readiness;
120 }
121
122 /**
123 * Subject-line tokens for a fetched report (#742).
124 *
125 * `%headline%` is the sentence the default subject is built from:
126 * "12,480 Google clicks (+12.4%) in the last 30 days" when both windows
127 * have totals, "Your SEO report for Aug 19 – Sep 17" when there is
128 * nothing to compare. The parts are exposed as their own tokens so a
129 * custom subject template can rebuild it differently.
130 *
131 * @param array $shared Output of fetch().
132 * @param int $frequency_days Report window in days.
133 * @return array<string,string> Token => value.
134 */
135 public static function subject_tokens(array $shared, int $frequency_days): array {
136 $days = max(1, $frequency_days);
137 $label = (string) ($shared['period_label'] ?? '');
138 $totals = $shared['comparison']['totals'] ?? [];
139 $current = is_array($totals['current'] ?? null) ? $totals['current'] : [];
140 $previous = is_array($totals['previous'] ?? null) ? $totals['previous'] : [];
141
142 $clicks = (int) ($current['clicks'] ?? 0);
143 $impressions = (int) ($current['impressions'] ?? 0);
144 if ($clicks === 0 && $impressions === 0) {
145 $dash = $shared['current']['search_performance']['totals'] ?? [];
146 $clicks = (int) ($dash['clicks'] ?? 0);
147 $impressions = (int) ($dash['impressions'] ?? 0);
148 }
149
150 $change = null;
151 if ((int) ($previous['clicks'] ?? 0) > 0 && class_exists(Email_Report_Sections\Email_Report_Html::class)) {
152 $change = Email_Report_Sections\Email_Report_Html::pct_change((float) $clicks, (float) ($previous['clicks'] ?? 0));
153 }
154 $signed = $change !== null && $change['direction'] !== 'flat'
155 ? ($change['direction'] === 'up' ? '+' : '−') . $change['text']
156 : '';
157
158 if ($clicks > 0 || $impressions > 0) {
159 $headline = sprintf(
160 /* translators: 1: number of clicks, 2: change in parentheses or empty, 3: number of days. */
161 _n('%1$s Google clicks%2$s in the last %3$d day', '%1$s Google clicks%2$s in the last %3$d days', $days, 'thinkrank'),
162 number_format_i18n($clicks),
163 $signed !== '' ? ' (' . $signed . ')' : '',
164 $days
165 );
166 } else {
167 $headline = $label !== ''
168 ? sprintf(
169 /* translators: %s: period label, e.g. "Aug 19 – Sep 17, 2026". */
170 __('Your SEO report for %s', 'thinkrank'),
171 $label
172 )
173 : __('Your SEO report', 'thinkrank');
174 }
175
176 return [
177 '%period%' => $label,
178 '%period_days%' => (string) $days,
179 '%clicks%' => number_format_i18n($clicks),
180 '%clicks_change%' => $signed,
181 '%impressions%' => number_format_i18n($impressions),
182 '%headline%' => $headline,
183 ];
184 }
185
186 /**
187 * Pull current + prior period dashboard data.
188 *
189 * @param int $frequency_days Reporting frequency in days.
190 * @return array{
191 * available:bool,
192 * current:array,
193 * prior:array,
194 * period_start:string,
195 * period_end:string,
196 * period_label:string,
197 * error?:string
198 * }
199 */
200 public function fetch(int $frequency_days): array {
201 $frequency_days = max(1, $frequency_days);
202
203 // One canonical window for the whole report. Search Console lags
204 // ~2 days, so it ends on the last date that actually has data —
205 // the same anchor Analytics_Manager::get_dashboard_data() uses for
206 // Key Metrics and Position Summary. Previously the header label ran
207 // through today and the comparison through yesterday, so a reader
208 // was handed three windows and told they were one.
209 $period_end = gmdate('Y-m-d', strtotime('-2 days'));
210 $period_start = gmdate(
211 'Y-m-d',
212 strtotime('-' . ($frequency_days - 1) . ' days', strtotime($period_end))
213 );
214 $period_label = $this->format_period_label($period_start, $period_end);
215
216 $manager = $this->get_analytics_manager();
217 if ($manager === null) {
218 return [
219 'available' => false,
220 'current' => [],
221 'prior' => [],
222 'period_start' => $period_start,
223 'period_end' => $period_end,
224 'period_label' => $period_label,
225 'error' => __('Analytics integration not available.', 'thinkrank'),
226 ];
227 }
228
229 try {
230 $range = $frequency_days . 'd';
231 $current = $manager->get_dashboard_data($range);
232
233 // Real period-over-period comparison: pull query- and page-level
234 // metrics for the current window AND the immediately preceding
235 // window of equal length straight from Search Console, then key
236 // them so winning/losing sections can compute true deltas.
237 $comparison = $this->build_comparison($manager, $frequency_days, $period_start, $period_end);
238
239 return [
240 'available' => true,
241 'readiness' => $this->readiness(),
242 'current' => is_array($current) ? $current : [],
243 'comparison' => $comparison,
244 'ai' => $this->ai_summary($frequency_days),
245 'period_start' => $period_start,
246 'period_end' => $period_end,
247 'period_label' => $period_label,
248 ];
249 } catch (Throwable $e) {
250 return [
251 'available' => false,
252 'current' => [],
253 'comparison' => ['available' => false, 'queries' => [], 'pages' => []],
254 'period_start' => $period_start,
255 'period_end' => $period_end,
256 'period_label' => $period_label,
257 'error' => $e->getMessage(),
258 ];
259 }
260 }
261
262 /**
263 * Build the current-vs-previous comparison from Search Console.
264 *
265 * Uses the Search Console client's arbitrary date-range API
266 * (`get_search_performance_by_dates`) — the same one the Rank Tracker
267 * and the Pro winning/losing endpoint use — to fetch query- and
268 * page-level rows for two equal, adjacent windows.
269 *
270 * The current window is handed in by fetch() rather than recomputed
271 * here, so the deltas describe exactly the period the report's header
272 * advertises. The previous window is the same length, immediately
273 * before it, with no gap or overlap.
274 *
275 * @param object $manager Analytics_Manager instance.
276 * @param int $frequency_days Window length in days.
277 * @param string $cur_start Current window start (Y-m-d).
278 * @param string $cur_end Current window end (Y-m-d).
279 * @return array{available:bool,queries:array,pages:array}
280 */
281 private function build_comparison($manager, int $frequency_days, string $cur_start, string $cur_end): array {
282 $empty = ['available' => false, 'queries' => [], 'pages' => [], 'totals' => []];
283
284 if (!method_exists($manager, 'get_search_console_client')) {
285 return $empty;
286 }
287 $sc = $manager->get_search_console_client();
288 if (!$sc || !method_exists($sc, 'get_search_performance_by_dates')) {
289 return $empty;
290 }
291 $site_url = method_exists($manager, 'get_property_url') ? (string) $manager->get_property_url() : '';
292 if ($site_url === '') {
293 return $empty;
294 }
295
296 // Previous window: the same number of days, ending the day before
297 // the current window opens.
298 $prev_end = gmdate('Y-m-d', strtotime('-1 day', strtotime($cur_start)));
299 $prev_start = gmdate('Y-m-d', strtotime('-' . ($frequency_days - 1) . ' days', strtotime($prev_end)));
300
301 $limit = self::COMPARISON_ROW_LIMIT;
302 $cur_q = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, $limit, ['query']);
303 $prev_q = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, $limit, ['query']);
304 $cur_p = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, $limit, ['page']);
305 $prev_p = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, $limit, ['page']);
306
307 // A row missing from a full current list may only have fallen below
308 // the cut. Ask Search Console about the likely losers directly before
309 // any of them is reported as having lost everything (#906).
310 [$found_q, $dropouts_q] = $this->confirm_dropouts($sc, $site_url, $cur_start, $cur_end, $cur_q, $prev_q, 'query');
311 [$found_p, $dropouts_p] = $this->confirm_dropouts($sc, $site_url, $cur_start, $cur_end, $cur_p, $prev_p, 'page');
312
313 // Whole-property totals for both windows. A query with no
314 // dimensions returns one aggregated row, so the hero's clicks,
315 // impressions, CTR and position — and their change — are exact
316 // rather than summed from the 1,000-row query lists above.
317 $cur_t = $sc->get_search_performance_by_dates($site_url, $cur_start, $cur_end, 1, []);
318 $prev_t = $sc->get_search_performance_by_dates($site_url, $prev_start, $prev_end, 1, []);
319
320 return [
321 'available' => true,
322 'queries' => $this->merge_periods(array_merge($cur_q, $found_q), $prev_q, true, $dropouts_q),
323 'pages' => $this->merge_periods(array_merge($cur_p, $found_p), $prev_p, false, $dropouts_p),
324 'totals' => [
325 'current' => $this->totals_row($cur_t),
326 'previous' => $this->totals_row($prev_t),
327 ],
328 ];
329 }
330
331 /**
332 * Sort out which previous-window rows missing from the current list
333 * really lost all their traffic.
334 *
335 * Both windows are capped lists ordered by clicks. Absence from a
336 * current list shorter than the cap is real: Search Console returned
337 * everything it has. Absence from a full list is not evidence of
338 * anything, because growth elsewhere pushes unchanged rows below the
339 * cut. Those were synthesised as total losses and led the losing cards,
340 * labelled "No impressions this period", with the same clicks in both
341 * windows (#906).
342 *
343 * So when the current list is full, the biggest candidates by previous
344 * clicks are looked up in the current window by exact key. A candidate
345 * that comes back has its real current row returned, so its change is
346 * measured rather than presumed. Only one that comes back empty is a
347 * drop-out. Candidates past DROPOUT_CONFIRM_LIMIT, or in a batch whose
348 * request failed, are neither: they are left out of the comparison,
349 * since saying nothing is better than reporting a loss nobody measured.
350 *
351 * @since 2.15.0
352 *
353 * @param object $sc Search Console client.
354 * @param string $site_url Property URL.
355 * @param string $cur_start Current window start (Y-m-d).
356 * @param string $cur_end Current window end (Y-m-d).
357 * @param array $current Current-window rows, as fetched.
358 * @param array $previous Previous-window rows, as fetched.
359 * @param string $dimension 'query' or 'page'.
360 * @return array{0: array, 1: ?array<string,bool>} Current rows found
361 * below the cut, and the confirmed drop-out keys, or null when
362 * the current list is complete and every absence is real.
363 */
364 private function confirm_dropouts($sc, string $site_url, string $cur_start, string $cur_end, array $current, array $previous, string $dimension): array {
365 if (count($current) < self::COMPARISON_ROW_LIMIT) {
366 return [[], null];
367 }
368
369 $is_query = 'query' === $dimension;
370
371 $present = [];
372 foreach ($current as $row) {
373 $raw = (string) ($row['keys'][0] ?? '');
374 if ('' !== $raw) {
375 $present[$this->normalize_key($raw, $is_query)] = true;
376 }
377 }
378
379 $candidates = [];
380 foreach ($previous as $row) {
381 $raw = (string) ($row['keys'][0] ?? '');
382 if ('' === $raw) {
383 continue;
384 }
385 $key = $this->normalize_key($raw, $is_query);
386 if (!isset($present[$key]) && !isset($candidates[$key])) {
387 $candidates[$key] = ['raw' => $raw, 'clicks' => (int) ($row['clicks'] ?? 0)];
388 }
389 }
390
391 if ([] === $candidates) {
392 return [[], []];
393 }
394
395 uasort($candidates, static fn($a, $b) => $b['clicks'] <=> $a['clicks']);
396 $candidates = array_slice($candidates, 0, self::DROPOUT_CONFIRM_LIMIT, true);
397
398 // Batch the exact-match lookups into as few requests as the
399 // expression length allows: one per dimension in practice.
400 $batches = [];
401 $batch = [];
402 $length = 0;
403 foreach ($candidates as $key => $candidate) {
404 $part = $this->re2_quote($candidate['raw']);
405 if ([] !== $batch && $length + strlen($part) + 1 > self::DROPOUT_REGEX_MAX_LENGTH) {
406 $batches[] = $batch;
407 $batch = [];
408 $length = 0;
409 }
410 $batch[$key] = $part;
411 $length += strlen($part) + 1;
412 }
413 $batches[] = $batch;
414
415 $found = [];
416 $dropouts = [];
417 foreach ($batches as $batch) {
418 try {
419 $rows = $sc->get_search_performance_by_dates(
420 $site_url,
421 $cur_start,
422 $cur_end,
423 count($batch),
424 [$dimension],
425 [[
426 'dimension' => $dimension,
427 'operator' => 'includingRegex',
428 'expression' => '^(?:' . implode('|', $batch) . ')$',
429 ]]
430 );
431 } catch (Throwable $e) {
432 // Unconfirmed: leave these out rather than call them lost.
433 continue;
434 }
435
436 $seen = [];
437 foreach ((array) $rows as $row) {
438 $raw = (string) ($row['keys'][0] ?? '');
439 $key = '' === $raw ? '' : $this->normalize_key($raw, $is_query);
440 if (isset($batch[$key])) {
441 $seen[$key] = true;
442 $found[] = $row;
443 }
444 }
445
446 foreach (array_keys($batch) as $key) {
447 if (!isset($seen[$key])) {
448 $dropouts[$key] = true;
449 }
450 }
451 }
452
453 return [$found, $dropouts];
454 }
455
456 /**
457 * Escape a literal for a Search Console (RE2) regular expression.
458 *
459 * RE2 accepts a backslash before any ASCII punctuation as that literal,
460 * so every punctuation character is escaped, not only the ones that are
461 * special today.
462 *
463 * @since 2.15.0
464 *
465 * @param string $literal Text to match exactly.
466 * @return string
467 */
468 private function re2_quote(string $literal): string {
469 return (string) preg_replace('/[[:punct:]]/', '\\\\$0', $literal);
470 }
471
472 /**
473 * Normalise the single aggregate row Search Console returns for a
474 * dimensionless query. An empty result (a property with no traffic in
475 * the window) yields zeroes, which the hero treats as "no comparison".
476 *
477 * @param array $rows API rows.
478 * @return array{clicks:int,impressions:int,ctr:float,position:float}
479 */
480 private function totals_row(array $rows): array {
481 $row = is_array($rows[0] ?? null) ? $rows[0] : [];
482 return [
483 'clicks' => (int) ($row['clicks'] ?? 0),
484 'impressions' => (int) ($row['impressions'] ?? 0),
485 'ctr' => (float) ($row['ctr'] ?? 0.0),
486 'position' => (float) ($row['position'] ?? 0.0),
487 ];
488 }
489
490 /**
491 * AI-assistant traffic for the current window and the one before it,
492 * from the first-party tracker AI Insights already runs. Null when the
493 * tracker is absent or has recorded nothing.
494 *
495 * The tracker summarises a trailing window, so the previous period is
496 * the double window minus the current one.
497 *
498 * @return array{current:array,previous:array}|null
499 */
500 private function ai_summary(int $frequency_days): ?array {
501 $tracker = $this->get_ai_tracker();
502 if ($tracker === null) {
503 return null;
504 }
505 try {
506 $current = (array) $tracker->summary($frequency_days);
507 if ((int) ($current['ai_sessions'] ?? 0) === 0 && (int) ($current['baseline'] ?? 0) === 0) {
508 return null;
509 }
510 $double = (array) $tracker->summary($frequency_days * 2);
511 $previous = [
512 'ai_sessions' => max(0, (int) ($double['ai_sessions'] ?? 0) - (int) ($current['ai_sessions'] ?? 0)),
513 'baseline' => max(0, (int) ($double['baseline'] ?? 0) - (int) ($current['baseline'] ?? 0)),
514 ];
515 return ['current' => $current, 'previous' => $previous];
516 } catch (Throwable $e) {
517 return null;
518 }
519 }
520
521 private function ai_tracker_has_data(): bool {
522 $tracker = $this->get_ai_tracker();
523 if ($tracker === null) {
524 return false;
525 }
526 try {
527 $summary = (array) $tracker->summary(30);
528 return (int) ($summary['ai_sessions'] ?? 0) > 0 || (int) ($summary['baseline'] ?? 0) > 0;
529 } catch (Throwable $e) {
530 return false;
531 }
532 }
533
534 private function get_ai_tracker() {
535 $cls = '\\ThinkRank\\SEO\\Ai_Traffic_Tracker';
536 if (!class_exists($cls) || !method_exists($cls, 'summary')) {
537 return null;
538 }
539 try {
540 return new $cls();
541 } catch (Throwable $e) {
542 return null;
543 }
544 }
545
546 /**
547 * Merge current + previous GSC rows into one keyed map carrying both
548 * periods' clicks and (for queries) average position.
549 *
550 * The key set is the union of both windows. Search Console omits rows
551 * with no activity in a window, so a page or query that dropped to zero
552 * clicks has no current row at all — keying off `$current` alone would
553 * silently discard exactly the biggest losers the losing sections exist
554 * to surface.
555 *
556 * Which absent rows count as drop-outs is up to the caller. Absence from
557 * a truncated list proves nothing, so build_comparison() passes the keys
558 * it confirmed with Search Console; any other absent row is left out
559 * (#906). Null means the current list was complete and every absence is
560 * a drop-out.
561 *
562 * @param array $current Current-window rows.
563 * @param array $previous Previous-window rows.
564 * @param bool $is_query True for query rows, false for page rows.
565 * @param array<string,bool>|null $dropout_keys Confirmed drop-out keys, or null for all.
566 * @return array<string,array>
567 */
568 private function merge_periods(array $current, array $previous, bool $is_query, ?array $dropout_keys = null): array {
569 $prev_map = [];
570 foreach ($previous as $row) {
571 $key = (string) ($row['keys'][0] ?? '');
572 if ($key === '') {
573 continue;
574 }
575 $prev_map[$this->normalize_key($key, $is_query)] = $row;
576 }
577
578 $merged = [];
579 foreach ($current as $row) {
580 $raw = (string) ($row['keys'][0] ?? '');
581 if ($raw === '') {
582 continue;
583 }
584 $key = $this->normalize_key($raw, $is_query);
585 $prev = $prev_map[$key] ?? null;
586
587 $entry = [
588 'cur_clicks' => (int) ($row['clicks'] ?? 0),
589 'prev_clicks' => $prev ? (int) ($prev['clicks'] ?? 0) : 0,
590 ];
591 if ($is_query) {
592 $entry['query'] = $raw;
593 $entry['cur_pos'] = round((float) ($row['position'] ?? 0), 1);
594 $entry['prev_pos'] = $prev ? round((float) ($prev['position'] ?? 0), 1) : null;
595 } else {
596 $entry['url'] = $raw;
597 }
598 $merged[$key] = $entry;
599 }
600
601 // Total drop-outs: present last period, absent now. Synthesize them
602 // from the previous window with the current metrics zeroed. Position
603 // stays null rather than 0 — "no data" is not "ranked first".
604 foreach ($prev_map as $key => $prev_row) {
605 if (isset($merged[$key])) {
606 continue;
607 }
608 if (null !== $dropout_keys && !isset($dropout_keys[$key])) {
609 continue;
610 }
611 $raw = (string) ($prev_row['keys'][0] ?? '');
612 if ($raw === '') {
613 continue;
614 }
615
616 $entry = [
617 'cur_clicks' => 0,
618 'prev_clicks' => (int) ($prev_row['clicks'] ?? 0),
619 ];
620 if ($is_query) {
621 $entry['query'] = $raw;
622 $entry['cur_pos'] = null;
623 $entry['prev_pos'] = round((float) ($prev_row['position'] ?? 0), 1);
624 } else {
625 $entry['url'] = $raw;
626 }
627 $merged[$key] = $entry;
628 }
629
630 return $merged;
631 }
632
633 private function normalize_key(string $key, bool $is_query): string {
634 return $is_query ? trim(strtolower($key)) : $key;
635 }
636
637 /**
638 * The plugin settings store, or a null-object when it is not loaded
639 * (unit tests without the core classes), which reads as "nothing
640 * configured".
641 */
642 private function settings() {
643 $cls = '\\ThinkRank\\Core\\Settings';
644 if (class_exists($cls) && method_exists($cls, 'instance')) {
645 try {
646 return $cls::instance();
647 } catch (Throwable $e) {
648 // Fall through to the null object.
649 }
650 }
651 return new class() {
652 public function get(string $key, $fallback = null) {
653 return $fallback;
654 }
655 };
656 }
657
658 private function get_analytics_manager() {
659 $cls = '\\ThinkRank\\SEO\\Analytics_Manager';
660 if (!class_exists($cls)) {
661 return null;
662 }
663 try {
664 return new $cls();
665 } catch (Throwable $e) {
666 return null;
667 }
668 }
669
670 private function format_period_label(string $start, string $end): string {
671 $fmt = (string) get_option('date_format', 'M j, Y');
672 return sprintf('%s – %s', $this->format_day($start, $fmt), $this->format_day($end, $fmt));
673 }
674
675 /**
676 * Render a bare Y-m-d as a localized date.
677 *
678 * Anchored at midday UTC on purpose: wp_date() shifts the timestamp into
679 * the site timezone, and a date parsed at midnight would render as the
680 * day before on any negative offset. Midday leaves the calendar date
681 * intact across every real-world offset.
682 */
683 private function format_day(string $date, string $format): string {
684 $ts = strtotime($date . ' 12:00:00 UTC');
685 return wp_date($format, $ts ?: time());
686 }
687 }
688