'kb_entry',
'plural' => 'kb_entries',
'ajax' => false,
)
);
$this->rag_engine = new AIOHM_KB_RAG_Engine();
// Initialize allowed tables for security validation
global $wpdb;
$this->allowed_tables = array(
$wpdb->prefix . 'aiohm_vector_entries',
$wpdb->prefix . 'aiohm_conversations',
$wpdb->prefix . 'aiohm_messages',
$wpdb->prefix . 'aiohm_projects',
);
}
/**
* Validate table name against allowed tables to prevent SQL injection
*
* @param string $table_name Table name to validate
* @return bool True if valid, false otherwise
*/
private function validate_table_name( $table_name ) {
return in_array( $table_name, $this->allowed_tables, true );
}
/**
* Safely get table reference with validation
*
* @param string $table_name Table name to validate
* @return string Validated table name
* @throws Exception If table name is invalid
*/
private function get_safe_table_name( $table_name ) {
if ( ! $this->validate_table_name( $table_name ) ) {
throw new Exception( 'Invalid table name for security reasons.' );
}
return $table_name;
}
function get_columns() {
return array(
'cb' => '',
'title' => __( 'Title', 'aiohm-knowledge-assistant' ),
'content_type' => __( 'Content Type', 'aiohm-knowledge-assistant' ),
'visibility' => __( 'Visibility', 'aiohm-knowledge-assistant' ),
'last_updated' => __( 'Last Updated', 'aiohm-knowledge-assistant' ),
'scope_toggle' => __( 'Actions', 'aiohm-knowledge-assistant' ),
);
}
/**
* Defines which columns are sortable.
*
* @return array
*/
protected function get_sortable_columns() {
$sortable_columns = array(
'title' => array( 'title', false ),
'content_type' => array( 'content_type', false ),
'visibility' => array( 'is_public', false ),
'last_updated' => array( 'created_at', true ),
'scope_toggle' => array( 'is_public', false ),
);
return $sortable_columns;
}
function column_cb( $item ) {
return sprintf( '', esc_attr( $item['content_id'] ) );
}
function column_scope_toggle( $item ) {
$action_links_html = array();
$current_user_id = get_current_user_id();
$is_public = isset( $item['is_public'] ) ? intval( $item['is_public'] ) : 0;
$is_mine = ( $item['user_id'] == $current_user_id );
// Toggle Scope link - admins can toggle any content between public/private
$new_scope = $is_public === 1 ? 'private' : 'public';
$button_text = $is_public === 1 ? 'Make Private' : 'Make Public';
$button_class = $is_public === 1 ? 'make-private' : 'make-public';
$action_links_html[] = sprintf(
'%s',
$button_class,
esc_attr( $item['content_id'] ),
$new_scope,
$button_text
);
// View Button - Enhanced to handle Brand Soul content type
$metadata = isset( $item['metadata'] ) ? json_decode( $item['metadata'], true ) : null;
$content_type = $item['content_type'] ?? '';
// Enhanced View button logic for different content types
// Content types that need modal view (All file types and special content, excluding PDFs)
$modal_content_types = array(
'brand-soul',
'brand_soul',
'brand-core',
'brand_core',
'github',
'repository',
'contact',
'contact_type',
'conversation',
'JSON',
'text/plain',
'TXT',
'text/csv',
'CSV',
'text/markdown',
'MD',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/msword',
'DOC',
'DOCX',
'manual',
'project_note',
// Add all new file types for universal modal viewing (excluding PDFs - they have special handling)
'HTML',
'HTM',
'text/html',
'XLSX',
'XLS',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
);
// Content types that can be edited (expanded to include more file types)
$editable_content_types = array(
'JSON',
'TXT',
'text/plain',
'MD',
'text/markdown',
'project_note',
// Add editable support for common document types
'PDF',
'application/pdf',
'HTML',
'HTM',
'text/html',
'DOC',
'DOCX',
'CSV',
'text/csv',
'XLSX',
'XLS',
);
// Special handling for PDFs - always use modal view
if ( $content_type === 'PDF' || $content_type === 'application/pdf' ) {
$action_links_html[] = sprintf(
'%s',
esc_attr( $item['content_id'] ),
__( 'View', 'aiohm-knowledge-assistant' )
);
} elseif ( in_array( $content_type, $modal_content_types ) ) {
$action_links_html[] = sprintf(
'%s',
esc_attr( $item['content_id'] ),
esc_attr( $content_type ),
__( 'View', 'aiohm-knowledge-assistant' )
);
}
// Links/URLs - always show View button
elseif ( is_array( $metadata ) && isset( $metadata['url'] ) ) {
$action_links_html[] = sprintf( '%s', esc_url( $metadata['url'] ), __( 'View', 'aiohm-knowledge-assistant' ) );
}
// Other content with metadata (posts, pages, attachments)
elseif ( is_array( $metadata ) ) {
$view_url = '';
$view_text = __( 'View', 'aiohm-knowledge-assistant' );
if ( isset( $metadata['post_id'] ) && get_post_type( $metadata['post_id'] ) ) {
$view_url = get_permalink( $metadata['post_id'] );
$action_links_html[] = sprintf( '%s', esc_url( $view_url ), $view_text );
} elseif ( isset( $metadata['attachment_id'] ) ) {
// For PDF files, show content preview instead of opening file
if ( $content_type === 'PDF' ) {
$action_links_html[] = sprintf(
'%s',
esc_attr( $item['content_id'] ),
esc_attr( $metadata['attachment_id'] ),
__( 'View Content', 'aiohm-knowledge-assistant' )
);
} else {
// For other attachments, keep the original behavior
$view_url = wp_get_attachment_url( $metadata['attachment_id'] );
if ( $view_url ) {
$action_links_html[] = sprintf( '%s', esc_url( $view_url ), $view_text );
}
}
}
}
// Fallback: if no metadata but content exists, show modal view
elseif ( ! empty( $item['content'] ) ) {
$action_links_html[] = sprintf(
'%s',
esc_attr( $item['content_id'] ),
esc_attr( $content_type ),
__( 'View', 'aiohm-knowledge-assistant' )
);
}
// Delete button
$delete_nonce = wp_create_nonce( 'aiohm_delete_entry_nonce' );
$delete_url = sprintf( '?page=aiohm-manage-kb&action=delete&content_id=%s&_wpnonce=%s', esc_attr( $item['content_id'] ), $delete_nonce );
$action_links_html[] = sprintf( 'Delete', $delete_url );
return implode( ' | ', $action_links_html );
}
function get_bulk_actions() {
return array(
'bulk-delete' => __( 'Delete', 'aiohm-knowledge-assistant' ),
'make-public' => __( 'Make Selected Public', 'aiohm-knowledge-assistant' ),
'make-private' => __( 'Make Selected Private', 'aiohm-knowledge-assistant' ),
);
}
function process_bulk_action() {
$action = $this->current_action();
if ( ! $action ) {
return;
}
// Bulk actions are handled by custom AJAX handlers
// This method is kept for WordPress compatibility
}
function column_default( $item, $column_name ) {
// $column_name will now always be a string (the column slug)
switch ( $column_name ) {
case 'content_type':
$type = esc_html( $item['content_type'] );
$display_type = $type;
$type_class = '';
// Enhance display for common types and assign classes
if ( $type === 'application/pdf' || $type === 'PDF' ) {
$display_type = 'PDF';
$type_class = 'type-pdf';
} elseif ( $type === 'JSON' ) {
$display_type = 'JSON';
$type_class = 'type-json';
} elseif ( $type === 'text/plain' || $type === 'TXT' ) {
$display_type = 'TXT';
$type_class = 'type-txt';
} elseif ( $type === 'text/csv' || $type === 'CSV' ) {
$display_type = 'CSV';
$type_class = 'type-csv';
} elseif ( $type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || $type === 'application/msword' || $type === 'DOC' || $type === 'DOCX' ) {
$display_type = 'DOC';
$type_class = 'type-doc';
} elseif ( $type === 'text/markdown' || $type === 'text/x-markdown' || $type === 'MD' ) {
$display_type = 'MD';
$type_class = 'type-md';
} elseif ( $type === 'text/html' || $type === 'HTML' || $type === 'HTM' ) {
$display_type = 'HTML';
$type_class = 'type-html';
} elseif ( $type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' || $type === 'XLSX' ) {
$display_type = 'XLSX';
$type_class = 'type-xlsx';
} elseif ( $type === 'application/vnd.ms-excel' || $type === 'XLS' ) {
$display_type = 'XLS';
$type_class = 'type-xls';
} elseif ( $type === 'post' ) {
$display_type = 'Post';
$type_class = 'type-post';
} elseif ( $type === 'page' ) {
$display_type = 'Page';
$type_class = 'type-page';
} elseif ( $type === 'manual' ) {
$display_type = 'Manual';
$type_class = 'type-manual';
} elseif ( $type === 'brand_soul' ) {
$display_type = 'Brand Soul';
$type_class = 'type-brand-soul';
} elseif ( $type === 'brand_core' ) {
$display_type = 'Brand Core';
$type_class = 'type-brand-core';
} elseif ( $type === 'github' || $type === 'repository' ) {
$display_type = 'GitHub';
$type_class = 'type-github';
} elseif ( $type === 'contact' || $type === 'contact_type' ) {
$display_type = 'Contact';
$type_class = 'type-contact';
} elseif ( $type === 'project_note' ) {
$display_type = 'Note';
$type_class = 'type-note';
} elseif ( $type === 'conversation' ) {
$display_type = 'CHAT';
$type_class = 'type-chat';
} elseif ( strpos( $type, '/' ) !== false ) {
$main_type = explode( '/', $type )[0];
$type_class = 'type-' . esc_attr( $main_type );
$display_type = strtoupper( $main_type );
} else {
$type_class = 'type-default';
}
return sprintf( '%s', $type_class, $display_type );
case 'visibility':
$is_public = isset( $item['is_public'] ) ? intval( $item['is_public'] ) : 0;
$visibility = $is_public === 1 ? 'Public' : 'Private';
$visibility_class = $is_public === 1 ? 'visibility-public' : 'visibility-private';
return sprintf( '%s', esc_attr( $visibility_class ), $visibility );
case 'last_updated':
return isset( $item['created_at'] ) ? esc_html( gmdate( 'Y-m-d H:i', strtotime( $item['created_at'] ) ) ) : 'N/A';
case 'title': // Explicitly handle 'title' for its bold formatting
return sprintf( '%s', esc_html( $item['title'] ) );
default:
// For any other column not explicitly handled, just return the item's value
return isset( $item[ $column_name ] ) ? esc_html( $item[ $column_name ] ) : '';
}
}
/**
* Renders the custom filters in the table nav area.
*
* @param string $which The part of the table nav to render (top or bottom).
*/
function extra_tablenav( $which ) {
// Render filters at the top, on the same line as bulk actions
if ( $which === 'top' ) {
?>
'post-query-submit' ) ); ?>
_column_headers = array( $this->get_columns(), array(), $this->get_sortable_columns() );
$per_page = 20;
$current_page = $this->get_pagenum();
$where_clauses = array( '1=1' );
$query_args = array();
// Removed search handling as per user request
// if (isset($_GET['s']) && !empty($_GET['s'])) { ... }
// Content type filter - admin interface only, no nonce required for GET filters
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- GET filter parameters safe for admin list filtering
if ( isset( $_GET['content_type'] ) && ! empty( $_GET['content_type'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin filter parameter
$content_type = sanitize_text_field( wp_unslash( $_GET['content_type'] ) );
$where_clauses[] = 'content_type = %s';
$query_args[] = $content_type;
}
// Visibility filter - admin interface only, no nonce required for GET filters
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- GET filter parameters safe for admin list filtering
if ( isset( $_GET['visibility'] ) && ! empty( $_GET['visibility'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin filter parameter
$visibility = sanitize_text_field( wp_unslash( $_GET['visibility'] ) );
if ( $visibility === 'public' ) {
$where_clauses[] = 'is_public = 1';
} elseif ( $visibility === 'private' ) {
$where_clauses[] = 'is_public = 0';
}
}
// Date range filter - admin interface only, no nonce required for GET filters
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- GET filter parameters safe for admin list filtering
if ( isset( $_GET['date_range'] ) && ! empty( $_GET['date_range'] ) ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin filter parameter
$date_range = sanitize_text_field( wp_unslash( $_GET['date_range'] ) );
$current_time = current_time( 'mysql' );
switch ( $date_range ) {
case 'last_7_days':
$where_clauses[] = 'created_at >= DATE_SUB(%s, INTERVAL 7 DAY)';
$query_args[] = $current_time;
break;
case 'last_30_days':
$where_clauses[] = 'created_at >= DATE_SUB(%s, INTERVAL 30 DAY)';
$query_args[] = $current_time;
break;
case 'this_month':
$where_clauses[] = 'YEAR(created_at) = YEAR(%s) AND MONTH(created_at) = MONTH(%s)';
$query_args[] = $current_time;
$query_args[] = $current_time;
break;
case 'this_year':
$where_clauses[] = 'YEAR(created_at) = YEAR(%s)';
$query_args[] = $current_time;
break;
}
}
$where_sql = implode( ' AND ', $where_clauses );
// Determine sorting
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- GET parameters safe for admin list ordering
$orderby = isset( $_GET['orderby'] ) ? sanitize_sql_orderby( wp_unslash( $_GET['orderby'] ) ) : 'id'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin ordering parameter
$order = isset( $_GET['order'] ) ? sanitize_sql_orderby( wp_unslash( $_GET['order'] ) ) : 'DESC'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Admin ordering parameter
// Validate orderby against sortable columns to prevent SQL injection
$sortable_columns = $this->get_sortable_columns();
// Handle special case where WordPress generates is_public instead of scope_toggle
if ( $orderby === 'is_public' ) {
$orderby = 'scope_toggle';
}
if ( ! array_key_exists( $orderby, $sortable_columns ) ) {
$orderby = 'id'; // Fallback to default if invalid orderby is provided
$order = 'DESC';
} else {
$db_column = $sortable_columns[ $orderby ][0]; // Get the actual database column name
$orderby = $db_column;
}
// Get total items count (respecting filters) with caching
$table_name = $wpdb->prefix . 'aiohm_vector_entries';
$total_items_query_args = $query_args;
// Create cache key based on filters
$cache_key = 'aiohm_kb_count_' . md5( $where_sql . serialize( $total_items_query_args ) );
$total_items = wp_cache_get( $cache_key, 'aiohm_kb_manager' );
if ( false === $total_items ) {
// Validate table name for security
$safe_table = $this->get_safe_table_name( $wpdb->prefix . 'aiohm_vector_entries' );
if ( ! empty( $where_clauses ) && ! empty( $total_items_query_args ) ) {
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- This is the correct and safe way to handle dynamic WHERE clauses. The placeholders are generated correctly before being passed to prepare().
$query = "SELECT COUNT(DISTINCT content_id) FROM `$safe_table` WHERE " . implode( ' AND ', $where_clauses );
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Admin count query with proper dynamic preparation and caching
$total_items = (int) $wpdb->get_var( $wpdb->prepare( $query, $total_items_query_args ) );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Simple count query with caching, safe table name validated
$total_items = (int) $wpdb->get_var( "SELECT COUNT(DISTINCT content_id) FROM `$safe_table`" );
}
// Cache for 5 minutes
wp_cache_set( $cache_key, $total_items, 'aiohm_kb_manager', 300 );
}
$this->set_pagination_args(
array(
'total_items' => $total_items,
'per_page' => $per_page,
)
);
$offset = ( $current_page - 1 ) * $per_page;
// Create cache key for items query
$items_cache_key = 'aiohm_kb_items_' . md5( $where_sql . serialize( $query_args ) . $orderby . $order . $per_page . $offset );
$this->items = wp_cache_get( $items_cache_key, 'aiohm_kb_manager' );
if ( false === $this->items ) {
// Validate table name for security
$safe_table = $this->get_safe_table_name( $wpdb->prefix . 'aiohm_vector_entries' );
if ( ! empty( $where_clauses ) && ! empty( $query_args ) ) {
array_push( $query_args, $per_page, $offset );
$query = 'SELECT MIN(id) as id, MIN(title) as title, content_type, user_id, content_id, MAX(created_at) as created_at, MIN(metadata) as metadata, MIN(content) as content, MAX(is_public) as is_public FROM `' . esc_sql( $safe_table ) . '` WHERE ' . implode( ' AND ', $where_clauses ) . ' GROUP BY content_id ORDER BY ' . esc_sql( $orderby ) . ' ' . esc_sql( $order ) . ' LIMIT %d OFFSET %d';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Admin list query with proper dynamic preparation and caching
$this->items = $wpdb->get_results( $wpdb->prepare( $query, $query_args ), ARRAY_A );
} else {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Admin list query with prepared statement and caching, safe table name validated
$this->items = $wpdb->get_results(
$wpdb->prepare(
'SELECT MIN(id) as id, MIN(title) as title, content_type, user_id, content_id, MAX(created_at) as created_at, MIN(metadata) as metadata, MIN(content) as content, MAX(is_public) as is_public FROM `' . esc_sql( $safe_table ) . '` GROUP BY content_id ORDER BY ' . esc_sql( $orderby ) . ' ' . esc_sql( $order ) . ' LIMIT %d OFFSET %d',
$per_page,
$offset
),
ARRAY_A
);
}
// Cache for 2 minutes (shorter for admin interface)
wp_cache_set( $items_cache_key, $this->items, 'aiohm_kb_manager', 120 );
}
}
}
class AIOHM_KB_Manager {
private $rag_engine;
private $list_table;
public function __construct() {
$this->rag_engine = new AIOHM_KB_RAG_Engine();
$this->list_table = new AIOHM_KB_List_Table();
}
public function display_page() {
$this->handle_actions();
$list_table = $this->list_table;
$list_table->prepare_items();
$settings = AIOHM_KB_Assistant::get_settings();
include_once AIOHM_KB_PLUGIN_DIR . 'templates/admin-manage-kb.php';
}
private function handle_actions() {
// Handle custom bulk actions first
if ( isset( $_POST['aiohm_bulk_action'] ) && isset( $_POST['entry_ids'] ) && ! empty( $_POST['entry_ids'] ) ) {
$this->handle_custom_bulk_action();
}
// Process WP_List_Table bulk actions (keeping this for compatibility)
$this->list_table->process_bulk_action();
$current_action = $this->list_table->current_action();
$nonce = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : '';
if ( 'delete' === $current_action && isset( $_GET['content_id'] ) && wp_verify_nonce( $nonce, 'aiohm_delete_entry_nonce' ) ) {
if ( $this->rag_engine->delete_entry_by_content_id( sanitize_text_field( wp_unslash( $_GET['content_id'] ) ) ) ) {
// Clear cache after successful delete
$this->clear_kb_manager_cache();
// Admin notice handled by JS in admin-manage-kb.php for single actions
} else {
// Notifying error in JS
}
}
// Note: Bulk actions are now handled by the WP_List_Table's process_bulk_action() method
}
/**
* Handle custom bulk actions that bypass WP_List_Table
*/
private function handle_custom_bulk_action() {
// Verify nonce
if ( ! isset( $_POST['aiohm_bulk_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['aiohm_bulk_nonce'] ) ), 'aiohm_bulk_action' ) ) {
wp_die( esc_html__( 'Security check failed.', 'aiohm-knowledge-assistant' ) );
}
$action = isset( $_POST['aiohm_bulk_action'] ) ? sanitize_text_field( wp_unslash( $_POST['aiohm_bulk_action'] ) ) : '';
$entry_ids = isset( $_POST['entry_ids'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['entry_ids'] ) ) : array();
try {
switch ( $action ) {
case 'delete':
$deleted_count = 0;
foreach ( $entry_ids as $content_id ) {
if ( $this->rag_engine->delete_entry_by_content_id( $content_id ) ) {
++$deleted_count;
}
}
$this->clear_kb_manager_cache();
break;
case 'make_public':
$updated_count = 0;
foreach ( $entry_ids as $content_id ) {
if ( $this->rag_engine->update_entry_visibility_by_content_id( $content_id, 1 ) ) {
++$updated_count;
}
}
$this->clear_kb_manager_cache();
break;
case 'make_private':
$updated_count = 0;
foreach ( $entry_ids as $content_id ) {
if ( $this->rag_engine->update_entry_visibility_by_content_id( $content_id, 0 ) ) {
++$updated_count;
}
}
$this->clear_kb_manager_cache();
break;
}
} catch ( Exception $e ) {
wp_die( esc_html__( 'An error occurred during bulk action: ', 'aiohm-knowledge-assistant' ) . esc_html( $e->getMessage() ) );
}
}
/**
* Clear all cache related to KB manager queries
*/
private function clear_kb_manager_cache() {
// Clear all cached queries for KB manager
wp_cache_flush_group( 'aiohm_kb_manager' );
}
}