$limit, 'indexed' => $indexed, 'server_state' => $state, 'plan' => isset( $response['plan'] ) && is_string( $response['plan'] ) ? sanitize_key( $response['plan'] ) : '', 'pruned_at' => isset( $block['pruned_at'] ) && is_string( $block['pruned_at'] ) ? substr( sanitize_text_field( $block['pruned_at'] ), 0, 40 ) : '', 'trial_active' => ! empty( $trial['active'] ) && true === $trial['active'], 'trial_ends' => isset( $trial['ends_at'] ) && is_string( $trial['ends_at'] ) ? substr( sanitize_text_field( $trial['ends_at'] ), 0, 40 ) : '', 'checked_at' => time(), ); $current = get_option( self::OPTION, null ); if ( is_array( $current ) && self::same_record( $current, $record ) && ( time() - (int) $current['checked_at'] ) < HOUR_IN_SECONDS ) { return true; // Ίδια τιμή — καμία εγγραφή στη βάση σε κάθε προβολή της καρτέλας. } update_option( self::OPTION, $record, true ); return true; } /** * Σύγκριση χωρίς τη χρονοσφραγίδα. * * @param array $a Παλιό. * @param array $b Νέο. * @return bool */ private static function same_record( array $a, array $b ) { unset( $a['checked_at'], $b['checked_at'] ); ksort( $a ); ksort( $b ); return $a === $b; } /** * Πλήθος δημοσιευμένων προϊόντων του καταστήματος (cached από τον πυρήνα). * * @return int */ public static function catalog_total() { $counts = function_exists( 'wp_count_posts' ) ? wp_count_posts( 'product' ) : null; return is_object( $counts ) && isset( $counts->publish ) ? (int) $counts->publish : 0; } /** * Η κατάσταση κάλυψης για UI/frontend. * * Μερική όταν: ο κόμβος λέει «partial», Ή υπάρχει γνωστό πεπερασμένο όριο και ο * κατάλογος το ξεπερνά — εκτός αν ο δείκτης έχει ήδη τουλάχιστον όσα προϊόντα * έχει το κατάστημα (τότε τίποτα δεν λείπει, ό,τι κι αν λέει παλιά κατάσταση). * * @return array{partial:bool,known:bool,limit:int|null,indexed:int|null,total:int,covered:int|null,server_state:string,plan:string,pruned_at:string,trial_active:bool,trial_ends:string} */ public static function state() { $record = get_option( self::OPTION, array() ); $record = is_array( $record ) ? $record : array(); $total = self::catalog_total(); $limit = isset( $record['limit'] ) && is_numeric( $record['limit'] ) ? (int) $record['limit'] : null; $indexed = isset( $record['indexed'] ) && is_numeric( $record['indexed'] ) ? (int) $record['indexed'] : null; $server = isset( $record['server_state'] ) ? (string) $record['server_state'] : ''; $partial = false; if ( null !== $indexed && $total <= $indexed ) { $partial = false; } elseif ( 'partial' === $server ) { $partial = true; } elseif ( null !== $limit && $total > $limit ) { $partial = true; } // Πόσα προϊόντα καλύπτει η έξυπνη αναζήτηση (για το «X από Y»): ό,τι // μετρά ο δείκτης, ποτέ πάνω από το όριο του πακέτου ή τον κατάλογο. $covered = $indexed; if ( null !== $covered && null !== $limit ) { $covered = min( $covered, $limit ); } if ( null !== $covered ) { $covered = min( $covered, $total ); } // Δοκιμή Pro: από τον κόμβο (/v1/stats `trial`) ή από τον hub (status `entitlement.trial`). $trial_active = ! empty( $record['trial_active'] ); $trial_ends = isset( $record['trial_ends'] ) ? (string) $record['trial_ends'] : ''; if ( class_exists( 'Addsynapse_Search_Hub' ) && Addsynapse_Search_Hub::is_connected() ) { $hub = Addsynapse_Search_Hub::status(); if ( ! empty( $hub['entitlement']['trial']['active'] ) ) { $trial_active = true; if ( '' === $trial_ends && ! empty( $hub['entitlement']['trial']['ends_at'] ) ) { $trial_ends = (string) $hub['entitlement']['trial']['ends_at']; } } } $state = array( 'partial' => $partial, 'known' => ! empty( $record ), 'limit' => $limit, 'indexed' => $indexed, 'total' => $total, 'covered' => $covered, 'server_state' => $server, 'plan' => isset( $record['plan'] ) ? (string) $record['plan'] : '', 'pruned_at' => isset( $record['pruned_at'] ) ? (string) $record['pruned_at'] : '', 'trial_active' => $trial_active, 'trial_ends' => $trial_ends, ); /** * Filters the catalogue-coverage state (partial = the plan covers fewer products than the store has). * * @param array $state Coverage state. */ $filtered = apply_filters( 'addsynapse_search_coverage_state', $state ); return is_array( $filtered ) ? array_merge( $state, $filtered ) : $state; } /** * Μερική κάλυψη (το συμπλήρωμα από την αναζήτηση του WordPress είναι ενεργό). * * @return bool */ public static function is_partial() { $state = self::state(); return ! empty( $state['partial'] ); } /** * Ανανέωση από το /v1/stats στο παρασκήνιο (cron/Action Scheduler) — ποτέ σε αίτημα σελίδας. * * @return void */ public static function refresh_in_background() { if ( ! class_exists( 'Addsynapse_Search_Analytics' ) || ! class_exists( 'Addsynapse_Search_License' ) || ! Addsynapse_Search_License::is_active() ) { return; } if ( ! ( wp_doing_cron() || doing_action( 'action_scheduler_run_queue' ) || ( defined( 'WP_CLI' ) && WP_CLI ) || is_admin() ) ) { return; } Addsynapse_Search_Analytics::get_stats( 30, true ); } /* ----------------------------------------------------------------- * Σύνδεσμος αναβάθμισης / δοκιμής * --------------------------------------------------------------- */ /** * Πού οδηγεί το κουμπί αναβάθμισης της καρτέλας: δοκιμή Pro (μόνο όταν ο hub τη * διαφημίζει με `portal.trial_url`), αλλιώς αναβάθμιση/διαχείριση στην πύλη, * αλλιώς η σελίδα τιμών του κόμβου. * * @return array{url:string,trial:bool} */ public static function upgrade_link() { $url = ''; $trial = false; if ( class_exists( 'Addsynapse_Search_Hub' ) && Addsynapse_Search_Hub::is_connected() ) { $status = Addsynapse_Search_Hub::status(); $portal = isset( $status['portal'] ) && is_array( $status['portal'] ) ? $status['portal'] : array(); $ent = isset( $status['entitlement'] ) && is_array( $status['entitlement'] ) ? $status['entitlement'] : array(); $t = isset( $ent['trial'] ) && is_array( $ent['trial'] ) ? $ent['trial'] : array(); $eligible = ! ( array_key_exists( 'eligible', $t ) && false === $t['eligible'] ) && empty( $t['active'] ); if ( $eligible && ! empty( $portal['trial_url'] ) && ! Addsynapse_Search_Hub::is_development_site() ) { $url = (string) $portal['trial_url']; $trial = true; } elseif ( ! empty( $portal['upgrade_url'] ) ) { $url = (string) $portal['upgrade_url']; } else { $url = Addsynapse_Search_Hub::manage_url(); } } if ( '' === $url && class_exists( 'Addsynapse_Search_License' ) ) { $url = Addsynapse_Search_License::pricing_url(); } return array( 'url' => $url, 'trial' => $trial, ); } /* ----------------------------------------------------------------- * Native συμπλήρωμα * --------------------------------------------------------------- */ /** * Καθαρό query για τη native αναζήτηση ('' = καμία αναζήτηση). * * @param mixed $raw Είσοδος. * @return string */ public static function clean_query( $raw ) { $q = is_scalar( $raw ) ? sanitize_text_field( (string) $raw ) : ''; $q = trim( preg_replace( '/\s+/u', ' ', $q ) ); if ( function_exists( 'mb_substr' ) ) { $q = mb_substr( $q, 0, self::NATIVE_MAX_CHARS ); $n = mb_strlen( $q ); } else { $q = substr( $q, 0, self::NATIVE_MAX_CHARS ); $n = strlen( $q ); } return $n >= self::NATIVE_MIN_CHARS ? $q : ''; } /** * Ids (γονικά προϊόντα, δημοσιευμένα, ορατά στην αναζήτηση) της κανονικής * αναζήτησης του WordPress, cached ανά query. * * @param string $query Καθαρό query (clean_query()). * @param int $pool Πόσα το πολύ. * @return int[] */ public static function native_ids( $query, $pool = self::NATIVE_POOL ) { $query = self::clean_query( $query ); $pool = max( 1, min( 200, (int) $pool ) ); if ( '' === $query ) { return array(); } $lower = function_exists( 'mb_strtolower' ) ? mb_strtolower( $query ) : strtolower( $query ); $key = self::NATIVE_CACHE_PREFIX . md5( $lower . '|' . $pool . '|' . determine_locale() . '|' . ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ? 1 : 0 ) ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return array_map( 'intval', $cached ); } $tax_query = array(); $excluded = array(); if ( function_exists( 'wc_get_product_visibility_term_ids' ) ) { $terms = wc_get_product_visibility_term_ids(); if ( ! empty( $terms['exclude-from-search'] ) ) { $excluded[] = (int) $terms['exclude-from-search']; } if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) && ! empty( $terms['outofstock'] ) ) { $excluded[] = (int) $terms['outofstock']; } } if ( ! empty( $excluded ) ) { $tax_query[] = array( 'taxonomy' => 'product_visibility', 'field' => 'term_taxonomy_id', 'terms' => $excluded, 'operator' => 'NOT IN', ); } $args = array( 'post_type' => 'product', 'post_status' => 'publish', 's' => $query, 'posts_per_page' => $pool, 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, self::QV_NATIVE => 1, ); if ( ! empty( $tax_query ) ) { $args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- ίδιο φίλτρο ορατότητας με την αναζήτηση του WooCommerce, bounded από posts_per_page. } if ( class_exists( 'Addsynapse_Search_Theme_Search' ) ) { $args[ Addsynapse_Search_Theme_Search::QV_SKIP ] = 1; } $wp_query = new WP_Query( $args ); $ids = array_values( array_unique( array_map( 'intval', (array) $wp_query->posts ) ) ); set_transient( $key, $ids, self::NATIVE_CACHE_TTL ); return $ids; } /** * Native ids χωρίς όσα έδειξε ήδη η μηχανή μας. * * @param string $query Query. * @param int[] $exclude Ids που υπάρχουν ήδη στη λίστα (γονικά). * @param int $max Πόσα το πολύ. * @return int[] */ public static function native_extras( $query, array $exclude, $max ) { $max = max( 0, (int) $max ); if ( 0 === $max ) { return array(); } $skip = array_flip( array_map( 'intval', $exclude ) ); $out = array(); foreach ( self::native_ids( $query, max( self::NATIVE_POOL, $max + count( $skip ) ) ) as $id ) { if ( isset( $skip[ $id ] ) ) { continue; } $out[] = $id; if ( count( $out ) >= $max ) { break; } } return $out; } /** * Κάρτες για το πάνελ: ίδιο σχήμα με τα hits του δείκτη (id/title/permalink/image/price/…). * * @param int[] $ids Ids. * @return array[] */ public static function native_items( array $ids ) { $items = array(); if ( ! function_exists( 'wc_get_product' ) ) { return $items; } foreach ( $ids as $id ) { $product = wc_get_product( (int) $id ); if ( ! ( $product instanceof WC_Product ) || 'publish' !== $product->get_status() || ! $product->is_visible() ) { continue; } $price = '' === (string) $product->get_price() ? null : (float) wc_get_price_to_display( $product ); $regular = null; if ( null !== $price && $product->is_on_sale() && '' !== (string) $product->get_regular_price() ) { $regular = (float) wc_get_price_to_display( $product, array( 'price' => $product->get_regular_price() ) ); if ( $regular <= $price ) { $regular = null; } } $image = ''; if ( $product->get_image_id() ) { $image = (string) wp_get_attachment_image_url( $product->get_image_id(), 'woocommerce_thumbnail' ); } if ( '' === $image && function_exists( 'wc_placeholder_img_src' ) ) { $image = (string) wc_placeholder_img_src( 'woocommerce_thumbnail' ); } $items[] = array( 'id' => (string) $product->get_id(), 'title' => html_entity_decode( wp_strip_all_tags( $product->get_name() ), ENT_QUOTES, 'UTF-8' ), 'permalink' => esc_url_raw( (string) get_permalink( $product->get_id() ) ), 'image' => esc_url_raw( $image ), 'price' => $price, 'regular_price' => $regular, 'in_stock' => (bool) $product->is_in_stock(), 'native' => true, ); } return $items; } /** * Δημόσιο endpoint (admin-ajax `addsynapse_search_native`) — συμπλήρωμα του πάνελ. * * 🔴 ΧΩΡΙΣ NONCE, όπως το `addsynapse_search_token`: ανάγνωση δημόσιων στοιχείων * προϊόντων (ό,τι δείχνει ήδη η σελίδα αναζήτησης του WordPress) για σελίδες που * μπορεί να έρχονται από cache. Προστασία: όριο ανά IP, cache ανά query, λίγα * αποτελέσματα, και τίποτα όταν η κάλυψη δεν είναι μερική. * * @return void */ public static function ajax_native() { nocache_headers(); if ( class_exists( 'Addsynapse_Search_Frontend' ) && Addsynapse_Search_Frontend::rate_limit_hit( 'addsynapse_search_natrl_', self::NATIVE_RATE_LIMIT ) ) { wp_send_json_error( array( 'code' => 'rate_limited' ), 429 ); } if ( ! self::is_partial() ) { wp_send_json_success( array( 'items' => array() ) ); } // phpcs:disable WordPress.Security.NonceVerification.Missing -- δημόσιο, read-only, rate-limited endpoint για cached σελίδες (βλ. docblock). $query = self::clean_query( isset( $_POST['q'] ) ? sanitize_text_field( wp_unslash( $_POST['q'] ) ) : '' ); $exclude = isset( $_POST['exclude'] ) ? sanitize_text_field( wp_unslash( $_POST['exclude'] ) ) : ''; $limit = isset( $_POST['limit'] ) ? absint( wp_unslash( $_POST['limit'] ) ) : self::NATIVE_MAX; // phpcs:enable WordPress.Security.NonceVerification.Missing $exclude_ids = array_slice( array_filter( array_map( 'absint', explode( ',', $exclude ) ) ), 0, self::NATIVE_MAX_EXCLUDE ); $limit = max( 1, min( self::native_max(), $limit ) ); if ( '' === $query ) { wp_send_json_success( array( 'items' => array() ) ); } wp_send_json_success( array( 'items' => self::native_items( self::native_extras( $query, $exclude_ids, $limit ) ) ) ); } /** * Όριο native αποτελεσμάτων του πάνελ. * * @return int */ public static function native_max() { /** * Filters how many standard-WordPress-search products are appended to the search panel when the plan covers part of the catalogue. * * @param int $max Default 6 (1–20). */ return max( 0, min( 20, (int) apply_filters( 'addsynapse_search_native_max', self::NATIVE_MAX ) ) ); } /** * Όριο native ids στη σελίδα αποτελεσμάτων του θέματος («theme» mode). * * @return int */ public static function native_page_max() { /** * Filters how many standard-WordPress-search products are appended to the theme's search results page when the plan covers part of the catalogue. * * @param int $max Default 48 (0–200). */ return max( 0, min( 200, (int) apply_filters( 'addsynapse_search_native_page_max', self::NATIVE_PAGE_MAX ) ) ); } /* ----------------------------------------------------------------- * Προτεραιότητα στη δεικτοδότηση * --------------------------------------------------------------- */ /** * Τα «σημαντικότερα» προϊόντα (πωλήσεις → διαθέσιμα → νεότερα), για το πρελούδιο * ενός πλήρους περάσματος όταν ο κατάλογος ξεπερνά το όριο του πακέτου: στέλνονται * πρώτα (με `priority`), ώστε ο κόμβος να κρατήσει αυτά και όχι τα πρώτα κατά id. * * @param int $limit Όριο πακέτου. * @return int[] */ public static function priority_ids( $limit ) { $limit = (int) $limit; if ( $limit <= 0 ) { return array(); } $wanted = min( 5000, $limit + (int) ceil( $limit / 10 ) ); $wp_query = new WP_Query( array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $wanted, 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- μία φορά ανά πλήρες πέρασμα (παρασκήνιο), bounded. 'meta_query' => array( 'sales' => array( 'key' => 'total_sales', 'type' => 'NUMERIC', 'compare' => 'EXISTS', ), 'stock' => array( 'key' => '_stock_status', 'compare' => 'EXISTS', ), ), // _stock_status: instock < onbackorder < outofstock (αλφαβητικά) → ASC = διαθέσιμα πρώτα. 'orderby' => array( 'sales' => 'DESC', 'stock' => 'ASC', 'date' => 'DESC', 'ID' => 'DESC', ), ) ); return array_values( array_unique( array_map( 'intval', (array) $wp_query->posts ) ) ); } /** * Χρειάζεται πρελούδιο προτεραιότητας; Μόνο με γνωστό πεπερασμένο όριο που ο κατάλογος ξεπερνά. * * @return int Το όριο (0 = όχι). */ public static function priority_limit() { $state = self::state(); if ( null === $state['limit'] || $state['limit'] <= 0 || $state['total'] <= $state['limit'] ) { return 0; } return (int) $state['limit']; } }