. * Το master key του Meilisearch δεν αγγίζει ποτέ το plugin. * * 🔴 Πολιτική χρόνου ανά περιβάλλον (βλ. request_policy()): * - Παρασκήνιο (WP-Cron / Action Scheduler / WP-CLI): 3 προσπάθειες × 15s, * backoff με sleep() — εκεί δεν περιμένει κανένας άνθρωπος. * - Επισκέπτης (frontend) ή κλήση που μπλοκάρει render σελίδας (token): * ΜΙΑ προσπάθεια, 3s, ΚΑΝΕΝΑ sleep(). Με το API πεσμένο, μια σελίδα του * καταστήματος περίμενε ~48s (3×15s + 1s + 2s) σε κάθε επισκέπτη. * - Ενέργεια διαχειριστή (κουμπί στο admin): μία προσπάθεια, 15s. * Retry μόνο σε 5xx/timeout — ΠΟΤΕ σε 4xx. */ class Addsynapse_Search_Api_Client { /** * Πόσες προσπάθειες συνολικά (1 αρχική + retries). * * @var int */ const MAX_ATTEMPTS = 3; /** * Στέλνει/ενημερώνει έγγραφα στον δείκτη. * * @param array $docs Λίστα εγγράφων (βλ. Addsynapse_Search_Indexer::build_document()). * @param bool $priority 0.8.0: πρελούδιο «σημαντικότερων» προϊόντων ενός πλήρους περάσματος * (ο κόμβος δίνει προσωρινό περιθώριο πάνω από το όριο και κλαδεύει * μετά κρατώντας τα σημαντικότερα· παλιός κόμβος το αγνοεί). * @return array|WP_Error */ public static function ingest( array $docs, $priority = false ) { $docs = array_values( $docs ); $extra = $priority ? array( 'priority' => true ) : array(); if ( ! self::catalog_visibility_unsupported() ) { $result = self::request( 'POST', '/v1/documents', array( 'documents' => $docs ) + $extra ); if ( ! self::is_catalog_visibility_rejection( $result ) || array() === self::catalog_only_ids( $docs ) ) { return $result; } // Παλιός server: θυμόμαστε την ικανότητα ανά api_base για μία ημέρα // (μετά ξαναδοκιμάζει ολόκληρη παρτίδα — «αναβαθμίστηκε;»). set_transient( self::catalog_capability_transient(), 'no', DAY_IN_SECONDS ); } return self::ingest_without_catalog_only( $docs, $extra ); } /** * 🔴 Συμβατότητα με server πριν το 0.4.2 (audit F04): ο παλιός * DocumentsController δέχεται `catalog_visibility` ΜΟΝΟ `visible|search` — * ένα έγγραφο «catalog» (ορατό μόνο στον κατάλογο) έριχνε 422 σε ΟΛΗ την * παρτίδα, άρα ΚΑΝΕΝΑ προϊόν της δεν ενημερωνόταν. Εδώ στέλνουμε την * παρτίδα χωρίς αυτά· επιστρέφονται ως `rejected_ids` (δεν δεικτοδοτήθηκαν * — καμία σφραγίδα/«indexed» action, μια εκκρεμής διαγραφή τους ισχύει) με * ρητό `accepted` (η σκούπα μετρά μόνο ό,τι σφραγίστηκε). Τα νέα πεδία * `category_ids`/`category_path_ids`/`brand_ids` ο παλιός server τα πετά * σιωπηλά (validate() κρατά μόνο ό,τι έχει κανόνα) — ακίνδυνο. * * @param array[] $docs Έγγραφα (list). * @param array $extra Επιπλέον πεδία σώματος (π.χ. priority). * @return array|WP_Error */ private static function ingest_without_catalog_only( array $docs, array $extra = array() ) { $skipped = self::catalog_only_ids( $docs ); $send = array(); foreach ( $docs as $doc ) { if ( ! ( is_array( $doc ) && isset( $doc['catalog_visibility'] ) && 'catalog' === $doc['catalog_visibility'] ) ) { $send[] = $doc; } } $result = array() === $send ? array( 'accepted' => 0 ) : self::request( 'POST', '/v1/documents', array( 'documents' => $send ) + $extra ); if ( ! is_array( $result ) || array() === $skipped ) { return $result; } // Όπως έκανε το ≤0.4.1 για τα «catalog» (εκτός δείκτη): ένα προϊόν που // ΜΟΛΙΣ έγινε «μόνο κατάλογος» δεν πρέπει να μείνει ορατό στην // αναζήτηση με το παλιό του έγγραφο. Best effort — η σκούπα του // πλήρους περάσματος το πιάνει ούτως ή άλλως (δεν σφραγίστηκε). self::delete( $skipped ); if ( ! isset( $result['accepted'] ) || ! is_numeric( $result['accepted'] ) ) { $result['accepted'] = count( $send ); } $already = isset( $result['rejected_ids'] ) && is_array( $result['rejected_ids'] ) ? $result['rejected_ids'] : array(); $result['rejected_ids'] = array_values( array_merge( $already, $skipped ) ); $result['skipped_catalog_only'] = count( $skipped ); return $result; } /** * Ids των εγγράφων «μόνο κατάλογος». * * @param array[] $docs Έγγραφα. * @return string[] */ private static function catalog_only_ids( array $docs ) { $ids = array(); foreach ( $docs as $doc ) { if ( is_array( $doc ) && isset( $doc['id'], $doc['catalog_visibility'] ) && 'catalog' === $doc['catalog_visibility'] ) { $ids[] = (string) $doc['id']; } } return $ids; } /** * 422 του οποίου ΟΛΑ τα πεδία σφάλματος είναι `documents.N.catalog_visibility` * (οποιοδήποτε άλλο πεδίο = πραγματικό πρόβλημα → επιστρέφεται ως έχει). * * @param array|WP_Error $result Απάντηση request(). * @return bool */ private static function is_catalog_visibility_rejection( $result ) { if ( ! is_wp_error( $result ) ) { return false; } $data = $result->get_error_data(); if ( ! is_array( $data ) || ! isset( $data['status'] ) || 422 !== (int) $data['status'] || empty( $data['error_fields'] ) || ! is_array( $data['error_fields'] ) ) { return false; } foreach ( $data['error_fields'] as $field ) { if ( 1 !== preg_match( '/^documents\.\d+\.catalog_visibility$/', (string) $field ) ) { return false; } } return true; } /** * Ο server (αυτού του api_base) είναι γνωστό ότι ΔΕΝ δέχεται «catalog». * * @return bool */ public static function catalog_visibility_unsupported() { return 'no' === get_transient( self::catalog_capability_transient() ); } /** * Transient ικανότητας ανά api_base (άλλος κόμβος = άλλη έκδοση server). * * @return string */ private static function catalog_capability_transient() { $base = class_exists( 'Addsynapse_Search_Settings' ) ? (string) Addsynapse_Search_Settings::get( 'api_base', Addsynapse_Search_Settings::DEFAULT_API_BASE ) : ''; return 'addsynapse_search_cap_catalog_' . substr( md5( untrailingslashit( $base ) ), 0, 12 ); } /** * Διαγράφει έγγραφα από τον δείκτη με τα id τους. * * @param array $ids Λίστα από product id (string ή int). * @return array|WP_Error */ public static function delete( array $ids ) { $ids = array_values( array_map( 'strval', $ids ) ); return self::request( 'DELETE', '/v1/documents', array( 'ids' => $ids ) ); } /** * Σκούπα ορφανών: ο server σβήνει από τον δείκτη ΤΟΥ ΚΑΤΑΣΤΗΜΑΤΟΣ κάθε * έγγραφο που ΔΕΝ φέρει τη γενιά $generation (δηλαδή δεν ξαναστάλθηκε * στο πλήρες πέρασμα που μόλις τελείωσε). Αρνείται (409) αν στον δείκτη * βρεθούν λιγότερα από $expected έγγραφα με αυτή τη γενιά. * * @param string $generation Γενιά του περάσματος (αλφαριθμητικό). * @param int $expected Πόσα έγγραφα στάλθηκαν με επιτυχία στο πέρασμα. * @return array|WP_Error Σχήμα: array( 'ok' => true, 'deleted' => int, 'kept' => int ). */ public static function sweep( $generation, $expected ) { return self::request( 'POST', '/v1/documents/sweep', array( 'generation' => (string) $generation, 'expected' => (int) $expected, ) ); } /** * Ζητά νέο tenant token (JWT, περιορισμένο στον δείκτη του καταστήματος). * Χρησιμοποιεί το αποθηκευμένο license key της εγκατάστασης. * * Καλείται κατά το render σελίδας (βλ. Addsynapse_Search_License::tenant_token()), * άρα ΠΑΝΤΑ με τη γρήγορη πολιτική εκτός αν τρέχουμε στο παρασκήνιο. * * @return array|WP_Error Αναμενόμενα κλειδιά: token, ttl, active, plan, expires_at. */ public static function token() { return self::remember_semantic( self::request( 'GET', '/v1/auth/token', array(), null, true, array( 'fast' => true ) ) ); } /** * Κρατά το μπλοκ `semantic` (0.7.0) από απαντήσεις settings/stats/auth-token * — βλ. Addsynapse_Search_Settings::store_semantic_state(). Επιστρέφει την * απάντηση αυτούσια. * * @param array|WP_Error $response Απάντηση request(). * @return array|WP_Error */ private static function remember_semantic( $response ) { if ( is_array( $response ) && class_exists( 'Addsynapse_Search_Settings' ) ) { Addsynapse_Search_Settings::store_semantic_state( $response ); } return $response; } /** * Ζητά ΒΡΑΧΥΒΙΟ σύνδεσμο προς το Customer Portal του Stripe: συνδρομή, * ακύρωση, αλλαγή κάρτας, τιμολόγια. * * 🔴 Καμία σύνδεση, κανένας κωδικός: ταυτοποίηση με το license key, όπως * κάθε άλλη κλήση. Ο χειριστής είναι ήδη admin του WordPress — δεύτερο * login θα πρόσθετε τριβή χωρίς ασφάλεια. * * 🔴 Ο σύνδεσμος ΔΕΝ αποθηκεύεται πουθενά: δίνει πρόσβαση στα τιμολόγια * του πελάτη και λήγει μόνος του. * * @return array|WP_Error Σχήμα: array( 'url' => '...' ). */ public static function billing_portal() { return self::request( 'POST', '/v1/billing-portal' ); } /** * Στατιστικά αναζητήσεων/κλικ του καταστήματος. * * @param array $args Παράμετροι φίλτρου (π.χ. range). * @return array|WP_Error */ public static function stats( array $args = array() ) { $response = self::remember_semantic( self::request( 'GET', '/v1/stats', $args ) ); // 0.8.0: κάλυψη καταλόγου (όριο/δεικτοδοτημένα/κατάσταση) για το native συμπλήρωμα. if ( is_array( $response ) && class_exists( 'Addsynapse_Search_Coverage' ) ) { Addsynapse_Search_Coverage::record_stats( $response ); } return $response; } /** * Διαβάζει τα συνώνυμα ΑΠΟ ΤΟΝ ΙΔΙΟ ΤΟΝ ΔΕΙΚΤΗ Meilisearch — η αλήθεια, όχι * το τοπικό option (που μπορεί να έχει αποκλίνει, π.χ. αν απέτυχε * προηγούμενο settings() κάπου στο παρελθόν). * * @return array|WP_Error Αναμενόμενο σχήμα: array( 'synonyms' => array( 'όρος, όρος, όρος', ... ) ). */ public static function get_settings() { return self::request( 'GET', '/v1/settings' ); } /** * Ζητά προτάσεις προϊόντων για έναν όρο αναζήτησης χωρίς αποτέλεσμα * («τι μπορεί να εννοούσαν;»), ώστε το admin UI να προτείνει με ένα κλικ * συνώνυμο όρος -> λέξεις του προϊόντος. * * @param string $term Ο όρος αναζήτησης. * @return array|WP_Error Αναμενόμενο σχήμα: array( 'term' => '', 'candidates' => array( array( 'id' => 0, 'title' => '', 'score' => 0.0 ) ) ). */ public static function suggest( $term ) { return self::request( 'POST', '/v1/suggest', array( 'term' => (string) $term ) ); } /** * Στέλνει ρυθμίσεις δείκτη (προς το παρόν: συνώνυμα) στο API, ώστε να * εφαρμοστούν στον δείκτη Meilisearch ΤΟΥ ΚΑΤΑΣΤΗΜΑΤΟΣ. * * 🔴 ΚΑΛΥΠΤΕΙ ΓΝΩΣΤΟ ΚΕΝΟ: πριν, τα συνώνυμα αποθηκεύονταν ΜΟΝΟ τοπικά * (Addsynapse_Search_Settings) — ο πελάτης έβλεπε «αποθηκεύτηκε» αλλά τίποτα δεν * άλλαζε στον πραγματικό δείκτη, γιατί κανένα endpoint δεν τα μετέφερε. * Καλείται από Addsynapse_Search_Admin::handle_save_settings() (αυτόματα με κάθε * αποθήκευση ρυθμίσεων) ΚΑΙ από Addsynapse_Search_License::activate() (στην πλήρη * ενεργοποίηση/επαναδεικτοδότηση). * * Από 0.4.2 στέλνεται ΚΑΙ το `send_search_stats` (bool): ο server το * αποθηκεύει στο κατάστημα και αγνοεί τα beacons του /v1/track όταν είναι * off (audit F09 — server-side επιβολή της επιλογής). * * @param array $settings Ζεύγη key => value προς αποστολή (π.χ. array( 'synonyms' => "...\n..." )). * @return array|WP_Error */ public static function settings( array $settings ) { // Γλώσσα καταλόγου σε ΚΑΘΕ συγχρονισμό -> ο server διαλέγει stop words // (αλλαγή γλώσσας site = νέα λίστα στην επόμενη αποθήκευση ρυθμίσεων). if ( ! isset( $settings['locale'] ) ) { $settings['locale'] = Addsynapse_Search_Frontend::catalog_lang(); } if ( isset( $settings['send_search_stats'] ) ) { $settings['send_search_stats'] = (bool) $settings['send_search_stats']; } // 0.7.0: σημασιολογική αναζήτηση (beta) — ο server ρυθμίζει/αφαιρεί τον // embedder του δείκτη. Στέλνεται ΠΑΝΤΑ (όπως το locale). if ( ! isset( $settings['semantic_search'] ) && class_exists( 'Addsynapse_Search_Settings' ) ) { $settings['semantic_search'] = Addsynapse_Search_Settings::get( 'semantic_search', false ); } if ( isset( $settings['semantic_search'] ) ) { $settings['semantic_search'] = (bool) $settings['semantic_search']; } return self::remember_semantic( self::request( 'POST', '/v1/settings', $settings ) ); } /** * Γενικό αίτημα προς το api_base. Δημόσιο ώστε άλλες κλάσεις (π.χ. Addsynapse_Search_License, για * ενέργειες όπως activate/deactivate που δεν καλύπτονται από τα παραπάνω) να μπορούν * να το χρησιμοποιήσουν, ενδεχομένως με ρητό license key (π.χ. πριν αποθηκευτεί). * * ΠΟΤΕ μην καταγράφεις (log) το $license_key ή το Authorization header. * * @param string $method GET|POST|PUT|DELETE. * @param string $endpoint Path, π.χ. "/v1/license/activate". * @param array $body Body (POST/PUT/DELETE) ή query args (GET). * @param string|null $license_key Ρητό license key· αλλιώς παίρνει το αποθηκευμένο. * @param bool $require_key Αν false, επιτρέπεται κενό κλειδί (δημόσια endpoints) * — ΔΕΝ στέλνεται Authorization header. * @param array $opts Προαιρετικά: 'fast' => true για κλήσεις που μπλοκάρουν * render σελίδας (βλ. request_policy()). * @return array|WP_Error Αποκωδικοποιημένο JSON ως array, ή WP_Error. */ public static function request( $method, $endpoint, array $body = array(), $license_key = null, $require_key = true, array $opts = array() ) { $method = strtoupper( (string) $method ); $endpoint = '/' . ltrim( (string) $endpoint, '/' ); $base = class_exists( 'Addsynapse_Search_Settings' ) ? Addsynapse_Search_Settings::get( 'api_base', Addsynapse_Search_Settings::DEFAULT_API_BASE ) : 'https://search-api.addsynapse.com'; $base = untrailingslashit( (string) $base ); $url = $base . $endpoint; // 🔴 audit F07: ΠΟΤΕ license key/κατάλογος προς host εκτός allowlist — // ακόμα κι αν κάποια παλιά/αλλοιωμένη τιμή βρίσκεται ήδη στο option. if ( class_exists( 'Addsynapse_Search_Settings' ) && ! Addsynapse_Search_Settings::is_allowed_service_url( $base ) ) { return new WP_Error( 'addsynapse_search_api_host_not_allowed', __( 'The configured service address is not an allowed AddSynapse Search host.', 'addsynapse-search-filter' ) ); } $key = null !== $license_key ? $license_key : ( class_exists( 'Addsynapse_Search_Settings' ) ? Addsynapse_Search_Settings::get( 'license_key', '' ) : '' ); $key = is_string( $key ) ? trim( $key ) : ''; if ( '' === $key && $require_key ) { return new WP_Error( 'addsynapse_search_api_no_key', __( 'No license key is available for the API request.', 'addsynapse-search-filter' ) ); } $policy = self::request_policy( $opts ); $args = array( 'method' => $method, 'timeout' => $policy['timeout'], // 🔴 audit F07: καμία αυτόματη ανακατεύθυνση — ένα 3xx θα // ξανάστελνε το Authorization header σε άλλον, μη ελεγμένο host. 'redirection' => 0, 'headers' => array( 'Accept' => 'application/json', // Έκδοση plugin: ο server τη χρειάζεται για να ξέρει πότε // κανένα ενεργό κατάστημα δεν τρέχει πια έκδοση που δεν // ακολουθεί νέο host (κλείσιμο παλιού host). Όχι προσωπικό δεδομένο. 'X-AddSynapse-Plugin' => defined( 'ADDSYNAPSE_SEARCH_VERSION' ) ? ADDSYNAPSE_SEARCH_VERSION : '', ), ); if ( '' !== $key ) { $args['headers']['Authorization'] = 'Bearer ' . $key; } // Συνδεδεμένο με AddSynapse: το (δημόσιο) site key δένει κάθε κλήση στο site — ο κόμβος // απορρίπτει κλειδί άδειας που έρχεται με site key άλλου site. if ( class_exists( 'Addsynapse_Search_Hub' ) && '' !== Addsynapse_Search_Hub::site_key() ) { $args['headers']['X-AddSynapse-Site'] = Addsynapse_Search_Hub::site_key(); } if ( 'GET' === $method ) { if ( ! empty( $body ) ) { $url = add_query_arg( $body, $url ); } } else { $args['headers']['Content-Type'] = 'application/json'; $args['body'] = wp_json_encode( $body ); } $attempt = 0; $last_error = null; while ( $attempt < $policy['attempts'] ) { $attempt++; $response = wp_remote_request( $url, $args ); if ( is_wp_error( $response ) ) { $last_error = $response; if ( $attempt < $policy['attempts'] ) { self::wait_before_retry( $attempt ); continue; } break; } $code = (int) wp_remote_retrieve_response_code( $response ); $raw_body = wp_remote_retrieve_body( $response ); if ( $code >= 500 ) { $last_error = new WP_Error( 'addsynapse_search_api_server_error', sprintf( /* translators: %d: HTTP status code */ __( 'The AddSynapse Search server responded with error %d.', 'addsynapse-search-filter' ), $code ) ); if ( $attempt < $policy['attempts'] ) { self::wait_before_retry( $attempt ); continue; } break; } if ( $code >= 300 && $code < 400 ) { // Ανακατεύθυνση: δεν την ακολουθούμε (βλ. 'redirection' => 0). return new WP_Error( 'addsynapse_search_api_redirect', __( 'The AddSynapse Search server answered with an unexpected redirect.', 'addsynapse-search-filter' ), array( 'status' => $code ) ); } if ( $code < 200 ) { $last_error = new WP_Error( 'addsynapse_search_api_unknown_error', __( 'Unknown error communicating with the API.', 'addsynapse-search-filter' ) ); break; } if ( $code >= 400 ) { // ΟΧΙ retry σε 4xx — λάθος του αιτήματος, δεν διορθώνεται με επανάληψη. return new WP_Error( 'addsynapse_search_api_client_error', self::extract_error_message( $raw_body, $code ), self::extract_error_data( $raw_body, $code ) ); } $decoded = json_decode( $raw_body, true ); if ( JSON_ERROR_NONE !== json_last_error() ) { return new WP_Error( 'addsynapse_search_api_invalid_json', __( 'Invalid response from the API.', 'addsynapse-search-filter' ) ); } if ( ! is_array( $decoded ) ) { return array(); } // 0.6.3: ο κόμβος ξαναέφτιαξε ΑΔΕΙΟ δείκτη (διαγραφή λόγω αδράνειας) → μία πλήρης // επαναδεικτοδότηση στο παρασκήνιο (ουρά, ΠΟΤΕ σύγχρονα εδώ). Πρώτη επιτυχής ingest = τέλος. if ( isset( $decoded['needs_reindex'] ) && true === $decoded['needs_reindex'] && class_exists( 'Addsynapse_Search_Indexer' ) ) { Addsynapse_Search_Indexer::note_needs_reindex(); } elseif ( 'POST' === $method && '/v1/documents' === $endpoint && class_exists( 'Addsynapse_Search_Indexer' ) ) { Addsynapse_Search_Indexer::clear_needs_reindex(); } return $decoded; } return $last_error instanceof WP_Error ? $last_error : new WP_Error( 'addsynapse_search_api_unknown_error', __( 'Unknown error communicating with the API.', 'addsynapse-search-filter' ) ); } /** * Προσπάθειες/timeout για το τρέχον αίτημα. * * Παρασκήνιο = WP-Cron, WP-CLI ή εκτέλεση ουράς του Action Scheduler (και * μέσω του async runner του, που τρέχει σε admin-ajax). Μόνο εκεί * επιτρέπεται sleep() και πολλαπλές προσπάθειες. * * @param array $opts Βλ. request(). * @return array{attempts:int,timeout:int} */ private static function request_policy( array $opts ) { $background = wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI ) || doing_action( 'action_scheduler_run_queue' ); if ( $background ) { return array( 'attempts' => self::MAX_ATTEMPTS, 'timeout' => 15, ); } if ( ! empty( $opts['fast'] ) || ! is_admin() ) { return array( 'attempts' => 1, 'timeout' => 3, ); } return array( 'attempts' => 1, 'timeout' => 15, ); } /** * Απλό γραμμικό backoff πριν από retry (1ο retry ~1s, 2ο retry ~2s). * Φτάνουμε εδώ ΜΟΝΟ στο παρασκήνιο (attempts > 1 μόνο εκεί). * * @param int $attempt Ο αριθμός της προσπάθειας που μόλις απέτυχε. * @return void */ private static function wait_before_retry( $attempt ) { $seconds = max( 1, min( 3, (int) $attempt ) ); sleep( $seconds ); } /** * Εξάγει δομημένα δεδομένα λάθους από μια 4xx απάντηση (πέρα από το status), * ώστε οι καλούντες (π.χ. Addsynapse_Search_Indexer για 402 plan_limit) να μπορούν να κλαδέψουν βάσει του `code` * του server αντί να ξαναπαρσάρουν το JSON μόνοι τους. * * @param string $raw_body Το ακατέργαστο body. * @param int $code Ο HTTP status code. * @return array */ private static function extract_error_data( $raw_body, $code ) { $data = array( 'status' => (int) $code ); $decoded = json_decode( $raw_body, true ); if ( is_array( $decoded ) ) { // `accepted` (402 ingest): υπάρχει ΜΟΝΟ στον νέο server, όπου το 402 // σημαίνει «απορρίφθηκαν μόνο νέα» — βλ. Addsynapse_Search_Indexer::is_new_only_limit(). foreach ( array( 'code', 'limit', 'current', 'accepted' ) as $key ) { if ( isset( $decoded[ $key ] ) && is_scalar( $decoded[ $key ] ) ) { $data[ $key ] = $decoded[ $key ]; } } // Κοινό σχήμα σφάλματος (audit F13): κάποια endpoints (παλιότερος // server, π.χ. billing-portal) στέλνουν τον κωδικό ως `error`. Ο // καλών διαβάζει ΜΟΝΟ το `code` — ποτέ ολόκληρο το body. if ( ! isset( $data['code'] ) && isset( $decoded['error'] ) && is_string( $decoded['error'] ) ) { $data['code'] = $decoded['error']; } if ( isset( $data['code'] ) ) { $data['code'] = sanitize_key( (string) $data['code'] ); } // 422 validation (Laravel `errors`): ΜΟΝΟ τα ονόματα πεδίων, ποτέ // τα μηνύματα — βλ. is_catalog_visibility_rejection(). if ( isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) ) { $fields = array(); foreach ( array_slice( array_keys( $decoded['errors'] ), 0, 200 ) as $field ) { if ( is_string( $field ) && 1 === preg_match( '/^[A-Za-z0-9_.*-]{1,120}$/', $field ) ) { $fields[] = $field; } } $data['error_fields'] = $fields; } } return $data; } /** * Εξάγει ανθρώπινο μήνυμα λάθους από το body μιας 4xx απάντησης, αν υπάρχει. * * @param string $raw_body Το ακατέργαστο body. * @param int $code Ο HTTP status code. * @return string */ private static function extract_error_message( $raw_body, $code ) { $decoded = json_decode( $raw_body, true ); if ( is_array( $decoded ) && ! empty( $decoded['message'] ) && is_string( $decoded['message'] ) ) { return sanitize_text_field( $decoded['message'] ); } return sprintf( /* translators: %d: HTTP status code */ __( 'The request failed with code %d.', 'addsynapse-search-filter' ), $code ); } }