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() {
?>
0
·
·
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);
}
}