array('label' => __( 'Needs attention', 'ai-eshop-optimizer' ), 'icon' => 'zap'), 'drafts' => array('label' => __( 'AI drafts', 'ai-eshop-optimizer' ), 'icon' => 'sparkles'), 'all' => array('label' => __( 'All content', 'ai-eshop-optimizer' ), 'icon' => 'list'), ); } public function get_tab_arg() { return 'metab_tab'; } public function register_hooks() { add_action( 'wp_ajax_aieo_dmm_metab_use_ours', array( $this, 'ajax_use_ours' ) ); add_action( 'wp_ajax_aieo_dmm_metab_ai_draft', array( $this, 'ajax_ai_draft' ) ); add_action( 'wp_ajax_aieo_dmm_metab_decide', array( $this, 'ajax_decide' ) ); add_action( 'wp_ajax_aieo_dmm_metab_bulk', array( $this, 'ajax_bulk' ) ); add_filter( 'aieo_dmm_admin_navigator_cards', array( $this, 'navigator_cards' ) ); add_filter( 'aieo_report_rows', array( $this, 'decorate_rows' ), 10, 2 ); } public function navigator_cards( $cards ) { $slug = AIEO_DMM::MENU_SLUG; foreach ( AIEO_DMM_UI::normalise_tabs( $this->get_tabs() ) as $key => $tab ) { $cards[] = array( 'label' => sprintf( __( 'Descriptions & Keywords — %s', 'ai-eshop-optimizer' ), $tab['label'] ), 'page_slug' => $slug, 'dmm_tab' => 'seo_meta_batch', 'query' => array( 'metab_tab' => $key ), ); } return $cards; } private function guard() { AIEO_DMM::verify_ajax_request(); if ( ! current_user_can( 'manage_woocommerce' ) && ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'ai-eshop-optimizer' ) ) ); } } public static function covered_types() { $types = get_post_types( array( 'public' => true ), 'names' ); unset( $types['attachment'] ); return array_values( (array) apply_filters( 'aieo_seo_meta_batch_types', array_values( $types ) ) ); } private static function list_where( $tab, $type, $q ) { $types = self::covered_types(); if ( '' !== $type && in_array( $type, $types, true ) ) { $types = array( $type ); } $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $where = "p.post_status = 'publish' AND p.post_type IN ({$placeholders})"; $args = $types; if ( 'drafts' === $tab ) { $where .= " AND sp.ai_state = 'suggested'"; } elseif ( 'needs' === $tab ) { $where .= " AND ( sp.object_id IS NULL OR COALESCE(sp.description, '') = '' OR COALESCE(sp.keyphrases, '') = '' OR sp.keyphrases NOT LIKE '%%additional\":[{%%' )"; } if ( '' !== $q ) { $where .= ' AND p.post_title LIKE %s'; $args[] = '%' . $GLOBALS['wpdb']->esc_like( $q ) . '%'; } return array( 'where' => $where, 'args' => $args, ); } private static function order_clause( $sort, $dir ) { $dir = ( 'asc' === strtolower( (string) $dir ) ) ? 'ASC' : 'DESC'; $allowed = array( 'title' => 'p.post_title', 'type' => 'p.post_type', 'demand' => 'COALESCE(sp.search_volume_total, 0)', 'striking' => 'COALESCE(sp.striking_count, 0)', 'drafted' => 'sp.suggested_at', ); if ( isset( $allowed[ $sort ] ) ) { return $allowed[ $sort ] . ' ' . $dir . ', p.ID ASC'; } return 'COALESCE(sp.search_volume_total, 0) DESC, p.ID ASC'; } public static function list_page( $tab, $type, $q, $sort, $dir, $page ) { global $wpdb; $sp = $wpdb->prefix . 'aieo_seo_posts'; $w = self::list_where( $tab, $type, $q ); $join = "FROM {$wpdb->posts} p LEFT JOIN `{$sp}` sp ON sp.object_type = p.post_type AND sp.object_id = p.ID WHERE {$w['where']}"; $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) {$join}", $w['args'] ) ); $offset = max( 0, ( max( 1, (int) $page ) - 1 ) * self::PER_PAGE ); $order = self::order_clause( $sort, $dir ); $args = array_merge( $w['args'], array( self::PER_PAGE, $offset ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_type, p.post_title, sp.title AS seo_title, sp.description, sp.keyphrases, sp.focus_keyphrase, sp.ai_state, sp.ai_locked, sp.suggested_title, sp.suggested_description, sp.suggested_keyphrase, sp.suggested_at, COALESCE(sp.search_volume_total, 0) AS demand, COALESCE(sp.striking_count, 0) AS striking {$join} ORDER BY {$order} LIMIT %d OFFSET %d", $args ), ARRAY_A ); return array( 'rows' => is_array( $rows ) ? $rows : array(), 'total' => $total, ); } public function decorate_rows( $rows, $def ) { if ( ! is_object( $def ) || 'seo_meta_batch_catalogue' !== $def->id() ) { return $rows; } $ai_on = ( class_exists( 'AIEO_SEO_AI_Assist' ) && AIEO_SEO_AI_Assist::available() ) ? '1' : '0'; $cues = class_exists( 'AIEO_SEO_Semantic_Cues' ); $kw = class_exists( 'AIEO_SEO_Semantic_Cues' ); foreach ( $rows as $i => $r ) { $rows[ $i ]['ai_available'] = $ai_on; $rows[ $i ]['keyword_count'] = $kw ? count( (array) AIEO_SEO_Semantic_Cues::keywords_from_json( (string) $r['keyphrases'] ) ) : 0; $rows[ $i ]['ours_description'] = ''; $rows[ $i ]['ours_keywords'] = array(); if ( $cues ) { $cue = AIEO_SEO_Semantic_Cues::for_object( (string) $r['post_type'], (int) $r['ID'] ); if ( $cue ) { $rows[ $i ]['ours_description'] = (string) $cue['description']; $rows[ $i ]['ours_keywords'] = (array) $cue['keywords']; } } } return $rows; } public function render_subtab() { $tabs = $this->get_tabs(); $tab = isset( $_GET['metab_tab'] ) ? sanitize_key( wp_unslash( $_GET['metab_tab'] ) ) : 'needs'; if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'needs'; } $this->render_template( 'seo/meta-batch.php', array( 'metab_tabs' => $tabs, 'metab_tab' => $tab, 'metab_ai_on' => class_exists( 'AIEO_SEO_AI_Assist' ) && AIEO_SEO_AI_Assist::available(), 'metab_nonce' => wp_create_nonce( AIEO_DMM::NONCE_ACTION ), ) ); } private function posted_object() { $type = isset( $_POST['object_type'] ) ? sanitize_key( wp_unslash( $_POST['object_type'] ) ) : ''; $id = isset( $_POST['object_id'] ) ? (int) $_POST['object_id'] : 0; $post = get_post( $id ); if ( ! $post instanceof WP_Post || $post->post_type !== $type || 'publish' !== $post->post_status ) { wp_send_json_error( array( 'message' => __( 'Object not found or not published.', 'ai-eshop-optimizer' ) ) ); } return array( $type, $id ); } public function ajax_use_ours() { $this->guard(); list( $type, $id ) = $this->posted_object(); $what = isset( $_POST['what'] ) ? sanitize_key( wp_unslash( $_POST['what'] ) ) : 'both'; if ( ! class_exists( 'AIEO_SEO_Semantic_Cues' ) ) { wp_send_json_error( array( 'message' => __( 'Cue engine unavailable.', 'ai-eshop-optimizer' ) ) ); } $cues = AIEO_SEO_Semantic_Cues::for_object( $type, $id ); $fields = array(); if ( ( 'description' === $what || 'both' === $what ) && '' !== (string) $cues['description'] ) { $fields['description'] = (string) $cues['description']; } if ( ( 'keywords' === $what || 'both' === $what ) && ! empty( $cues['keywords'] ) ) { $existing = (array) AIEO_SEO_Store::get( $type, $id ); $fields['keyphrases'] = AIEO_SEO_Semantic_Cues::keywords_to_json( (string) ( $existing['keyphrases'] ?? '' ), array_map( 'strval', (array) $cues['keywords'] ) ); } if ( empty( $fields ) ) { wp_send_json_error( array( 'message' => __( 'Nothing to apply — the cue engine produced no suggestion for this object.', 'ai-eshop-optimizer' ) ) ); } $ok = AIEO_SEO_Store::save( $type, $id, $fields ); if ( $ok && class_exists( 'AIEO_SEO_Head_Builder' ) && method_exists( 'AIEO_SEO_Head_Builder', 'build' ) ) { AIEO_SEO_Head_Builder::build( $type, $id ); } $ok ? wp_send_json_success( array( 'applied' => array_keys( $fields ) ) ) : wp_send_json_error( array( 'message' => __( 'Save failed.', 'ai-eshop-optimizer' ) ) ); } public function ajax_ai_draft() { $this->guard(); list( $type, $id ) = $this->posted_object(); $res = $this->draft_one( $type, $id ); if ( is_wp_error( $res ) ) { wp_send_json_error( array( 'message' => $res->get_error_message() ) ); } wp_send_json_success( array( 'state' => $res ) ); } private function draft_one( $type, $id ) { if ( ! class_exists( 'AIEO_SEO_AI_Assist' ) || ! AIEO_SEO_AI_Assist::available() ) { return new WP_Error( 'no_ai', __( 'AI assist unavailable — connect your Rooster account token first.', 'ai-eshop-optimizer' ) ); } $row = (array) AIEO_SEO_Store::get( $type, $id ); if ( ! empty( $row['ai_locked'] ) ) { return new WP_Error( 'locked', __( 'This object is locked against AI writes.', 'ai-eshop-optimizer' ) ); } if ( class_exists( 'AIEO_SEO_Write_Queue' ) ) { $state = AIEO_SEO_Write_Queue::process( $type, $id ); if ( in_array( $state, array( 'drafted', 'applied' ), true ) ) { return $state; } } $post = get_post( $id ); $res = AIEO_SEO_AI_Assist::suggest( $post ); if ( is_wp_error( $res ) ) { return $res; } if ( '' === (string) $res['title'] && '' === (string) $res['description'] ) { return new WP_Error( 'empty', __( 'The AI returned an empty suggestion.', 'ai-eshop-optimizer' ) ); } AIEO_SEO_Store::save( $type, $id, array( 'suggested_title' => (string) $res['title'], 'suggested_description' => (string) $res['description'], 'suggested_keyphrase' => (string) ( $res['keyphrase'] ?? '' ), 'suggested_at' => current_time( 'mysql', true ), 'ai_state' => 'suggested', ) ); return 'drafted'; } public function ajax_decide() { $this->guard(); list( $type, $id ) = $this->posted_object(); $decision = isset( $_POST['decision'] ) ? sanitize_key( wp_unslash( $_POST['decision'] ) ) : ''; if ( ! class_exists( 'AIEO_SEO_Write_Queue' ) || ! in_array( $decision, array( 'approve', 'dismiss' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Invalid decision.', 'ai-eshop-optimizer' ) ) ); } $ok = ( 'approve' === $decision ) ? AIEO_SEO_Write_Queue::approve( $type, $id ) : AIEO_SEO_Write_Queue::dismiss( $type, $id ); $ok ? wp_send_json_success( array( 'decision' => $decision ) ) : wp_send_json_error( array( 'message' => __( 'Decision failed — the draft may have changed underneath you. Reload the list.', 'ai-eshop-optimizer' ) ) ); } public function ajax_bulk() { $this->guard(); global $wpdb; $op = isset( $_POST['op'] ) ? sanitize_key( wp_unslash( $_POST['op'] ) ) : ''; $tab = isset( $_POST['tab'] ) ? sanitize_key( wp_unslash( $_POST['tab'] ) ) : 'needs'; $type = isset( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : ''; $q = isset( $_POST['q'] ) ? sanitize_text_field( wp_unslash( $_POST['q'] ) ) : ''; if ( ! in_array( $op, array( 'use_ours', 'ai_draft', 'approve_all' ), true ) ) { wp_send_json_error( array( 'message' => __( 'Unknown bulk operation.', 'ai-eshop-optimizer' ) ) ); } if ( 'approve_all' === $op ) { $tab = 'drafts'; } $sp = $wpdb->prefix . 'aieo_seo_posts'; $w = self::list_where( $tab, $type, $q ); $cap = ( 'ai_draft' === $op ) ? self::BULK_AI_CAP : self::BULK_DB_CHUNK; $ids = $wpdb->get_results( $wpdb->prepare( "SELECT p.ID, p.post_type FROM {$wpdb->posts} p LEFT JOIN `{$sp}` sp ON sp.object_type = p.post_type AND sp.object_id = p.ID WHERE {$w['where']} ORDER BY COALESCE(sp.search_volume_total, 0) DESC, p.ID ASC LIMIT %d", array_merge( $w['args'], array( $cap + 1 ) ) ), ARRAY_A ); $more = count( $ids ) > $cap; $ids = array_slice( $ids, 0, $cap ); $done = 0; $skipped = 0; foreach ( $ids as $r ) { $type_r = (string) $r['post_type']; $id_r = (int) $r['ID']; if ( 'use_ours' === $op ) { $ok = $this->bulk_use_ours_one( $type_r, $id_r ); } elseif ( 'approve_all' === $op ) { $ok = AIEO_SEO_Write_Queue::approve( $type_r, $id_r ); } else { $state = $this->draft_one( $type_r, $id_r ); $ok = ! is_wp_error( $state ); } $ok ? $done++ : $skipped++; } wp_send_json_success( array( 'done' => $done, 'skipped' => $skipped, 'more' => $more, 'message' => $more ? sprintf( __( 'Processed %1$d (%2$d skipped) — more remain, run again to continue.', 'ai-eshop-optimizer' ), $done, $skipped ) : sprintf( __( 'Done: %1$d processed, %2$d skipped. Filter is drained.', 'ai-eshop-optimizer' ), $done, $skipped ), ) ); } private function bulk_use_ours_one( $type, $id ) { if ( ! class_exists( 'AIEO_SEO_Semantic_Cues' ) ) { return false; } $cues = AIEO_SEO_Semantic_Cues::for_object( $type, $id ); $existing = (array) AIEO_SEO_Store::get( $type, $id ); $fields = array(); if ( '' === trim( (string) ( $existing['description'] ?? '' ) ) && '' !== (string) $cues['description'] ) { $fields['description'] = (string) $cues['description']; } $have_kw = array(); if ( class_exists( 'AIEO_SEO_Semantic_Cues' ) ) { $have_kw = AIEO_SEO_Semantic_Cues::keywords_from_json( (string) ( $existing['keyphrases'] ?? '' ) ); } if ( empty( $have_kw ) && ! empty( $cues['keywords'] ) ) { $fields['keyphrases'] = AIEO_SEO_Semantic_Cues::keywords_to_json( (string) ( $existing['keyphrases'] ?? '' ), array_map( 'strval', (array) $cues['keywords'] ) ); } if ( empty( $fields ) ) { return false; } $ok = AIEO_SEO_Store::save( $type, $id, $fields ); if ( $ok && class_exists( 'AIEO_SEO_Head_Builder' ) && method_exists( 'AIEO_SEO_Head_Builder', 'build' ) ) { AIEO_SEO_Head_Builder::build( $type, $id ); } return (bool) $ok; } }