service = $service ?: new AnswerRails_Knowledge_Service(); add_action('rest_api_init', array($this, 'register_routes')); } /** * Register REST API routes */ public function register_routes() { // ⚠ This controller serves ONLY the routes knowledge-admin.js / // knowledge-viewer.js actually call — GET list, POST {id}/sync, // POST bulk-sync, GET {id}/view. Admin CRUD goes through // admin-ajax; a route here with no caller is dead weight (#81). // Collection: list — the Sync-all id source for the shared bulk handler // (#248 D1). The JS list renderer that once read it is deleted. register_rest_route(self::NAMESPACE, '/' . self::BASE_ROUTE, array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => array($this, 'get_items'), 'permission_callback' => array($this, 'check_admin_permission'), 'args' => $this->get_collection_params() ) )); // Action endpoints register_rest_route(self::NAMESPACE, '/' . self::BASE_ROUTE . '/(?P[\d]+)/sync', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array($this, 'sync_item'), 'permission_callback' => array($this, 'check_admin_permission'), 'args' => array( 'id' => array( 'description' => 'Unique identifier for the knowledge item', 'type' => 'integer', 'required' => true, 'sanitize_callback' => 'absint' ) ) )); register_rest_route(self::NAMESPACE, '/' . self::BASE_ROUTE . '/bulk-sync', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => array($this, 'bulk_sync'), 'permission_callback' => array($this, 'check_admin_permission'), 'args' => array( 'ids' => array( 'description' => 'Array of knowledge item IDs to sync', 'type' => 'array', 'required' => true, 'items' => array( 'type' => 'integer' ) ) ) )); // Route for viewing file content (knowledge-viewer.js) register_rest_route(self::NAMESPACE, '/' . self::BASE_ROUTE . '/(?P\d+)/view', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array($this, 'view_file_content'), 'permission_callback' => array($this, 'check_admin_permission'), 'args' => array( 'id' => array( 'required' => true, 'type' => 'integer', 'sanitize_callback' => 'absint', 'validate_callback' => function($param) { return is_numeric($param); } ), 'preview' => array( 'default' => true, 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean' ) ) )); } /** * Get all knowledge items * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object */ public function get_items($request) { try { // Build filters from request parameters $filters = $this->build_filters($request); // Get items from service $items = $this->service->get_all_knowledge_items($filters); $count_filters = $this->filters_for_count($filters); $total_items = $this->service->count_knowledge_items($count_filters); $per_page = isset($filters['per_page']) ? (int) $filters['per_page'] : count($items); $page = isset($filters['page']) ? (int) $filters['page'] : 1; $truncated = false; if ($per_page > 0) { $displayed = $page * $per_page; $truncated = $total_items > $displayed; } $response_data = array( 'items' => $items, 'total' => (int) $total_items, 'page' => max(1, $page), 'per_page' => $per_page, 'truncated' => $truncated, ); return new WP_REST_Response($response_data, 200); } catch (Exception $e) { return $this->error_response('fetch_failed', $e->getMessage(), 500); } } /** * Sync knowledge item with OpenAI * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object */ public function sync_item($request) { try { $id = $request->get_param('id'); // Sync via service $result = $this->service->sync_item($id); if (is_wp_error($result)) { return $this->error_response( $result->get_error_code(), $result->get_error_message(), 400 ); } // #245 D4: the body is the repository-hydrated item — stored // last_synced (UTC), openai_file_id, checksum, source_error and // sync_error. The client renders the row FROM THIS, never from // its own clock; keep the hydrated shape whole. return new WP_REST_Response($result, 200); } catch (Exception $e) { return $this->error_response('sync_failed', $e->getMessage(), 500); } } /** * Bulk sync multiple items * * @param WP_REST_Request $request Request object * @return WP_REST_Response Response object */ public function bulk_sync($request) { try { $ids = $request->get_param('ids'); if (empty($ids) || !is_array($ids)) { return $this->error_response('invalid_ids', 'IDs must be a non-empty array', 400); } // Sanitize IDs $ids = array_map('absint', $ids); // Bulk sync via service $results = $this->service->bulk_sync($ids); // #245 D4: a per-item outcome is not a request failure, so the // status stays 200 — but the body now CARRIES the outcome. The // bare map this route used to return could not distinguish // "all synced" from "every item failed", and the only consumer // read it as success either way. summary.synced + summary.failed // always equals summary.total. // // #248 D2: each entry also carries `item` — the // repository-hydrated record, the SAME shape sync_item() // returns — so the client can repaint the affected rows in // place. Reloading past a failure is what hid nine of them, and // the failure path therefore has no reload to repaint through. // A row that vanished between the sync and the read is null, // not an omitted key: the client checks before it paints. $repository = $this->service->get_repository(); $failed = 0; foreach ($results as $id => $result) { if (empty($result['success'])) { $failed++; } $item = $repository ? $repository->get((int) $id) : null; $results[$id]['item'] = is_array($item) ? $item : null; } return new WP_REST_Response(array( 'results' => $results, 'summary' => array( 'total' => count($results), 'synced' => count($results) - $failed, 'failed' => $failed, ), ), 200); } catch (Exception $e) { return $this->error_response('bulk_sync_failed', $e->getMessage(), 500); } } /** * Check admin permission — the single bar for every route in this * controller (#172): it is an admin surface, same data and same screens * as the AJAX twins, which already require `manage_options`. * * @param WP_REST_Request $request Request object * @return bool True if permitted */ public function check_admin_permission($request) { return current_user_can('manage_options'); } /** * Get collection parameters * * @return array Parameter definitions */ private function get_collection_params() { return array( // #174: the enum DERIVES from the E1 registry // (answerrails_knowledge_source_types) rather than restating // it. The hard-coded five-entry list this replaces was the // last static membership list beside the registry authority — // stale the moment anything registered a type. // // Registry membership ALONE, not membership ∪ handler keys: // the union was written first and its negative round would // not detonate (dropping 'url' from the E1 registration left // the schema unchanged, because the handler entry covered for // it). A second path into the schema is a second membership // list wearing a different hat — rule 2 names one // authority, and this is it. 'type' => array( 'description' => 'Filter by knowledge type', 'type' => 'string', 'enum' => AnswerRails_Knowledge_Service::registered_source_types(), 'sanitize_callback' => 'sanitize_text_field' ), 'is_linkable' => array( 'description' => 'Filter by linkable status', 'type' => 'boolean', 'sanitize_callback' => 'rest_sanitize_boolean' ), 'needs_sync' => array( 'description' => 'Filter to items needing sync', 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean' ), 'search' => array( 'description' => 'Search in title and content', 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ), 'per_page' => array( 'description' => 'Maximum number of items to return', 'type' => 'integer', 'default' => 100, 'minimum' => 1, 'maximum' => 500, 'sanitize_callback' => 'absint' ), 'page' => array( 'description' => 'Current page of results', 'type' => 'integer', 'default' => 1, 'minimum' => 1, 'sanitize_callback' => 'absint' ), 'orderby' => array( 'description' => 'Sort results by field', 'type' => 'string', 'default' => 'date', 'enum' => array('date', 'title', 'modified', 'id'), 'sanitize_callback' => 'sanitize_text_field' ), 'order' => array( 'description' => 'Sort direction', 'type' => 'string', 'default' => 'DESC', 'enum' => array('ASC', 'DESC'), 'sanitize_callback' => 'sanitize_text_field' ) ); } /** * Build filters from request * * @param WP_REST_Request $request Request object * @return array Filter array */ private function build_filters($request) { $filters = array(); // Type filter if ($request->has_param('type')) { $filters['type'] = $request->get_param('type'); } // Linkable filter if ($request->has_param('is_linkable')) { $filters['is_linkable'] = $request->get_param('is_linkable'); } // Needs sync filter if ($request->get_param('needs_sync')) { $filters['needs_sync'] = true; } // Search if ($request->has_param('search')) { $filters['search'] = sanitize_text_field($request->get_param('search')); } $per_page = $request->get_param('per_page'); $per_page = is_numeric($per_page) ? (int) $per_page : null; if (null !== $per_page) { $filters['per_page'] = $per_page; } $page = $request->get_param('page'); $page = is_numeric($page) ? (int) $page : null; if (null !== $page) { $filters['page'] = $page; } // Ordering $orderby = $request->get_param('orderby'); if (!empty($orderby)) { $filters['orderby'] = sanitize_key($orderby); } $order = $request->get_param('order'); if (!empty($order)) { $filters['order'] = sanitize_text_field($order); } return $filters; } private function filters_for_count(array $filters) { $count_filters = $filters; unset($count_filters['page'], $count_filters['per_page']); return $count_filters; } /** * Create error response * * @param string $code Error code * @param string $message Error message * @param int $status HTTP status code * @return WP_REST_Response Error response */ private function error_response($code, $message, $status = 400) { $error_data = array( 'code' => $code, 'message' => $message ); return new WP_REST_Response($error_data, $status); } /** * View file content from knowledge item * * @param WP_REST_Request $request * @return WP_REST_Response|WP_Error */ public function view_file_content($request) { $knowledge_id = $request->get_param('id'); $preview_only = $request->get_param('preview'); // Delegate to service layer $content_data = $this->service->get_file_content($knowledge_id, $preview_only); if (is_wp_error($content_data)) { return $this->error_response( $content_data->get_error_code(), $content_data->get_error_message(), $content_data->get_error_data('status') ?: 400 ); } return new WP_REST_Response($content_data, 200); } }