' . esc_html( sprintf( /* translators: %s: template file name */ __( 'テンプレートファイル %s が見つかりません。', 'aio-agent-article-generator' ), $template_name ) ) . '
'; } } /** * キーワード管理アクション処理 */ private static function handle_keywords_actions() { if ( ! isset( $_POST['action'] ) || ! isset( $_POST['aagl_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['aagl_nonce'] ) ), 'aagl_keywords_action' ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } global $wpdb; $main_keywords_table = $wpdb->prefix . 'aagl_main_keywords'; $sub_keywords_table = $wpdb->prefix . 'aagl_sub_keywords'; $action = sanitize_text_field( wp_unslash( $_POST['action'] ) ); switch ( $action ) { case 'add_main_keyword': if ( isset( $_POST['keyword'] ) && ! empty( $_POST['keyword'] ) ) { $keyword = sanitize_text_field( wp_unslash( $_POST['keyword'] ) ); $category = isset( $_POST['category'] ) ? sanitize_text_field( wp_unslash( $_POST['category'] ) ) : ''; $priority = isset( $_POST['priority'] ) ? absint( $_POST['priority'] ) : 1; $notes = isset( $_POST['notes'] ) ? sanitize_textarea_field( wp_unslash( $_POST['notes'] ) ) : ''; $result = $wpdb->insert( $main_keywords_table, array( 'keyword' => $keyword, 'category' => $category, 'priority' => $priority, 'status' => 'active', 'notes' => $notes, ), array( '%s', '%s', '%d', '%s', '%s' ) ); if ( false !== $result ) { AAGL_Utilities::add_admin_notice( 'メインキーワードを追加しました。', 'success' ); } else { AAGL_Utilities::add_admin_notice( 'キーワードの追加に失敗しました。', 'error' ); } } break; case 'delete_main_keyword': if ( isset( $_POST['keyword_id'] ) ) { $keyword_id = absint( $_POST['keyword_id'] ); // AAGL_Keyword_Functionsの手動カスケード削除を使用 // (dbDeltaは外部キー制約を作成しないため、手動で関連データを削除する必要がある) $keyword_functions = AAGL_Keyword_Functions::get_instance(); $result = $keyword_functions->delete_main_keyword( $keyword_id ); if ( $result['success'] ) { AAGL_Utilities::add_admin_notice( $result['message'], 'success' ); } else { AAGL_Utilities::add_admin_notice( $result['error'], 'error' ); } } break; case 'add_sub_keyword': if ( isset( $_POST['sub_keyword'] ) && ! empty( $_POST['sub_keyword'] ) && isset( $_POST['main_keyword_id'] ) ) { $sub_keywords_input = sanitize_textarea_field( wp_unslash( $_POST['sub_keyword'] ) ); $main_keyword_id = intval( $_POST['main_keyword_id'] ); // メインキーワードの存在確認 $main_exists = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}aagl_main_keywords WHERE id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $main_keyword_id ) ); if ( $main_exists ) { // カンマ区切りで分割して複数のサブキーワードを処理 $sub_keywords = array_map( 'trim', explode( ',', $sub_keywords_input ) ); $sub_keywords = array_filter( $sub_keywords ); // 空の要素を削除 $success_count = 0; $error_count = 0; $duplicate_count = 0; foreach ( $sub_keywords as $sub_keyword ) { if ( empty( $sub_keyword ) ) { continue; } // 重複チェック $existing = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}aagl_sub_keywords WHERE main_keyword_id = %d AND sub_keyword = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $main_keyword_id, $sub_keyword ) ); if ( $existing > 0 ) { $duplicate_count++; continue; } $result = $wpdb->insert( $sub_keywords_table, array( 'main_keyword_id' => $main_keyword_id, 'sub_keyword' => $sub_keyword, 'status' => 'active', 'source' => 'manual', ), array( '%d', '%s', '%s', '%s' ) ); if ( false !== $result ) { $success_count++; } else { $error_count++; } } // 結果メッセージ $message_parts = array(); if ( $success_count > 0 ) { $message_parts[] = "{$success_count}個のサブキーワードを追加しました"; } if ( $duplicate_count > 0 ) { $message_parts[] = "{$duplicate_count}個は既に存在するためスキップしました"; } if ( $error_count > 0 ) { $message_parts[] = "{$error_count}個の追加に失敗しました"; } if ( $success_count > 0 ) { AAGL_Utilities::add_admin_notice( implode( '、', $message_parts ) . '。', 'success' ); } elseif ( $duplicate_count > 0 && 0 === $error_count ) { AAGL_Utilities::add_admin_notice( implode( '、', $message_parts ) . '。', 'warning' ); } else { AAGL_Utilities::add_admin_notice( implode( '、', $message_parts ) . '。', 'error' ); } } else { AAGL_Utilities::add_admin_notice( '指定されたメインキーワードが存在しません。', 'error' ); } } break; case 'delete_sub_keyword': if ( isset( $_POST['sub_keyword_id'] ) ) { $sub_keyword_id = intval( $_POST['sub_keyword_id'] ); $result = $wpdb->delete( $sub_keywords_table, array( 'id' => $sub_keyword_id ), array( '%d' ) ); if ( false !== $result ) { AAGL_Utilities::add_admin_notice( 'サブキーワードを削除しました。', 'success' ); } else { AAGL_Utilities::add_admin_notice( 'サブキーワードの削除に失敗しました。', 'error' ); } } break; case 'bulk_delete_sub_keyword': if ( isset( $_POST['sub_keyword_ids'] ) && is_array( $_POST['sub_keyword_ids'] ) ) { $ids = array_filter( array_map( 'intval', wp_unslash( $_POST['sub_keyword_ids'] ) ), function ( $id ) { return $id > 0; } ); $deleted = 0; foreach ( $ids as $sid ) { $r = $wpdb->delete( $sub_keywords_table, array( 'id' => $sid ), array( '%d' ) ); if ( false !== $r ) { $deleted += (int) $r; } } if ( $deleted > 0 ) { AAGL_Utilities::add_admin_notice( $deleted . '件のサブキーワードを削除しました。', 'success' ); } else { AAGL_Utilities::add_admin_notice( '削除するサブキーワードが選択されていません。', 'error' ); } } break; } } /** * API設定保存処理 */ private static function handle_api_settings_save() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified immediately below; this is an early bail-out. if ( ! isset( $_POST['submit'] ) ) { // 設定フォームが送信されていない場合は何もしない return; } if ( ! isset( $_POST['aagl_settings_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['aagl_settings_nonce'] ) ), 'aagl_settings_save' ) ) { AAGL_Utilities::add_admin_notice( 'セキュリティ検証に失敗しました。ページを再読み込みして再度お試しください。', 'error' ); return; } // パスワードロック有効時: セッショントークン検証 if ( AAGL_Utilities::is_limit_locked() && ! AAGL_Utilities::is_settings_unlocked() ) { AAGL_Utilities::add_admin_notice( '設定を変更するにはパスワード認証が必要です。', 'error' ); return; } // 設定データ取得と検証 $settings = array(); // API設定(暗号化保存、マスク値は既存値を保持) if ( isset( $_POST['aagl_settings']['api_settings'] ) ) { $api_settings = array_map( 'sanitize_text_field', wp_unslash( $_POST['aagl_settings']['api_settings'] ) ); $existing_api_settings = AAGL_Utilities::get_array_value( get_option( 'aagl_settings', array() ), 'api_settings', array() ); $api_key_fields = array( 'claude_api_key', 'gemini_api_key' ); $new_api_settings = array(); foreach ( $api_key_fields as $field ) { $submitted = isset( $api_settings[ $field ] ) ? $api_settings[ $field ] : ''; // マスク値(••••)=フォーム未変更 → 既存キーを保持。 // 空欄での保存 = ユーザーが明示的にキーをクリア(フォームは設定済みキーをマスク表示するため、 // 未変更ならマスクが送信され空にはならない=通常保存で誤って消える事故は起きない)。 if ( preg_match( '/^[•]+$/', $submitted ) ) { $new_api_settings[ $field ] = isset( $existing_api_settings[ $field ] ) ? $existing_api_settings[ $field ] : ''; } elseif ( '' === $submitted ) { $new_api_settings[ $field ] = ''; } else { $new_api_settings[ $field ] = AAGL_Utilities::encrypt_api_key( $submitted ); } } $settings['api_settings'] = $new_api_settings; } // システム設定 if ( isset( $_POST['aagl_settings']['system_settings'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $raw_system = wp_unslash( $_POST['aagl_settings']['system_settings'] ); $system_settings = array_map( 'sanitize_text_field', $raw_system ); $existing_system = AAGL_Utilities::get_array_value( get_option( 'aagl_settings', array() ), 'system_settings', array() ); $new_daily = isset( $system_settings['max_articles_per_day'] ) ? absint( $system_settings['max_articles_per_day'] ) : 10; $new_monthly = isset( $system_settings['max_articles_per_month'] ) ? absint( $system_settings['max_articles_per_month'] ) : 0; // 保存時はsanitize_keyのみ。取得時にget_post_type_setting()がpost_type_exists()で検証する $new_post_type = isset( $system_settings['post_type'] ) ? sanitize_key( $system_settings['post_type'] ) : 'post'; $new_parent_page = isset( $system_settings['parent_page_id'] ) ? absint( $system_settings['parent_page_id'] ) : 0; if ( $new_parent_page > 0 && ! get_post( $new_parent_page ) ) { $new_parent_page = 0; } $settings['system_settings'] = array_merge( $existing_system, array( 'max_articles_per_day' => $new_daily, 'max_articles_per_month' => $new_monthly, 'post_type' => $new_post_type, 'parent_page_id' => $new_parent_page, ) ); } // AI設定 if ( isset( $_POST['aagl_settings']['ai_settings'] ) ) { $ai_settings = array_map( 'sanitize_text_field', wp_unslash( $_POST['aagl_settings']['ai_settings'] ) ); // 注意: このハンドラと aio-agent-article-generator.php の validate_settings() の // 2経路が同じ aagl_settings を書く。新しいキーは必ず両方に定義すること // (過去に post_type と max_articles_per_month が片方漏れで消えている) $settings['ai_settings'] = array( 'preferred_ai' => isset( $ai_settings['preferred_ai'] ) ? sanitize_key( $ai_settings['preferred_ai'] ) : 'claude', 'temperature' => isset( $ai_settings['temperature'] ) ? floatval( $ai_settings['temperature'] ) : 0.7, 'claude_model' => isset( $ai_settings['claude_model'] ) ? $ai_settings['claude_model'] : AAGL_DEFAULT_CLAUDE_MODEL, 'gemini_model' => isset( $ai_settings['gemini_model'] ) ? $ai_settings['gemini_model'] : AAGL_DEFAULT_GEMINI_MODEL, 'claude_model_custom' => isset( $ai_settings['claude_model_custom'] ) ? $ai_settings['claude_model_custom'] : '', 'gemini_model_custom' => isset( $ai_settings['gemini_model_custom'] ) ? $ai_settings['gemini_model_custom'] : '', 'provider_fallback' => empty( $ai_settings['provider_fallback'] ) ? 0 : 1, ); // 設定を見直した以上、自動切替の通知は役目を終える if ( class_exists( 'AAGL_Model_Registry' ) ) { AAGL_Model_Registry::clear_auto_switches(); } } // 既存設定を取得してマージ $current_settings = get_option( 'aagl_settings', array() ); $merged_settings = array_merge( $current_settings, $settings ); // 設定保存(update_optionは値が変わらない場合もfalseを返すため、保存後に再取得して検証) $save_result = update_option( 'aagl_settings', $merged_settings, false ); AAGL_Utilities::clear_settings_cache(); if ( $save_result ) { AAGL_Utilities::add_admin_notice( '設定を保存しました。', 'success' ); } else { // update_optionは値未変更時もfalseを返すため、実際にDBの値を確認 // キャッシュの古い値による誤検知を防ぐため、optionsキャッシュを破棄してDBから再読込し、 // 比較は厳密一致(===)ではなくシリアライズ等価で行う(v2.16.3: 三幸福祉会で誤検知事故) global $wpdb; wp_cache_delete( 'aagl_settings', 'options' ); $saved = get_option( 'aagl_settings', array() ); // update_option() は register_setting の sanitize コールバック(validate_settings)を通すため、 // 実際にDBへ書かれる値は $merged_settings を同フィルタに通した後の値になる(例: api_settings へ // ai_provider が付与される等)。照合対象も同じフィルタを適用して形を揃え、二重パスの形差による // 誤検知(保存は成功しているのに「失敗」と表示される)を防ぐ。 $expected = apply_filters( 'sanitize_option_aagl_settings', $merged_settings, 'aagl_settings' ); if ( maybe_serialize( $saved ) === maybe_serialize( $expected ) ) { AAGL_Utilities::add_admin_notice( '設定を保存しました。(変更なし)', 'success' ); } else { AAGL_Utilities::log( 'Settings save failed. wpdb last_error: ' . ( $wpdb->last_error ?: '(empty)' ), 'error' ); AAGL_Utilities::add_admin_notice( '設定の保存に失敗しました。詳細はエラーログを確認してください。', 'error' ); } } } /** * データテーブル用のページネーション生成 */ public static function render_pagination( $current_page, $total_items, $per_page, $base_url ) { $total_pages = ceil( $total_items / $per_page ); if ( $total_pages <= 1 ) { return; } echo '' . esc_html( $description ) . '
'; } // 通知表示 AAGL_Utilities::display_admin_notices(); } /** * 管理画面共通フッター */ public static function render_admin_footer() { echo '