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 '
' . 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 '