is_configured()) return; add_filter('posts_search', [$this, 'override_search'], 10, 2); add_action('aksrt_meilisearch_sync', [$this, 'sync_all_posts']); add_action('save_post', [$this, 'on_save_post'], 10, 3); add_action('delete_post', [$this, 'on_delete_post']); // Register cron schedule add_filter('cron_schedules', function ($schedules) { $schedules['aksrt_meilisearch_hourly'] = [ 'interval' => HOUR_IN_SECONDS, 'display' => __('每小时', 'aksrt'), ]; return $schedules; }); if (!wp_next_scheduled('aksrt_meilisearch_sync')) { wp_schedule_event(time(), 'aksrt_meilisearch_hourly', 'aksrt_meilisearch_sync'); } } /** * Check if MeiliSearch is configured */ public function is_configured(): bool { if (!class_exists('AKSRT_Settings')) return false; $host = AKSRT_Settings::get_value('meilisearch_host', ''); $key = AKSRT_Settings::get_value('meilisearch_api_key', ''); return !empty($host) && !empty($key); } /** * Resolve which post types are indexed/searched. Shares the single * data-selection contract with front search and search push. * * @return string[] */ private function indexable_post_types(): array { if (function_exists('aksrt_searchable_post_types')) { return aksrt_searchable_post_types(); } return ['post']; } /** * Get MeiliSearch config */ private function get_config(): array { return [ 'host' => (string) AKSRT_Settings::get_value('meilisearch_host', ''), 'api_key' => (string) AKSRT_Settings::get_value('meilisearch_api_key', ''), 'index' => (string) AKSRT_Settings::get_value('meilisearch_index', 'aksrt_posts'), ]; } /** * Make HTTP request to MeiliSearch */ private function request(string $method, string $path, array $body = []): ?array { $config = $this->get_config(); $url = rtrim($config['host'], '/') . $path; $args = [ 'method' => $method, 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . $config['api_key'], 'Content-Type' => 'application/json', ], ]; if (!empty($body)) { $args['body'] = wp_json_encode($body); } $response = wp_remote_request($url, $args); if (is_wp_error($response)) { return null; } $code = wp_remote_retrieve_response_code($response); if ($code < 200 || $code >= 300) { return null; } $data = json_decode(wp_remote_retrieve_body($response), true); return is_array($data) ? $data : null; } /** * Search posts via MeiliSearch */ public function search(string $query, array $args = []): array { $config = $this->get_config(); $index = $config['index']; $body = [ 'q' => $query, 'limit' => $args['posts_per_page'] ?? get_option('posts_per_page', 10), 'offset' => (($args['paged'] ?? 1) - 1) * ($args['posts_per_page'] ?? get_option('posts_per_page', 10)), 'attributesToRetrieve' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail'], ]; // Filter by post type if (!empty($args['post_type'])) { $body['filter'] = 'post_type = ' . $args['post_type']; } $result = $this->request('POST', "/indexes/{$index}/search", $body); if (!$result) return []; return [ 'hits' => $result['hits'] ?? [], 'total' => $result['estimatedTotalHits'] ?? 0, 'query_time' => $result['processingTimeMs'] ?? 0, ]; } /** * Override WordPress search with MeiliSearch */ public function override_search(string $search, WP_Query $query): string { if (!$query->is_search() || !$query->is_main_query()) { return $search; } $q = $query->get('s'); if (empty($q)) return $search; $results = $this->search($q, [ 'posts_per_page' => $query->get('posts_per_page'), 'paged' => $query->get('paged') ?: 1, 'post_type' => $query->get('post_type') ?: 'post', ]); if (empty($results['hits'])) { // No results — force empty return 'AND 1=0'; } $ids = array_map(function ($hit) { return (int) ($hit['id'] ?? 0); }, $results['hits']); $ids = array_filter($ids); if (empty($ids)) return 'AND 1=0'; global $wpdb; return 'AND ' . $wpdb->posts . '.ID IN (' . implode(',', $ids) . ')'; } /** * Index a single post */ public function index_post(int $post_id): bool { $post = get_post($post_id); if (!$post || $post->post_status !== 'publish') return false; $config = $this->get_config(); $index = $config['index']; $doc = [ 'id' => $post_id, 'title' => $post->post_title, 'excerpt' => wp_strip_all_tags(get_the_excerpt($post)), 'content' => wp_strip_all_tags($post->post_content), 'url' => get_permalink($post_id), 'date' => $post->post_date, 'post_type' => $post->post_type, 'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '', ]; $result = $this->request('PUT', "/indexes/{$index}/documents", [$doc]); return $result !== null; } /** * Delete a post from index */ public function delete_post(int $post_id): bool { $config = $this->get_config(); $index = $config['index']; $result = $this->request('DELETE', "/indexes/{$index}/documents/{$post_id}"); return $result !== null; } /** * Sync all published posts */ public function sync_all_posts(): void { $config = $this->get_config(); $index = $config['index']; // Create index if not exists $this->request('POST', '/indexes', ['uid' => $index, 'primaryKey' => 'id']); // Configure filterable attributes $this->request('PUT', "/indexes/{$index}/settings", [ 'filterableAttributes' => ['post_type'], 'searchableAttributes' => ['title', 'excerpt', 'content'], 'displayedAttributes' => ['id', 'title', 'excerpt', 'url', 'date', 'thumbnail', 'post_type'], ]); $posts = get_posts([ 'post_type' => $this->indexable_post_types(), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', ]); $batch = []; foreach ($posts as $post_id) { $post = get_post($post_id); if (!$post) continue; $batch[] = [ 'id' => (int) $post_id, 'title' => $post->post_title, 'excerpt' => wp_strip_all_tags(get_the_excerpt($post)), 'content' => wp_strip_all_tags($post->post_content), 'url' => get_permalink($post_id), 'date' => $post->post_date, 'post_type' => $post->post_type, 'thumbnail' => get_the_post_thumbnail_url($post_id, 'medium') ?: '', ]; // Batch 100 at a time if (count($batch) >= 100) { $this->request('PUT', "/indexes/{$index}/documents", $batch); $batch = []; } } if (!empty($batch)) { $this->request('PUT', "/indexes/{$index}/documents", $batch); } } /** * Hook: re-index on post save */ public function on_save_post(int $post_id, $post, bool $update): void { if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id)) return; if ($post->post_status !== 'publish') return; if (!in_array($post->post_type, $this->indexable_post_types(), true)) return; // Debounce: schedule async wp_schedule_single_event(time() + 5, 'aksrt_meilisearch_index_single', [$post_id]); } /** * Hook: remove from index on post delete */ public function on_delete_post(int $post_id): void { $this->delete_post($post_id); } }