settings = $settings; } /** * Register hooks. */ public function hooks() { add_action( 'post_updated', array( $this, 'track_content_change' ), 10, 3 ); add_action( 'added_post_meta', array( $this, 'track_builder_change' ), 10, 3 ); add_action( 'updated_post_meta', array( $this, 'track_builder_change' ), 10, 3 ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Post meta keys that hold page-builder content. * * Elementor, Breakdance, Oxygen and SiteOrigin keep the page body in meta * and leave `post_content` empty or stale, so comparing `post_content` * across a save — which is all track_content_change() can see — reports * "nothing changed" for a page that was rewritten end to end. Divi and * Beaver Builder write into `post_content` and need nothing here. * * @return string[] */ public static function builder_meta_keys() { $keys = array( '_elementor_data', '_breakdance_data', 'ct_builder_shortcodes', 'ct_builder_json', 'panels_data', ); /** * Filter the meta keys treated as page-builder content. * * @param string[] $keys Meta keys. */ return apply_filters( 'oorank_builder_meta_keys', $keys ); } /** * Record a content change when a builder writes its own body. * * WordPress skips the update entirely when a meta value is unchanged, so * this action firing *is* the change — which also means a builder that * re-saves identical data on a settings-only edit does not register one. * That is the same discipline post_content gets, applied where the * content actually lives. * * @param int $meta_id Meta row ID. * @param int $post_id Post ID. * @param string $meta_key Meta key. */ public function track_builder_change( $meta_id, $post_id, $meta_key ) { unset( $meta_id ); if ( ! in_array( $meta_key, self::builder_meta_keys(), true ) ) { return; } $post_id = absint( $post_id ); if ( ! $post_id || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } // Never later than the post itself: a builder can write meta before // the post row is bumped, and a content date ahead of post_modified is // the one thing modified_gmt() refuses to publish. $post = get_post( $post_id ); $stamp = current_time( 'mysql', true ); if ( $post instanceof WP_Post && $post->post_modified_gmt > $stamp ) { $stamp = $post->post_modified_gmt; } update_post_meta( $post_id, self::META, $stamp ); } // ---- Accurate dateModified ---- /** * Record whether a save actually changed the content. * * Three cases, in order: * - The title, body or excerpt changed: this save is the new content date. * - Nothing changed and nothing is recorded yet: keep the date from * *before* this save, which is the last date that could have been real. * Without this the first trivial edit after installing would bake the * bumped timestamp in permanently. * - Nothing changed and a date is already recorded: leave it alone. * * @param int $post_id Post ID. * @param WP_Post $post_after Post after the update. * @param WP_Post $post_before Post before the update. */ public function track_content_change( $post_id, $post_after, $post_before ) { if ( ! $post_after instanceof WP_Post || ! $post_before instanceof WP_Post ) { return; } if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $changed = $post_after->post_content !== $post_before->post_content || $post_after->post_title !== $post_before->post_title || $post_after->post_excerpt !== $post_before->post_excerpt; if ( $changed ) { update_post_meta( $post_id, self::META, $post_after->post_modified_gmt ); return; } if ( '' === (string) get_post_meta( $post_id, self::META, true ) ) { update_post_meta( $post_id, self::META, $post_before->post_modified_gmt ); } } /** * When a post's content last actually changed, as a GMT date string. * * Never later than `post_modified_gmt` — a recorded date that has somehow * run ahead of the post is a bug, and publishing it would overstate * freshness, which is the exact failure this feature exists to prevent. * * @param WP_Post|int $post Post or ID. * @return string `Y-m-d H:i:s` GMT, or '' when unknown. */ public static function modified_gmt( $post ) { $post = get_post( $post ); if ( ! $post instanceof WP_Post ) { return ''; } $fallback = (string) $post->post_modified_gmt; $recorded = (string) get_post_meta( $post->ID, self::META, true ); if ( '' === $recorded || $recorded > $fallback ) { return $fallback; } return $recorded; } // ---- Staleness ---- /** * The configured staleness threshold in months. * * @param Oorank_Settings $settings Settings store. * @return int */ public static function months( Oorank_Settings $settings ) { $all = $settings->get(); $value = isset( $all['freshness']['months'] ) ? (int) $all['freshness']['months'] : self::DEFAULT_MONTHS; return self::clamp_months( $value ); } /** * Hold a month count to a range that means something. * * @param int $months Raw value. * @return int */ public static function clamp_months( $months ) { $months = absint( $months ); return max( 1, min( 60, $months ? $months : self::DEFAULT_MONTHS ) ); } /** * Published content whose body has not changed inside the threshold. * * Two queries, because the honest date can live in either place. Posts * whose `post_modified` is already old are stale outright; posts that look * recent may still be stale once the recorded content date is consulted, * and those are precisely the ones a plain query would miss. * * @param int $months Threshold in months. * @param int $limit Rows to return. * @return array{items:array, total:int, capped:bool, months:int} */ public function stale( $months = 0, $limit = 20 ) { $months = $months ? self::clamp_months( $months ) : self::months( $this->settings ); $limit = max( 1, min( 50, absint( $limit ) ) ); $cutoff = gmdate( 'Y-m-d H:i:s', strtotime( '-' . $months . ' months' ) ); $types = $this->post_types(); if ( ! $types ) { return array( 'items' => array(), 'total' => 0, 'capped' => false, 'months' => $months, ); } $base = array( 'post_type' => $types, 'post_status' => 'publish', 'fields' => 'ids', 'posts_per_page' => self::MAX, 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_term_cache' => false, 'orderby' => 'modified', 'order' => 'ASC', ); $by_date = get_posts( array_merge( $base, array( 'date_query' => array( array( 'column' => 'post_modified_gmt', 'before' => $cutoff, ), ), ) ) ); $by_meta = get_posts( array_merge( $base, array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded to MAX rows, admin-only, and no index can answer "content older than X" otherwise. 'meta_query' => array( array( 'key' => self::META, 'value' => $cutoff, 'compare' => '<', 'type' => 'DATETIME', ), ), ) ) ); $capped = count( $by_date ) >= self::MAX || count( $by_meta ) >= self::MAX; $ids = array_values( array_unique( array_merge( $by_date, $by_meta ) ) ); $items = array(); foreach ( $ids as $id ) { $modified = self::modified_gmt( $id ); if ( '' === $modified || $modified >= $cutoff ) { continue; } $stamp = (int) strtotime( $modified . ' UTC' ); $items[] = array( 'id' => (int) $id, 'title' => get_the_title( $id ), 'url' => get_permalink( $id ), 'edit_url' => (string) get_edit_post_link( $id, 'raw' ), 'modified' => $stamp, 'age_days' => (int) floor( ( time() - $stamp ) / DAY_IN_SECONDS ), ); } usort( $items, static function ( $a, $b ) { return $a['modified'] <=> $b['modified']; } ); return array( 'items' => array_slice( $items, 0, $limit ), 'total' => count( $items ), 'capped' => $capped, 'months' => $months, ); } /** * The post types staleness covers — the ones llms.txt advertises, so the * freshness list and the site audit describe the same body of content. * * @return string[] */ private function post_types() { $all = $this->settings->get(); $types = isset( $all['llms']['post_types'] ) && is_array( $all['llms']['post_types'] ) ? $all['llms']['post_types'] : array(); return array_values( array_filter( $types, 'post_type_exists' ) ); } // ---- REST ---- /** * Register the freshness route. */ public function register_routes() { register_rest_route( Oorank_Rest::NAMESPACE, '/freshness', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_stale' ), 'permission_callback' => array( $this, 'can_manage' ), 'args' => array( 'months' => array( 'type' => 'integer', 'required' => false, 'sanitize_callback' => 'absint', ), ), ) ); } /** * Permission callback — the Oorank capability, with the REST nonce enforced by * the cookie-auth layer. * * @return bool */ public function can_manage() { return Oorank_Roles::can(); } /** * GET /freshness. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_stale( WP_REST_Request $request ) { $months = absint( $request->get_param( 'months' ) ); return rest_ensure_response( $this->stale( $months ) ); } }