'_awb_seo_title', 'missing_description' => '_awb_seo_description', 'missing_keyword' => '_awb_seo_focus_keyword', ]; public static function get_posts( $post_type = 'post', $per_page = 50, $page = 1, $search = '', $filter = '' ) { // 'any' means "Posts & Pages together" - used by the SEO-health // "Fix →" links, which count issues across both, not just posts. $post_types = ( 'any' === $post_type ) ? [ 'post', 'page' ] : $post_type; $args = [ 'post_type' => $post_types, 'post_status' => 'publish', 'posts_per_page' => $per_page, 'paged' => max( 1, $page ), 'orderby' => 'modified', 'order' => 'DESC', ]; if ( $search ) { $args['s'] = sanitize_text_field( $search ); } if ( $filter && isset( self::FILTER_META_KEYS[ $filter ] ) ) { $meta_key = self::FILTER_META_KEYS[ $filter ]; $args['meta_query'] = [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query 'relation' => 'OR', [ 'key' => $meta_key, 'compare' => 'NOT EXISTS' ], [ 'key' => $meta_key, 'value' => '', 'compare' => '=' ], ]; } $query = new WP_Query( $args ); $posts = []; foreach ( $query->posts as $post ) { $title = AWBSEO_Meta_Manager::get( $post->ID, 'title' ); $desc = AWBSEO_Meta_Manager::get( $post->ID, 'description' ); $kw = AWBSEO_Meta_Manager::get( $post->ID, 'focus_keyword' ); $raw = $post->post_excerpt ?: $post->post_content; $suggested_desc = mb_substr( wp_strip_all_tags( wp_trim_words( $raw, 30, '' ) ), 0, 155 ); $posts[] = [ 'id' => $post->ID, 'post_title' => $post->post_title, 'url' => get_permalink( $post->ID ), 'seo_title' => $title, 'seo_description' => $desc, 'focus_keyword' => $kw, 'suggested_desc' => $suggested_desc, 'title_len' => mb_strlen( $title ?: AWBSEO_Meta_Manager::get_title( $post->ID ) ), 'desc_len' => mb_strlen( $desc ?: AWBSEO_Meta_Manager::get_description( $post->ID ) ), 'modified' => $post->post_modified, 'has_issues' => ( ! $title || ! $desc || ! $kw ), ]; } return [ 'posts' => $posts, 'total' => $query->found_posts, 'total_pages' => $query->max_num_pages, ]; } /** * Save bulk edits from the admin table. * Expects $_POST['awb_bulk'] as an array of [ id, seo_title, seo_description, focus_keyword ]. */ public static function save_bulk( $items ) { if ( ! current_user_can( 'edit_posts' ) ) { return new WP_Error( 'forbidden', 'You do not have permission to bulk-edit SEO data.' ); } $saved = 0; foreach ( $items as $item ) { $post_id = (int) ( $item['id'] ?? 0 ); // Generic edit_posts above just confirms the user can edit // *something*; without this per-post check a Contributor/Author // could pass any post ID - including someone else's draft - and // overwrite its SEO meta (IDOR). if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) continue; if ( isset( $item['seo_title'] ) ) { update_post_meta( $post_id, '_awb_seo_title', sanitize_text_field( $item['seo_title'] ) ); } if ( isset( $item['seo_description'] ) ) { update_post_meta( $post_id, '_awb_seo_description', sanitize_textarea_field( $item['seo_description'] ) ); } if ( isset( $item['focus_keyword'] ) ) { update_post_meta( $post_id, '_awb_seo_focus_keyword', sanitize_text_field( $item['focus_keyword'] ) ); } // This saves meta directly via AJAX rather than a full post // save, so save_post (and the score caching hung off it) never // fires on its own - without this, filling in a missing title // here would clear the Dashboard's "missing title" count right // away (that count reads the meta directly) but leave the // post's SEO Score and issue count stale until it's next // opened, saved, or rescanned. if ( class_exists( 'AWBSEO_Post_Columns' ) ) { AWBSEO_Post_Columns::analyze_and_cache( $post_id ); } $saved++; } return $saved; } /** * Export all SEO meta as CSV. */ public static function export_csv() { if ( ! current_user_can( 'export' ) ) { wp_die( 'Permission denied.' ); } $posts = get_posts( [ 'post_type' => [ 'post', 'page' ], 'post_status' => 'publish', 'posts_per_page' => -1 ] ); header( 'Content-Type: text/csv; charset=UTF-8' ); header( 'Content-Disposition: attachment; filename="awb-seo-export-' . date( 'Y-m-d' ) . '.csv"' ); header( 'Pragma: no-cache' ); $out = fopen( 'php://output', 'w' ); fputcsv( $out, [ 'ID', 'Post Title', 'URL', 'SEO Title', 'Meta Description', 'Focus Keyword', 'Robots Index', 'Robots Follow' ] ); foreach ( $posts as $post ) { fputcsv( $out, [ $post->ID, $post->post_title, get_permalink( $post->ID ), AWBSEO_Meta_Manager::get( $post->ID, 'title' ), AWBSEO_Meta_Manager::get( $post->ID, 'description' ), AWBSEO_Meta_Manager::get( $post->ID, 'focus_keyword' ), AWBSEO_Meta_Manager::get( $post->ID, 'robots_index', 'index' ), AWBSEO_Meta_Manager::get( $post->ID, 'robots_follow', 'follow' ), ] ); } fclose( $out ); exit; } }