*/ public static function report(bool $refresh = false): array { $thresholds = self::thresholds(); // Keep filling the index before answering. Each call is bounded, and // `pending` tells the caller whether the answer is complete yet. $pending = Word_Count_Index::refresh(array_keys($thresholds), self::STATUSES); // What the answer depends on. The revision covers a recount, the // thresholds cover the user changing what thin means, and the unit // covers a locale switch, which changes every number at once. $signature = [ 'revision' => Word_Count_Index::revision(), 'thresholds' => $thresholds, 'unit' => Word_Count_Index::unit(), ]; $cached = get_option(self::CACHE_OPTION, null); if (!$refresh && is_array($cached) && 0 === $pending && ($cached['signature'] ?? null) === $signature ) { return self::present($cached, 0); } $totals = Word_Count_Index::totals($thresholds, self::STATUSES); $types = []; foreach ($thresholds as $post_type => $threshold) { $counted = (int) ($totals[$post_type]['counted'] ?? 0); $thin = (int) ($totals[$post_type]['thin'] ?? 0); // A post type with nothing published is not a finding. Listing it // would fill the report with rows that can never change. if (0 === $counted) { continue; } $types[] = [ 'post_type' => $post_type, 'label' => self::post_type_label($post_type), 'threshold' => $threshold, 'counted' => $counted, 'thin' => $thin, 'overridden' => self::has_override($post_type), 'posts' => $thin > 0 ? self::describe(Word_Count_Index::thinnest( $post_type, $threshold, self::STATUSES, self::MAX_LISTED )) : [], ]; } $report = [ 'signature' => $signature, 'types' => $types, 'unit' => $signature['unit'], 'built_at' => time(), ]; // 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 Posts still to count. * @return array */ private static function present(array $report, int $pending): array { $types = self::add_viewer_fields(array_values((array) ($report['types'] ?? []))); $thin = 0; $counted = 0; foreach ($types as $type) { $thin += (int) ($type['thin'] ?? 0); $counted += (int) ($type['counted'] ?? 0); } 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), 'counted' => $counted, 'thin' => $thin, 'types' => $types, // Words on most sites, characters on ja/th/zh_* — the number above // means nothing without it. 'unit' => (string) ($report['unit'] ?? Word_Count_Index::unit()), 'default_threshold' => self::default_threshold(), 'generated_at' => (int) ($report['built_at'] ?? 0), 'scheduled' => Plan_Config::can('scheduled_alerts', 'thin_content'), 'limits' => [ 'max_listed' => self::MAX_LISTED, 'min_threshold' => self::MIN_THRESHOLD, 'max_threshold' => self::MAX_THRESHOLD, ], ]; } /** * Turn counted post IDs into something a reader can act on. * * Deliberately carries nothing that depends on *who is asking*. This array * is what gets stored in the cache option, and the report is one option * shared by every user: an editor's `edit_url` and `can_edit` frozen into it * would be handed to the administrator who asked next, who would then be * sent to the public permalink instead of the editor. {@see add_viewer_fields()} * resolves those two per request instead. * * @param array $rows From the index. * @return array> */ private static function describe(array $rows): array { $ids = array_column($rows, 'post_id'); if (!empty($ids)) { _prime_post_caches($ids, false, true); } $described = []; foreach ($rows as $row) { $post = get_post($row['post_id']); if (!$post instanceof \WP_Post) { continue; } $described[] = [ 'post_id' => (int) $post->ID, 'post_title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8'), 'count' => $row['count'], 'permalink' => (string) get_permalink($post), 'modified' => (string) $post->post_modified_gmt, ]; } return $described; } /** * 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, because * the cached report is shared and capabilities are not. * * @param array> $types Post type rows. * @return array> */ private static function add_viewer_fields(array $types): array { $ids = []; foreach ($types as $type) { foreach ((array) ($type['posts'] ?? []) as $post) { $ids[] = (int) ($post['post_id'] ?? 0); } } $ids = array_values(array_filter($ids)); if (!empty($ids)) { _prime_post_caches($ids, false, true); } foreach ($types as $i => $type) { foreach ((array) ($type['posts'] ?? []) as $j => $post) { $post_id = (int) ($post['post_id'] ?? 0); $types[$i]['posts'][$j]['can_edit'] = $post_id > 0 && current_user_can('edit_post', $post_id); $types[$i]['posts'][$j]['edit_url'] = $post_id > 0 ? (string) get_edit_post_link($post_id, 'raw') : ''; } } return $types; } /** * The threshold for every post type in scope. * * @return array Post type => threshold, in post type order. */ public static function thresholds(): array { $settings = self::settings(); $thresholds = []; foreach (self::post_types() as $post_type) { $thresholds[$post_type] = isset($settings['overrides'][$post_type]) ? self::clamp((int) $settings['overrides'][$post_type]) : (int) $settings['default']; } return $thresholds; } /** * The default threshold, applied to any post type with no override. * * @return int */ public static function default_threshold(): int { return (int) self::settings()['default']; } /** * Whether a post type has a threshold of its own. * * @param string $post_type Post type. * @return bool */ private static function has_override(string $post_type): bool { return isset(self::settings()['overrides'][$post_type]); } /** * Stored settings, with every value validated. * * @return array{default:int, overrides:array} */ public static function settings(): array { $stored = get_option(self::SETTINGS_OPTION, []); $stored = is_array($stored) ? $stored : []; $default = isset($stored['default']) ? self::clamp((int) $stored['default']) : self::DEFAULT_THRESHOLD; $overrides = []; $raw = isset($stored['overrides']) && is_array($stored['overrides']) ? $stored['overrides'] : []; $allowed = self::post_types(); foreach ($raw as $post_type => $value) { $post_type = (string) $post_type; // A post type that has been unregistered since the override was // saved is kept out of the map rather than dropped from storage: // deactivating a plugin for an afternoon should not lose the // threshold its post type had. if (!in_array($post_type, $allowed, true)) { continue; } $overrides[$post_type] = self::clamp((int) $value); } return ['default' => $default, 'overrides' => $overrides]; } /** * Store new settings, merging into what is there. * * @param array $input Partial settings. * @return array{default:int, overrides:array} What is now stored. */ public static function save_settings(array $input): array { $stored = get_option(self::SETTINGS_OPTION, []); $stored = is_array($stored) ? $stored : []; $next = [ 'default' => isset($stored['default']) ? (int) $stored['default'] : self::DEFAULT_THRESHOLD, 'overrides' => isset($stored['overrides']) && is_array($stored['overrides']) ? $stored['overrides'] : [], ]; if (isset($input['default'])) { $next['default'] = self::clamp((int) $input['default']); } if (isset($input['overrides']) && is_array($input['overrides'])) { foreach ($input['overrides'] as $post_type => $value) { $post_type = sanitize_key((string) $post_type); // null clears an override and returns the post type to the // default, which is the only way back from one. if (null === $value || '' === $value) { unset($next['overrides'][$post_type]); continue; } $next['overrides'][$post_type] = self::clamp((int) $value); } } update_option(self::SETTINGS_OPTION, $next, false); // The thresholds are part of the report's signature, so the cached // report retires itself. Nothing has to be recounted: a threshold // decides how a count is read, not what it is. return self::settings(); } /** * Hold a threshold inside its bounds. * * @param int $value Requested threshold. * @return int */ public static function clamp(int $value): int { return max(self::MIN_THRESHOLD, min(self::MAX_THRESHOLD, $value)); } /** * Post types the report covers. * * The same policy the rest of Global SEO applies, resolved here rather than * on {@see Global_SEO_Post_Types} so this feature carries no shared-surface * change of its own. Attachments are excluded: an attachment page has no * body to be thin, and including them would report every image on the site. * * @return string[] */ public static function post_types(): array { $post_types = []; foreach (get_post_types(['public' => true], 'objects') as $object) { if ('attachment' === $object->name || !Global_SEO_Post_Types::is_allowed($object)) { continue; } $post_types[] = $object->name; } return $post_types; } /** * A post type's plural name, for the report. * * @param string $post_type Post type. * @return string */ private static function post_type_label(string $post_type): string { $object = get_post_type_object($post_type); if (!$object instanceof \WP_Post_Type) { return $post_type; } return (string) ($object->labels->name ?? $object->label ?? $post_type); } }