service = $service ?: new AnswerRails_Knowledge_Service(); // Handle form submissions before headers are sent add_action('admin_init', array($this, 'handle_form_submission')); // Display admin notices add_action('admin_notices', array($this, 'display_admin_notices')); // Enqueue scripts and styles add_action('admin_enqueue_scripts', array($this, 'enqueue_assets')); } /** * Render the knowledge management page */ public function render_page() { // Load WP_List_Table class if not already loaded // This is CRITICAL - WP_List_Table is only available in admin context if (!class_exists('WP_List_Table')) { require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php'; } // Now load our custom list table class if not loaded if (!class_exists('AnswerRails_Knowledge_List_Table')) { require_once plugin_dir_path(dirname(__FILE__)) . 'admin/class-answerrails-knowledge-list-table.php'; } // Initialize list table with service dependency $this->list_table = new AnswerRails_Knowledge_List_Table($this->service); $this->list_table->prepare_items(); ?>


render_tabs(); ?>
current_tab(); switch ($current_tab) { case 'add': $this->render_add_form(); break; case 'settings': $this->render_settings_form(); break; default: $this->render_list_view(); break; } ?>
fails) $nonce = sanitize_text_field(wp_unslash($_POST['answerrails_knowledge_nonce'] ?? '')); if (!wp_verify_nonce($nonce, 'answerrails_knowledge_action')) { $this->add_notice('Security check failed', 'error'); return; } // Check capabilities if (!current_user_can('manage_options')) { $this->add_notice('You do not have permission to perform this action', 'error'); return; } $action = sanitize_text_field(wp_unslash($_POST['answerrails_knowledge_action'])); switch ($action) { case 'add_document': $this->handle_add_document(); break; case 'add_json': $this->handle_add_json(); break; case 'add_url': $this->handle_add_url(); break; // #73 cleanup (D1/D2): add_post / add_product (single-select // form-POST) are replaced by the Site Content picker's AJAX // flow; the update_knowledge case was stranded (no view ever // posted it — items remain view-only; the D6 sync-mode toggle // is the one sanctioned mutation, via its own AJAX endpoint). // #248 D4: bulk_sync / bulk_delete went the same way. Both keyed // on `bulk_item_ids[]`, which no view, template or JS has // emitted since the #139 Batch 1/2 rewiring — the three actions // above are the ONLY values any surface posts here. The live // replacements are '#sync-selected-items' (REST bulk-sync) and // '#answerrails-bulk-delete' (answerrails_bulk_knowledge_action). // ⚠ #140 (DD11): this tab saves no link-generation settings. // The canonical link-tuning controls live on the main Settings // page (#104), and answerrails_variation_config is deliberately // UI-less; a second surface saving the same options is a // split-brain. The // consolidated Settings tab saves via the settings API // (answerrails_knowledge_settings group), not this router. } // Redirect to prevent form resubmission if (!empty($this->notices)) { set_transient('answerrails_knowledge_notices', $this->notices, 30); wp_safe_redirect(remove_query_arg(array('action', 'item', '_wpnonce'))); exit; } } /** * Handle document upload */ private function handle_add_document() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce + capability verified upstream in handle_form_submission() before dispatch if (empty($_FILES['document_file']['name'])) { $this->add_notice('Please select a file to upload', 'error'); return; } // Handle file upload through WordPress if (!function_exists('wp_handle_upload')) { require_once(ABSPATH . 'wp-admin/includes/file.php'); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- the $_FILES array is consumed by wp_handle_upload(), WP's own sanitiser; the client-supplied NAME is sanitised at the read immediately below. $uploadedfile = $_FILES['document_file']; // #331 D6: sanitise the client-supplied filename AT THE READ. It // was already sanitised where it became a post_title, but it also // reached pathinfo() as the fallback item title with no sanitiser // on that path. sanitize_file_name() is idempotent and // create_knowledge_item() applies sanitize_text_field downstream, // so doing it here moves the guard earlier without moving // behaviour. if (isset($uploadedfile['name'])) { $uploadedfile['name'] = sanitize_file_name((string) $uploadedfile['name']); } $upload_overrides = array('test_form' => false); $movefile = wp_handle_upload($uploadedfile, $upload_overrides); if ($movefile && !isset($movefile['error'])) { // Create attachment $attachment = array( 'post_mime_type' => $movefile['type'], // FIX: Properly sanitize with UTF-8 preservation 'post_title' => sanitize_text_field( wp_strip_all_tags($uploadedfile['name']) ), 'post_content' => '', 'post_status' => 'inherit' ); $attach_id = wp_insert_attachment($attachment, $movefile['file']); if (!is_wp_error($attach_id)) { // FIX: Get title with proper encoding $title = !empty($_POST['document_title']) ? sanitize_text_field(wp_unslash($_POST['document_title'])) : pathinfo($uploadedfile['name'], PATHINFO_FILENAME); // Process through service layer $result = $this->service->create_knowledge_item(array( 'type' => 'document', // FIX: Explicitly set type 'title' => $title, 'media_attachment_id' => $attach_id, 'is_linkable' => !empty($_POST['is_linkable']), 'link_text' => sanitize_text_field(wp_unslash($_POST['link_text'] ?? '')), // #73 D7: documents never track a source — no sync-mode field. )); // #132 (D2): informational logging goes through the // WP_DEBUG-gated authority, never bare error_log. AnswerRails_Utils::debug_log('knowledge-admin:add-document result', array('result' => $result)); if (is_wp_error($result)) { wp_delete_attachment($attach_id, true); $this->add_notice($result->get_error_message(), 'error'); } else { $this->add_notice('Document knowledge item created successfully', 'success'); } } else { $this->add_notice('Failed to create attachment: ' . $attach_id->get_error_message(), 'error'); } } else { $this->add_notice('File upload failed: ' . ($movefile['error'] ?? 'Unknown error'), 'error'); } // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * Handle structured-data (JSON) knowledge */ private function handle_add_json() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce + capability verified upstream in handle_form_submission() before dispatch $title = sanitize_text_field(wp_unslash($_POST['json_title'] ?? '')); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON body: no string sanitiser can run here without corrupting it, so it is DECODED AND RE-ENCODED immediately below and the raw value never travels. $content_raw = wp_unslash($_POST['json_content'] ?? ''); if (empty($title) || empty($content_raw)) { $this->add_notice('Title and content are required', 'error'); return; } // #331 D6 (amendment §8.1): validate AT THE READ — decode, confirm // the decode succeeded, and re-encode through wp_json_encode, so // what travels onward is canonical JSON this code produced rather // than the raw request body. // // ⚠ NO top-level type restriction, deliberately. Both existing // validation sites — here and // AnswerRails_Knowledge_Service::process_json_knowledge() — check // json_last_error() ONLY, so a valid scalar document ("Open Mon-Fri", // 42, true) is accepted today and round-trips through // wp_json_encode. Requiring an array or object would WITHDRAW // content owners can add right now, which is a compatibility // change, not scanner remediation — #331 puts behaviour changes out // of scope. The one structural requirement in the product is // lookup_declaration_error(), and it applies only when the owner // declares a lookup table. // // Behaviour-identical by construction: the service re-encodes with // wp_json_encode($json, JSON_PRETTY_PRINT) downstream, so the // stored bytes were always this function's output, never the raw // body. $decoded = json_decode((string) $content_raw); if (json_last_error() !== JSON_ERROR_NONE) { $this->add_notice('Invalid JSON: ' . json_last_error_msg(), 'error'); return; } $content = wp_json_encode($decoded); if (false === $content) { $this->add_notice('Invalid JSON: the content could not be re-encoded', 'error'); return; } $result = $this->service->create_knowledge_item(array( 'type' => 'json', 'title' => $title, 'content' => $content, 'is_linkable' => !empty($_POST['is_linkable']), 'link_text' => sanitize_text_field(wp_unslash($_POST['link_text'] ?? '')), // #73 D7: json never tracks a source — no sync-mode field. // #142 DD5: the owner's lookup-table declaration. Validation // (declared fields must fit the content) lives in the service — // one authority for this route and every future one. 'lookup_table' => !empty($_POST['lookup_table']), 'lookup_key_field' => sanitize_text_field(wp_unslash($_POST['lookup_key_field'] ?? '')), 'lookup_value_field' => sanitize_text_field(wp_unslash($_POST['lookup_value_field'] ?? '')), // Multi-section amendment: checkboxes post an array of section // paths; stored as a CSV in the single lookup_path meta. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each element is unslashed then sanitized in the maps below 'lookup_path' => implode(',', array_map('sanitize_text_field', array_map('wp_unslash', (array) ($_POST['lookup_path'] ?? array())))), )); if (is_wp_error($result)) { $this->add_notice($result->get_error_message(), 'error'); } else { $this->add_notice('JSON knowledge item created successfully', 'success'); } // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * Handle URL content */ private function handle_add_url() { // phpcs:disable WordPress.Security.NonceVerification.Missing -- nonce + capability verified upstream in handle_form_submission() before dispatch // ⚠ #132: never log raw $_POST here — it carries visitor-supplied // content. // FIX: Use correct field name 'url_source' $url = isset($_POST['url_source']) ? esc_url_raw(wp_unslash($_POST['url_source'])) : ''; if (empty($url)) { $this->add_notice('Please enter a URL', 'error'); return; } // Get optional fields $title = isset($_POST['url_title']) ? sanitize_text_field(wp_unslash($_POST['url_title'])) : ''; // Build data array with title only if non-empty $data = array( 'type' => 'url', 'url' => $url, 'is_linkable' => !empty($_POST['is_linkable']), // #73 D7: sync-mode radio pair, default Auto — anything except an // explicit 'manual' tracks the source (aligns the UI with the // service default instead of silently defeating it). 'auto_sync' => 'manual' !== sanitize_key(wp_unslash($_POST['sync_mode'] ?? 'auto')), ); // Only add title if user provided one if (!empty($title)) { $data['title'] = $title; } // Process through service layer $result = $this->service->create_knowledge_item($data); if (is_wp_error($result)) { $this->add_notice($result->get_error_message(), 'error'); } else { $this->add_notice('URL knowledge item created successfully', 'success'); } // phpcs:enable WordPress.Security.NonceVerification.Missing } /** * Render navigation tabs */ /** * Resolve the active tab (#140 — the ONE tab-resolution site for * render_page and render_tabs). First-run semantics: OpenAI key present * + no vector store = vector search is one step from ready, so land on * Settings. */ private function current_tab() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change $tab = isset($_GET['tab']) ? sanitize_key($_GET['tab']) : ''; if ('' !== $tab) { return $tab; } if ('' !== AnswerRails_Utils::get_api_key_for('openai') && '' === (string) get_option('answerrails_knowledge_vector_store_id', '')) { return 'settings'; } return 'list'; } private function render_tabs() { $current_tab = $this->current_tab(); $tabs = array( 'list' => __('Knowledge Items', 'answerrails'), 'add' => __('Add New', 'answerrails'), 'settings' => __('Settings', 'answerrails') ); echo ''; } /** * Render list view */ private function render_list_view() { ?>
list_table->display(); ?>


