knowledge = $knowledge_instance; parent::__construct(array( 'singular' => 'knowledge_item', 'plural' => 'knowledge_items', 'ajax' => false, )); } /** * Get list of columns */ public function get_columns() { return array( 'cb' => '', 'title' => __('Title', 'answerrails'), 'type' => __('Type', 'answerrails'), 'status' => __('Status', 'answerrails'), 'last_synced' => __('Last Synced', 'answerrails'), 'size' => __('Size', 'answerrails'), 'actions' => __('Actions', 'answerrails'), ); } /** * Get sortable columns */ public function get_sortable_columns() { return array( 'title' => array('title', false), 'type' => array('type', false), 'last_synced' => array('last_synced', true), // Default sort 'size' => array('size', false), ); } /** * Prepare items for display */ public function prepare_items() { $per_page = 20; $current_page = $this->get_pagenum(); // Handle sorting $orderby = sanitize_text_field(wp_unslash($_REQUEST['orderby'] ?? 'last_synced')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change $order = sanitize_text_field(wp_unslash($_REQUEST['order'] ?? 'desc')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change // Handle filtering $type_filter = sanitize_text_field(wp_unslash($_REQUEST['type_filter'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change // Handle search $search_term = isset($_REQUEST['s']) ? sanitize_text_field(wp_unslash($_REQUEST['s'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change // Get items $args = array( 'post_type' => 'answerrails_kb', 'post_status' => 'publish', 'posts_per_page' => $per_page, 'offset' => ($current_page - 1) * $per_page, 'orderby' => $this->get_orderby_field($orderby), 'order' => strtoupper($order), ); // Add search if specified if (!empty($search_term)) { $args['s'] = $search_term; } // Add type filter if specified if (!empty($type_filter)) { $args['meta_query'] = array( array( 'key' => '_answerrails_knowledge_type', 'value' => $type_filter, 'compare' => '=', ), ); } $posts = get_posts($args); // Get total count for pagination $total_args = $args; $total_args['posts_per_page'] = -1; $total_args['fields'] = 'ids'; unset($total_args['offset']); $total_items = count(get_posts($total_args)); // Set pagination $this->set_pagination_args(array( 'total_items' => $total_items, 'per_page' => $per_page, 'total_pages' => ceil($total_items / $per_page), )); // Format items for display $this->items = array(); foreach ($posts as $post) { $this->items[] = $this->format_item($post); } // Set column headers $this->_column_headers = array( $this->get_columns(), array(), // Hidden columns $this->get_sortable_columns(), ); } /** * Get orderby field for WordPress query */ private function get_orderby_field($orderby) { switch ($orderby) { case 'title': return 'title'; case 'type': return 'meta_value'; case 'last_synced': return 'meta_value'; case 'size': return 'meta_value_num'; default: return 'date'; } } /** * Format post for display */ private function format_item($post) { $type = get_post_meta($post->ID, '_answerrails_knowledge_type', true); $last_synced = get_post_meta($post->ID, '_answerrails_last_synced', true); $auto_sync = get_post_meta($post->ID, '_answerrails_auto_sync', true); $file_size = get_post_meta($post->ID, '_answerrails_file_size', true); $openai_file_id = get_post_meta($post->ID, '_answerrails_openai_file_id', true); $checksum = get_post_meta($post->ID, '_answerrails_checksum', true); $source_error = get_post_meta($post->ID, '_answerrails_source_error', true); $sync_error = get_post_meta($post->ID, '_answerrails_sync_error', true); return array( 'ID' => $post->ID, 'title' => $post->post_title, 'type' => $type, // #365 F9: site content of every post type is stored as type // `post`; column_type() labels it from this source's post type. 'source_post_id' => (int) get_post_meta($post->ID, '_answerrails_source_post_id', true), // #142 cross-reference declaration (visibility amendment, // 2026-08-06): the list must SHOW advanced structured-data use // and which section(s) feed it. 'lookup_table' => (bool) get_post_meta($post->ID, '_answerrails_lookup_table', true), 'lookup_path' => (string) get_post_meta($post->ID, '_answerrails_lookup_path', true), 'content' => $post->post_content, 'last_synced' => !empty($last_synced) ? $last_synced : null, 'auto_sync' => (bool) $auto_sync, 'file_size' => (int) $file_size, 'openai_synced' => !empty($openai_file_id), // #92: checksum is written ONLY by sync_item(), so a CURRENT // checksum with no OpenAI file id means a completed local-only // sync ("Synced (local)") — no chunk-table query needed. 'local_synced' => '' !== (string) $checksum && md5((string) $post->post_content) === (string) $checksum, // #138 D3: source-availability record (array) or '' when absent. 'source_error' => is_array($source_error) ? $source_error : array(), // #245 D4: last failed sync attempt (array) or '' when absent. 'sync_error' => is_array($sync_error) ? $sync_error : array(), 'post_date' => $post->post_date, ); } /** * Default column display */ public function column_default($item, $column_name) { switch ($column_name) { case 'size': return $this->format_file_size($item['file_size']); default: return $item[$column_name] ?? ''; } } /** * Checkbox column */ public function column_cb($item) { return sprintf( '', $item['ID'] ); } /** * Title column with row actions */ public function column_title($item) { $title = '' . esc_html($item['title']) . ''; // Build row actions $actions = array( 'view' => sprintf( '%s', $item['ID'], __('View', 'answerrails') ), 'sync' => sprintf( '%s', $item['ID'], __('Sync', 'answerrails') ), 'remove' => sprintf( '%s', $item['ID'], __('Remove', 'answerrails') ), ); // #138 D3: during the grace lag the owner can skip it — a // deliberate source removal should not keep serving for a day. $source_error = $item['source_error'] ?? array(); if (!empty($source_error) && is_array($source_error) && empty($source_error['withdrawn'])) { $actions['withdraw'] = sprintf( '%s', $item['ID'], __('Withdraw now', 'answerrails') ); } // #174: frozen rows (unregistered types) lose the Sync action // and the sync-mode toggle; the status column carries the frozen // state. View/Withdraw/Remove stay — the exits are always open. if ($this->is_sync_frozen($item)) { unset($actions['sync']); } // #73 D6: live sync-mode toggle — source-tracking types only, and // only while HEALTHY (pinning content whose source is known gone is // not a pin; the amber row offers Withdraw now, the red row // recovers automatically). Reversible, so no confirm(). if (!$this->is_sync_frozen($item) && AnswerRails_Knowledge_Service::type_tracks_source($item['type']) && empty($source_error)) { $target = $item['auto_sync'] ? 'manual' : 'auto'; $actions['sync_mode'] = sprintf( '%s', $item['ID'], esc_attr($target), 'manual' === $target ? esc_attr__('Pin the current copy; refresh only via the Sync button', 'answerrails') : esc_attr__('Track the source and refresh automatically', 'answerrails'), 'manual' === $target ? esc_html__('Switch to Manual', 'answerrails') : esc_html__('Switch to Auto', 'answerrails') ); } return $title . $this->row_actions($actions); } /** * Type column with icon */ public function column_type($item) { $type = $item['type']; $icons = array( 'document' => 'media-document', 'json' => 'editor-code', 'url' => 'admin-links', 'post' => 'admin-post', 'product' => 'cart', ); $labels = array( 'document' => __('Document', 'answerrails'), 'json' => __('JSON', 'answerrails'), 'url' => __('URL', 'answerrails'), 'post' => __('Post', 'answerrails'), 'product' => __('Product', 'answerrails'), ); $icon = $icons[$type] ?? 'admin-generic'; $label = $labels[$type] ?? ucfirst($type); if ('post' === $type) { $label = $this->source_type_label((int) ($item['source_post_id'] ?? 0), $label); } $cell = sprintf( ' %s', esc_attr($icon), esc_attr($label), esc_html($label) ); // #142 visibility amendment (2026-08-06): declared cross-reference // tables are badged, naming the section(s) in use. // #248 D2: the JS row renderer that carried the same badge is // deleted — this chip has no twin, and the list table is the one // producer of a knowledge row. if (!empty($item['lookup_table'])) { $sections = trim((string) ($item['lookup_path'] ?? '')); $badge = '' === $sections ? __('Cross-reference table', 'answerrails') /* translators: %s: comma-separated JSON section names */ : sprintf(__('Cross-reference: %s', 'answerrails'), $sections); $cell .= sprintf( '
%s', esc_attr__('The assistant follows these term mappings with a follow-up search before answering.', 'answerrails'), esc_html($badge) ); } return $cell; } /** * #365 F9 (D4): the singular label of a `post` item's source post type, * so a Page reads "Page". Falls back to $fallback ("Post") when the * source is missing or its post type is not registered. */ private function source_type_label($source_id, $fallback) { if ($source_id <= 0) { return $fallback; } $post_type = get_post_type($source_id); if (!$post_type) { return $fallback; } $object = get_post_type_object($post_type); if (!$object || empty($object->labels->singular_name)) { return $fallback; } return (string) $object->labels->singular_name; } /** * Human phrase for a #138 source-error code. */ private function source_error_reason($code) { switch ($code) { case 'post_missing': return __('Source post no longer exists', 'answerrails'); case 'post_not_public': return __('Source post is no longer published', 'answerrails'); case 'fetch_failed': return __('Source URL could not be fetched', 'answerrails'); default: return __('Source unavailable', 'answerrails'); } } /** * Whether this item is sync-frozen (#174): its type is not in the * E1 registry (pro absent/lapsed, or a third-party registrar * deactivated) — data preserved, honestly presented. Presentation-only * read — the server-side authority is * AnswerRails_Knowledge_Service::source_type_registered(), which * refuses the sync/toggle behind these renderings. */ private function is_sync_frozen($item) { return !in_array( $item['type'] ?? '', AnswerRails_Knowledge_Service::registered_source_types(), true ); } /** * Status column with sync status */ public function column_status($item) { // #138 D6: a source-availability problem outranks every other // state — the whole defect class was a UI asserting freshness // that did not exist. $source_error = $item['source_error'] ?? array(); if (!empty($source_error) && is_array($source_error)) { $reason = $this->source_error_reason($source_error['code'] ?? ''); if (!empty($source_error['withdrawn'])) { $tooltip = sprintf( /* translators: 1: reason, 2: UTC timestamp */ __('%1$s — withdrawn from answers %2$s UTC. Restores automatically if the source returns.', 'answerrails'), $reason, (string) ($source_error['withdrawn_time'] ?? '') ); return '' . esc_html__('Withdrawn (source missing)', 'answerrails') . ''; } $tooltip = sprintf( /* translators: 1: reason, 2: UTC timestamp */ __('%1$s since %2$s UTC — still answering from the last good copy; withdrawn from answers if still unavailable after the grace period.', 'answerrails'), $reason, (string) ($source_error['first_failed'] ?? '') ); return '' . esc_html__('Source unavailable', 'answerrails') . ''; } // #174: sync-frozen — a distinct state from #138's amber (the // source may be fine; no registered module tracks this type any // more). It outranks everything below it, which is what "checked // FIRST" always claimed and, until #248 D3, was not: the rung sat // under `sync_error` AND under the `openai_synced` branch, so a // frozen row that had ever failed a real sync read "Sync failed", // and a frozen row that had never reached OpenAI read "Not Synced". // Neither is clearable — `column_title()` removes the Sync action // from a frozen row — and both are vaguer than the true state. Only // #138's source_error, the more specific diagnosis of the same row, // outranks it. The item keeps serving as last synced; // withdraw/delete stay open. if ($this->is_sync_frozen($item)) { return '' . esc_html__('Sync frozen', 'answerrails') . ''; } // #245 D4: a recorded sync failure outranks every state below it — // "Not Synced" used to mean both "never tried" and "tried, failed" // (the whole point of the defect: the operator could not tell). It // sits UNDER #138's source_error and #174's freeze, both of which // are more specific diagnoses of the same row. $sync_error = $item['sync_error'] ?? array(); if (!empty($sync_error) && is_array($sync_error)) { $tooltip = sprintf( /* translators: 1: error message, 2: UTC timestamp */ __('Last sync attempt failed: %1$s (%2$s UTC)', 'answerrails'), (string) ($sync_error['message'] ?? ''), (string) ($sync_error['at'] ?? '') ); return '' . esc_html__('Sync failed', 'answerrails') . ''; } if (!$item['openai_synced']) { // #92 local mode: a current checksum without a file id is a // COMPLETED local sync — indexed for keyword retrieval in the // site's own database. Neutral state, not an error. if (!empty($item['local_synced'])) { return '' . __('Synced (local)', 'answerrails') . ''; } return '' . __('Not Synced', 'answerrails') . ''; } // Non-tracking types (documents and JSON store the source itself) // are always manual since they can't detect changes. if (!AnswerRails_Knowledge_Service::type_tracks_source($item['type'])) { return '' . __('Active (Manual)', 'answerrails') . ''; } // For auto-syncable types, check if auto-sync is enabled if ($item['auto_sync']) { return '' . __('Active (Auto)', 'answerrails') . ''; } else { return '' . __('Active (Manual)', 'answerrails') . ''; } } /** * Last synced column */ public function column_last_synced($item) { if (empty($item['last_synced']) || $item['last_synced'] === null) { return '' . __('Never', 'answerrails') . ''; } // Stamps are stored in UTC (#88.4; migration 1.3.0 converted legacy // site-local values) — parse explicitly as UTC so the relative time // can never go negative on non-UTC sites. $timestamp = strtotime($item['last_synced'] . ' UTC'); $time_diff = time() - $timestamp; if ($time_diff < HOUR_IN_SECONDS) { /* translators: %d: number of minutes */ $time_ago = sprintf(__('%d minutes ago', 'answerrails'), round($time_diff / MINUTE_IN_SECONDS)); } elseif ($time_diff < DAY_IN_SECONDS) { /* translators: %d: number of hours */ $time_ago = sprintf(__('%d hours ago', 'answerrails'), round($time_diff / HOUR_IN_SECONDS)); } else { /* translators: %d: number of days */ $time_ago = sprintf(__('%d days ago', 'answerrails'), round($time_diff / DAY_IN_SECONDS)); } return sprintf( '%s', esc_attr(wp_date(get_option('date_format') . ' ' . get_option('time_format'), $timestamp)), esc_html($time_ago) ); } /** * Actions column */ public function column_actions($item) { $actions = array(); // Sync button $actions[] = sprintf( '', $item['ID'], __('Sync with OpenAI', 'answerrails') ); // View content button - Same for all types $actions[] = sprintf( '', $item['ID'], esc_attr($item['type']), __('View Content', 'answerrails') ); // Remove button $actions[] = sprintf( '', $item['ID'], __('Remove', 'answerrails') ); return implode(' ', $actions); } /** * Format file size */ private function format_file_size($bytes) { if ($bytes == 0) { return '0 B'; } $units = array('B', 'KB', 'MB', 'GB'); $unitIndex = 0; while ($bytes >= 1024 && $unitIndex < count($units) - 1) { $bytes /= 1024; $unitIndex++; } return round($bytes, 1) . ' ' . $units[$unitIndex]; } /** * Display when no items found */ public function no_items() { echo '
'; echo '
'; echo ''; echo '
'; echo '

' . esc_html__('No knowledge items found', 'answerrails') . '

'; echo '

' . esc_html__('Start building your knowledge base by adding documents, URLs, structured data (JSON), or WordPress posts.', 'answerrails') . '

'; echo '' . esc_html__('Add Your First Knowledge Item', 'answerrails') . ''; echo '
'; } /** * Extra tablenav (filters and bulk actions) */ protected function extra_tablenav($which) { if ($which !== 'top') { return; } $current_type = sanitize_text_field(wp_unslash($_REQUEST['type_filter'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change ?>
'knowledge-filter-submit')); ?>
views(); echo '
'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change echo ''; $this->search_box(__('Search Knowledge Items', 'answerrails'), 'knowledge'); parent::display(); echo '
'; } /** * Get views (filter links) */ protected function get_views() { global $wpdb; $views = array(); $current_type = sanitize_text_field(wp_unslash($_REQUEST['type_filter'] ?? '')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display parameter; no state change $base_url = admin_url('admin.php?page=answerrails-knowledge'); // All items $total_count = $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = 'answerrails_kb' AND post_status = 'publish'" ); $class = empty($current_type) ? ' class="current"' : ''; $views['all'] = sprintf( '%s (%d)', $base_url, $class, __('All', 'answerrails'), $total_count ); // Type-specific counts $type_counts = $wpdb->get_results( "SELECT pm.meta_value as type, COUNT(*) as count FROM {$wpdb->posts} p JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id WHERE p.post_type = 'answerrails_kb' AND p.post_status = 'publish' AND pm.meta_key = '_answerrails_knowledge_type' GROUP BY pm.meta_value" ); $type_labels = array( 'document' => __('Documents', 'answerrails'), 'json' => __('JSON', 'answerrails'), 'url' => __('URLs', 'answerrails'), 'post' => __('Posts', 'answerrails'), ); foreach ($type_counts as $type_count) { $type = $type_count->type; $count = $type_count->count; $label = $type_labels[$type] ?? ucfirst($type); $class = ($current_type === $type) ? ' class="current"' : ''; $views[$type] = sprintf( '%s (%d)', add_query_arg('type_filter', $type, $base_url), $class, $label, $count ); } return $views; } }