, * groups: array>>, * truncated: array, * generated_at: int, * scheduled: bool * } */ public static function report(bool $refresh = false): array { $post_types = Global_SEO_Post_Types::allowed(); if ($refresh) { Snippet_Index::bump_generation(); } // Keep filling the index before answering. Each call is bounded, and // `pending` tells the caller whether the answer is complete yet. $pending = Snippet_Index::refresh_sitewide($post_types, self::STATUSES); // What the answer depends on. The generation alone is not enough: it // only moves when a *global* input changes, and one post's title // edited to match another's changes this report without touching it. // The revision covers that, and the count covers a post leaving the // scope entirely, which no rebuild would ever report. $signature = [ 'generation' => Snippet_Index::generation(), 'revision' => Snippet_Index::revision(), 'scanned' => Snippet_Index::current_count($post_types, self::STATUSES), ]; $cached = get_option(self::CACHE_OPTION, null); if (!$refresh && is_array($cached) && 0 === $pending && ($cached['signature'] ?? null) === $signature ) { return self::present($cached, 0); } $report = [ 'signature' => $signature, 'scanned' => $signature['scanned'], 'counts' => [], 'groups' => [], 'truncated' => [], 'built_at' => time(), ]; foreach (Snippet_Issues::grouped() as $issue) { $which = Snippet_Issues::DUPLICATE_TITLE === $issue ? 'title' : 'description'; // Totals come from their own count rather than from the listed // groups, which are capped — otherwise a site with 200 duplicated // titles would report 25 groups' worth and call it the total. $totals = Snippet_Index::duplicate_totals($post_types, self::STATUSES, $which); $report['counts'][$issue] = $totals['posts']; $report['group_counts'][$issue] = $totals['groups']; $report['truncated'][$issue] = $totals['groups'] > self::MAX_GROUPS; $report['groups'][$issue] = self::describe( Snippet_Index::duplicate_groups( $post_types, self::STATUSES, $which, self::MAX_MEMBERS, self::MAX_GROUPS ), $which ); } // Only a complete scan is worth caching: a partial one would be served // as the answer long after the index finished filling. if (0 === $pending) { update_option(self::CACHE_OPTION, $report, false); } return self::present($report, $pending); } /** * Shape a stored report for its caller. * * @param array $report Stored report. * @param int $pending Entries still to be indexed. * @return array */ private static function present(array $report, int $pending): array { $counts = []; $group_counts = []; $groups = []; $truncated = []; foreach (Snippet_Issues::grouped() as $issue) { $counts[$issue] = (int) ($report['counts'][$issue] ?? 0); $group_counts[$issue] = (int) ($report['group_counts'][$issue] ?? 0); $groups[$issue] = array_values((array) ($report['groups'][$issue] ?? [])); $truncated[$issue] = (bool) ($report['truncated'][$issue] ?? false); } $groups = self::add_viewer_fields($groups); return [ // Above zero means the scan has not covered the whole site yet, so // the numbers below are a floor rather than the answer. 'pending' => max(0, $pending), 'scanned' => (int) ($report['scanned'] ?? 0), // Posts caught up in a duplicate, per issue. 'counts' => $counts, // Distinct values being duplicated, per issue. 'group_counts' => $group_counts, 'groups' => $groups, 'truncated' => $truncated, 'generated_at' => (int) ($report['built_at'] ?? 0), 'scheduled' => \ThinkRank\Core\Plan_Config::can('scheduled', 'duplicate_snippets'), ]; } /** * Add the two fields that belong to the reader rather than to the report. * * Answered for the current user on every request, cache hit or not. The * report is one option shared by every user, so a `can_edit` resolved while * building it would be handed to whoever asked next: a rebuild triggered by * someone without edit rights cached an empty `edit_url`, and the * administrator who opened the screen after them was linked to the public * page instead of the editor. * * @param array>> $groups Groups per issue. * @return array>> */ private static function add_viewer_fields(array $groups): array { $ids = []; foreach ($groups as $found) { foreach ($found as $group) { foreach ((array) ($group['posts'] ?? []) as $post) { $ids[] = (int) ($post['post_id'] ?? 0); } } } $ids = array_values(array_unique(array_filter($ids))); if (!empty($ids)) { _prime_post_caches($ids, false, true); } foreach ($groups as $issue => $found) { foreach ($found as $i => $group) { foreach ((array) ($group['posts'] ?? []) as $j => $post) { $post_id = (int) ($post['post_id'] ?? 0); $groups[$issue][$i]['posts'][$j]['can_edit'] = $post_id > 0 && current_user_can('edit_post', $post_id); $groups[$issue][$i]['posts'][$j]['edit_url'] = $post_id > 0 ? (string) get_edit_post_link($post_id, 'raw') : ''; } } } return $groups; } /** * Turn grouped IDs into something a reader can act on: the value the group * shares, and a link to each page carrying it. * * The shared value is resolved once per group rather than once per post — * a duplicate group is by definition one value — so a report of 25 groups * resolves 25 snippets, not 250. * * Deliberately carries nothing that depends on *who is asking*: this array * is what gets stored in the cache option. {@see self::add_viewer_fields()} * resolves `edit_url` and `can_edit` per request instead. * * @param array $groups Groups. * @param string $which 'title' or 'description'. * @return array> */ private static function describe(array $groups, string $which): array { $all_ids = []; foreach ($groups as $group) { $all_ids = array_merge($all_ids, $group['post_ids']); } if (!empty($all_ids)) { _prime_post_caches(array_values(array_unique($all_ids)), false, true); } $described = []; foreach ($groups as $group) { $posts = []; foreach ($group['post_ids'] as $post_id) { $post = get_post($post_id); if (!$post instanceof \WP_Post) { continue; } $posts[] = [ 'post_id' => (int) $post->ID, 'post_title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8'), 'post_type' => $post->post_type, 'permalink' => (string) get_permalink($post), ]; } if (empty($posts)) { continue; } $first = (int) $posts[0]['post_id']; $described[] = [ 'key' => $group['key'], // Decoded, like post_title above: the resolved value is still // HTML (`Foo & Bar`), and both the card and the ability // hand it on as text, so the entity was shown as typed. 'value' => \ThinkRank\Core\Seo_Text::as_displayed( 'title' === $which ? Pattern_Resolver::effective_title($first) : Pattern_Resolver::effective_description($first) ), 'total' => $group['total'], 'posts' => $posts, // True when the group has more members than are named here. 'partial' => $group['total'] > count($posts), ]; } return $described; } }