'knowledge', 'section' => 'grounding_depth', )); ?>
notices[] = array( 'message' => $message, 'type' => $type ); } /** * Display admin notices */ public function display_admin_notices() { // #245 D4: the page-load pre-flight runs here because this is the // one place a Knowledge-page notice can still reach the screen — // add_notice() during admin_init only survives via the transient a // form-POST redirect sets, and a page LOAD has no redirect. $this->preflight_temp_dir(); // Get notices from transient (set after redirect) $notices = get_transient('answerrails_knowledge_notices'); if ($notices) { delete_transient('answerrails_knowledge_notices'); foreach ($notices as $notice) { ?>

notices as $notice) { ?>

notices = array(); } /** * Warn when the sync scratch directory exists but PHP cannot write to * it (#245 D4). * * The dogfood failure this closes: the directory had been created by a * different OS user, every document sync failed at create_temp_file(), * and the ONLY evidence was the server error log. A non-existent * directory is NOT a problem — wp_mkdir_p() creates it at sync time, as * it always has. */ private function preflight_temp_dir() { if (!$this->is_knowledge_screen()) { return; } // One producer for the path — the same call create_temp_file() // makes, so this can never check a different directory. $temp_dir = AnswerRails_Knowledge_Service::temp_dir(); if (!is_dir($temp_dir) || wp_is_writable($temp_dir)) { return; } $this->add_notice( sprintf( /* translators: %s: absolute filesystem path */ __('AnswerRails cannot write to %s — knowledge sync will fail until the web server can write there.', 'answerrails'), $temp_dir ), 'error' ); } /** * Is the current admin screen the Knowledge Base page? * * @return bool */ private function is_knowledge_screen() { if (!function_exists('get_current_screen')) { return false; } $screen = get_current_screen(); return $screen && false !== strpos((string) $screen->id, 'answerrails-knowledge'); } /** * Enqueue admin scripts and styles */ public function enqueue_assets($hook) { // Only load on our admin page if (strpos($hook, 'answerrails') === false) { return; } wp_enqueue_style( 'answerrails-knowledge-admin', plugin_dir_url(__FILE__) . '../assets/css/knowledge-admin.css', array(), defined('WP_DEBUG') && WP_DEBUG ? time() : ANSWERRAILS_VERSION ); // ADD: Knowledge viewer styles wp_enqueue_style( 'answerrails-knowledge-viewer', plugin_dir_url(__FILE__) . '../assets/css/knowledge-viewer.css', array(), defined('WP_DEBUG') && WP_DEBUG ? time() : ANSWERRAILS_VERSION ); wp_enqueue_script( 'answerrails-knowledge-admin', plugin_dir_url(__FILE__) . '../assets/js/knowledge-admin.js', array('jquery'), defined('WP_DEBUG') && WP_DEBUG ? time() : ANSWERRAILS_VERSION, true ); // Viewer JavaScript - separate concerns wp_enqueue_script( 'answerrails-knowledge-viewer', plugin_dir_url(__FILE__) . '../assets/js/knowledge-viewer.js', array('jquery'), // No dependency on admin script ANSWERRAILS_VERSION, true ); // Updated localization with both AJAX and REST support $localization_data = array( 'ajaxUrl' => admin_url('admin-ajax.php'), 'restUrl' => rest_url('answerrails/v1/'), // ADD THIS - REST API base URL 'restNonce' => wp_create_nonce('wp_rest'), // ADD THIS - WordPress REST nonce 'nonce' => wp_create_nonce('answerrails_knowledge'), // Keep original for AJAX calls that still use it // #174 S2 (E1): the registered source types. The picker offers // registered types only (server-rendered) and the JS keeps a // defensive mirror. 'sourceTypes' => AnswerRails_Knowledge_Service::registered_source_types(), 'strings' => array( 'confirmDelete' => __('Are you sure you want to delete this item?', 'answerrails'), 'confirmBulkDelete' => __('Are you sure you want to delete the selected items?', 'answerrails'), 'confirmSync' => __('This will sync the item with OpenAI. Continue?', 'answerrails'), 'confirmBulkSync' => __('This will sync the selected knowledge items. Continue?', 'answerrails'), 'syncSuccess' => __('Knowledge item synced successfully', 'answerrails'), 'syncError' => __('Error syncing knowledge item', 'answerrails'), 'deleteSuccess' => __('Knowledge item deleted successfully', 'answerrails'), 'deleteError' => __('Error deleting knowledge item', 'answerrails'), 'viewError' => __('Failed to load file content', 'answerrails'), 'copySuccess' => __('Content copied to clipboard!', 'answerrails'), 'copyError' => __('Failed to copy content', 'answerrails'), ) ); wp_localize_script('answerrails-knowledge-admin', 'answerrailsKnowledge', $localization_data); } }