, complete: bool } */ private static function term_links_map( $field ) { $cached = get_transient( self::TERM_LINKS_CACHE . $field ); if ( is_array( $cached ) && isset( $cached['map'] ) && is_array( $cached['map'] ) ) { return $cached; } $map = array(); $complete = true; foreach ( self::term_link_taxonomies( $field ) as $taxonomy ) { $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => false, 'orderby' => 'count', 'order' => 'DESC', 'number' => self::TERM_LINKS_MAP_MAX, ) ); if ( ! is_array( $terms ) ) { continue; } if ( count( $terms ) >= self::TERM_LINKS_MAP_MAX ) { $complete = false; } foreach ( $terms as $term ) { $link = get_term_link( $term ); if ( is_wp_error( $link ) || ! is_string( $link ) || '' === $link ) { continue; } foreach ( array( (string) $term->name, html_entity_decode( (string) $term->name, ENT_QUOTES, 'UTF-8' ) ) as $name ) { if ( ! isset( $map[ $name ] ) ) { $map[ $name ] = esc_url_raw( $link ); } } } } $data = array( 'map' => $map, 'complete' => $complete, ); set_transient( self::TERM_LINKS_CACHE . $field, $data, 12 * HOUR_IN_SECONDS ); return $data; } /** * Καθαρισμός ονόματος όρου από το δημόσιο ajax_term_links(). * * ΟΧΙ sanitize_text_field: θα έκοβε «<»/«&…;» από γνήσια ονόματα όρων και δεν θα * ταίριαζαν. Το όνομα είναι μόνο κλειδί αναζήτησης σε πίνακα — δεν τυπώνεται ποτέ. * * @param mixed $name Τιμή από το αίτημα. * @return string '' αν δεν είναι έγκυρο. */ public static function sanitize_term_name( $name ) { if ( ! is_string( $name ) ) { return ''; } // Ίδια εξυγίανση με αυτή που κάνει ο πυρήνας στα ονόματα όρων (pre_term_name). $name = trim( sanitize_text_field( $name ) ); return strlen( $name ) <= 200 ? $name : ''; } /** * Δημόσιο endpoint: URL των σελίδων κατηγορίας/μάρκας για τα ονόματα που * δείχνει το sidebar του advanced πάνελ (admin-ajax `addsynapse_search_term_links`). * * 🔴 ΓΙΑΤΙ ΕΔΩ ΚΑΙ ΟΧΙ ΣΤΟΝ ΔΕΙΚΤΗ. Ο δείκτης κρατά μόνο ονόματα όρων. Ένα * slug/URL μέσα στο έγγραφο θα ήθελε αλλαγή indexer ΚΑΙ backend * (DocumentsController πετά σιωπηλά κάθε πεδίο χωρίς κανόνα) ΚΑΙ πλήρη * επαναδεικτοδότηση — και θα παλιώνε σε κάθε αλλαγή permalinks. Εδώ το * get_term_link() δίνει πάντα τη ΣΗΜΕΡΙΝΗ διεύθυνση του θέματος/WC. * * Χωρίς nonce για τον ίδιο λόγο με το ajax_token() (cached σελίδες): * read-only, επιστρέφει ΜΟΝΟ δημόσια URLs αρχείων. Όρια: ≤40 ονόματα, * ≤200 χαρακτήρες το καθένα, όριο αιτημάτων ανά IP, cached χάρτης. * * @return void */ public static function ajax_term_links() { nocache_headers(); // phpcs:disable WordPress.Security.NonceVerification.Missing -- δημόσιο, read-only, rate-limited endpoint για cached σελίδες (βλ. docblock). $field = isset( $_POST['field'] ) ? sanitize_key( wp_unslash( $_POST['field'] ) ) : ''; // Κάθε στοιχείο περνά από self::sanitize_term_name() (UTF-8 έλεγχος, χωρίς tags, ≤200 bytes). $raw = isset( $_POST['names'] ) && is_array( $_POST['names'] ) ? array_map( array( __CLASS__, 'sanitize_term_name' ), array_slice( map_deep( wp_unslash( $_POST['names'] ), 'sanitize_text_field' ), 0, self::TERM_LINKS_MAX_NAMES ) ) : array(); // phpcs:enable WordPress.Security.NonceVerification.Missing if ( ! in_array( $field, array( 'categories', 'brand' ), true ) ) { wp_send_json_error( array( 'code' => 'bad_field' ), 400 ); } if ( self::rate_limited( 'addsynapse_search_tlrl_', self::TERM_LINKS_RATE_LIMIT ) ) { wp_send_json_error( array( 'code' => 'rate_limited' ), 429 ); } $names = array_values( array_filter( $raw, 'strlen' ) ); $data = self::term_links_map( $field ); $links = array(); foreach ( $names as $name ) { $url = isset( $data['map'][ $name ] ) ? $data['map'][ $name ] : ''; // Πολύ μεγάλη ταξινομία (χάρτης κομμένος): ερώτημα ανά όνομα. if ( '' === $url && ! $data['complete'] ) { foreach ( self::term_link_taxonomies( $field ) as $taxonomy ) { $term = get_term_by( 'name', $name, $taxonomy ); if ( $term && ! is_wp_error( $term ) ) { $link = get_term_link( $term ); if ( is_string( $link ) ) { $url = esc_url_raw( $link ); break; } } } } $links[ $name ] = $url; } wp_send_json_success( array( 'links' => (object) $links ) ); } /** * Δημόσιο endpoint ανανέωσης του tenant token (admin-ajax `addsynapse_search_token`). * * 🔴 ΓΙΑΤΙ ΥΠΑΡΧΕΙ. Το token ταξιδεύει μέσα στο HTML (wp_localize_script). * Full-page cache/CDN σερβίρει σελίδα με token που έχει λήξει, και μια * καρτέλα ανοιχτή από χθες κρατά το χθεσινό. Η ανανέωση του transient * στον server δεν φτάνει ποτέ σε αυτές τις σελίδες. Το JS (Auth στο * addsynapse-search.js) καλεί εδώ σε 401/403 ή όταν βλέπει ληγμένο token. * * 🔴 ΓΙΑΤΙ ΧΩΡΙΣ NONCE. Είναι ανάγνωση, χωρίς καμία αλλαγή κατάστασης, και * επιστρέφει ΑΚΡΙΒΩΣ ό,τι τυπώνεται ήδη δημόσια σε κάθε σελίδα του * καταστήματος (read-only token δεμένο στον δείκτη, όνομα δείκτη, host * αναζήτησης). Ένα nonce θα ζούσε ΚΙ ΑΥΤΟ μέσα στην cached σελίδα και θα * έληγε — δηλαδή θα ακύρωνε τον λόγο ύπαρξης του endpoint. ΠΟΤΕ το * license key: δεν περνά από εδώ. * * Προστασία: nocache headers (POST, ποτέ cacheable), όριο ανά IP, και το * caching/backoff του ίδιου του Addsynapse_Search_License::tenant_token() (δεν * χτυπά το API μας σε κάθε κλήση). * * @return void */ public static function ajax_token() { nocache_headers(); // 🔴 audit F03: το φθηνό όριο ανά IP ΠΡΩΤΟ — το is_licensed() μπορεί // να ανανεώσει token (κλήση δικτύου), δεν το αφήνουμε σε κάθε αίτημα. if ( self::token_rate_limited() ) { wp_send_json_error( array( 'code' => 'rate_limited' ), 429 ); } if ( ! self::is_licensed() ) { wp_send_json_error( array( 'code' => 'inactive' ), 403 ); } // Το token με το οποίο ο browser πήρε 401/403. Προαιρετικό: χωρίς αυτό // (π.χ. προληπτική ανανέωση ληγμένου token) δίνουμε απλώς το cached. // Δημόσιο endpoint → δεν απαιτεί nonce (cached σελίδες έχουν ληγμένο // nonce)· η μορφή JWT ελέγχεται μέσα στο tenant_token_force_refresh(). $stale = isset( $_POST['stale'] ) ? sanitize_text_field( wp_unslash( $_POST['stale'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- δημόσιο, read-only, rate-limited endpoint για cached σελίδες. if ( strlen( $stale ) > 4096 ) { $stale = ''; } $token = '' !== $stale ? (string) Addsynapse_Search_License::tenant_token_force_refresh( $stale ) : (string) Addsynapse_Search_License::tenant_token(); if ( '' === $token ) { wp_send_json_error( array( 'code' => 'unavailable' ), 503 ); } wp_send_json_success( array( 'token' => sanitize_text_field( $token ), 'index' => self::index_uid(), 'endpoint' => self::search_endpoint(), 'expires' => self::token_expires( $token ), ) ); } /** * Ελαφρύ όριο ανά IP (transient με hash της IP — δεν αποθηκεύουμε IP). * * @return bool true = πάνω από το όριο. */ private static function token_rate_limited() { return self::rate_limited( 'addsynapse_search_tokrl_', self::TOKEN_RATE_LIMIT ); } /** * Δημόσιο όριο ανά IP για endpoints άλλων κλάσεων (π.χ. Addsynapse_Search_Coverage::ajax_native()). * * @param string $prefix Πρόθεμα transient ανά endpoint (addsynapse_search_*). * @param int $limit Μέγιστα αιτήματα στο παράθυρο (TOKEN_RATE_WINDOW). * @return bool true = πάνω από το όριο. */ public static function rate_limit_hit( $prefix, $limit ) { return self::rate_limited( (string) $prefix, (int) $limit ); } /** * Κοινό όριο αιτημάτων ανά IP (hash, όχι η IP) μέσα σε TOKEN_RATE_WINDOW. * * @param string $prefix Πρόθεμα transient ανά endpoint. * @param int $limit Μέγιστα αιτήματα στο παράθυρο. * @return bool true = πάνω από το όριο. */ private static function rate_limited( $prefix, $limit ) { $key = $prefix . substr( wp_hash( self::client_ip() ), 0, 20 ); $hits = (int) get_transient( $key ); if ( $hits >= $limit ) { return true; } set_transient( $key, $hits + 1, self::TOKEN_RATE_WINDOW ); return false; } /** * Η IP για τα όρια ανά IP: ΜΟΝΟ `REMOTE_ADDR`. * * 🔴 audit F03: ΠΟΤΕ αυθαίρετο `X-Forwarded-For` — ο επισκέπτης το * γράφει ό,τι θέλει και θα άλλαζε «IP» σε κάθε αίτημα. Site πίσω από * ΓΝΩΣΤΟ reverse proxy/CDN μπορεί να δώσει την πραγματική IP με το * φίλτρο `addsynapse_search_client_ip` (αφού επαληθεύσει τον proxy). * * @return string */ private static function client_ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Filters the client IP used for per-IP rate limits of the public endpoints. * * @param string $ip REMOTE_ADDR as seen by PHP. */ return (string) apply_filters( 'addsynapse_search_client_ip', $ip ); } /** * Ρύθμιση του native συμπληρώματος για το JS (μόνο σε μερική κάλυψη). * * @return array|null { partial: true, nativeMax: int, minChars: int } */ private static function coverage_config() { if ( ! class_exists( 'Addsynapse_Search_Coverage' ) || ! Addsynapse_Search_Coverage::is_partial() ) { return null; } $max = Addsynapse_Search_Coverage::native_max(); return $max > 0 ? array( 'partial' => true, 'nativeMax' => $max, 'minChars' => Addsynapse_Search_Coverage::NATIVE_MIN_CHARS, ) : null; } /** * Unix χρόνος λήξης του token, από την cached εγγραφή του License * (ίδιο token). 0 = άγνωστο — το JS διαβάζει τότε το `exp` του JWT. * * @param string $token Το token που δίνουμε. * @return int */ private static function token_expires( $token ) { $cached = get_transient( Addsynapse_Search_License::TRANSIENT_TOKEN ); if ( is_array( $cached ) && isset( $cached['token'], $cached['expires_at'] ) && (string) $cached['token'] === (string) $token ) { return absint( $cached['expires_at'] ); } return 0; } /** * Το όνομα του δείκτη. Το ορίζει Ο SERVER και αποθηκεύεται στο * `index_uid` κατά την ενεργοποίηση. Το fallback κρατά το ΙΣΤΟΡΙΚΟ σχήμα * `sups_` — ΟΧΙ το νέο prefix του plugin: ο δείκτης στο Meilisearch * δεν μετονομάστηκε μαζί με το plugin, και το tenant token είναι δεμένο * σε αυτό ακριβώς το όνομα. * * @return string */ public static function index_uid() { $uid = (string) Addsynapse_Search_Settings::get( 'index_uid', '' ); if ( '' !== $uid ) { return sanitize_text_field( $uid ); } $shop_id = class_exists( 'Addsynapse_Search_License' ) ? (string) Addsynapse_Search_License::shop_id() : ''; return 'sups_' . sanitize_key( $shop_id ); } /** * Ο δημόσιος host αναζήτησης (Meilisearch πίσω από το δικό μας domain). * * @return string */ public static function search_endpoint() { return esc_url_raw( Addsynapse_Search_Settings::get( 'meilisearch_host', Addsynapse_Search_Settings::DEFAULT_SEARCH_HOST ) ); } /** * Η γλώσσα της σελίδας που βλέπει ο επισκέπτης: WPML → Polylang → * get_locale(). Μορφή όπως τη δίνει η πηγή («el», «pt-br», «el_GR»). * * @return string */ public static function page_locale() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's public API filter. $wpml = apply_filters( 'wpml_current_language', null ); if ( is_string( $wpml ) && '' !== $wpml && 'all' !== $wpml ) { return $wpml; } if ( function_exists( 'pll_current_language' ) ) { $pll = pll_current_language( 'locale' ); if ( is_string( $pll ) && '' !== $pll ) { return $pll; } } return (string) get_locale(); } /** * Κωδικός γλώσσας 2-3 γραμμάτων από locale/κωδικό: «el_GR» → «el», * «pt-br» → «pt». '' αν δεν βγαίνει. * * @param string $locale Locale ή κωδικός γλώσσας. * @return string */ public static function lang_code( $locale ) { if ( ! is_string( $locale ) || ! preg_match( '/^([a-z]{2,3})(?:[-_]|$)/i', $locale, $m ) ) { return ''; } return strtolower( $m[1] ); } /** * Γλώσσα του καταλόγου για τα stop words του δείκτη (στέλνεται στο * backend στην ενεργοποίηση/αποθήκευση ρυθμίσεων): η ΠΡΟΕΠΙΛΕΓΜΕΝΗ * γλώσσα του site (WPML/Polylang), όχι της τρέχουσας σελίδας. * * @return string Κωδικός 2 γραμμάτων ή ''. */ public static function catalog_lang() { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WPML's public API filter. $wpml = apply_filters( 'wpml_default_language', null ); if ( is_string( $wpml ) && '' !== $wpml ) { return self::lang_code( $wpml ); } if ( function_exists( 'pll_default_language' ) ) { $pll = pll_default_language( 'slug' ); if ( is_string( $pll ) && '' !== $pll ) { return self::lang_code( $pll ); } } return self::lang_code( (string) get_locale() ); } /** * Λίστα φράσεων-γεμίσματος μιας γλώσσας από assets/data/fillers/.json * (δικές μας λίστες, GPL). null αν δεν υπάρχει αρχείο για τη γλώσσα — * τότε το JS ψάχνει ακριβώς όπως πριν. * * @param string $lang Κωδικός 2 γραμμάτων. * @return array|null array( 'lead' => [], 'after' => [], 'trail' => [] ) */ private static function load_fillers( $lang ) { static $cache = array(); $lang = self::lang_code( $lang ); if ( '' === $lang ) { return null; } if ( array_key_exists( $lang, $cache ) ) { return $cache[ $lang ]; } $data = null; $file = ADDSYNAPSE_SEARCH_PLUGIN_DIR . 'assets/data/fillers/' . $lang . '.json'; if ( is_readable( $file ) ) { $json = wp_json_file_decode( $file, array( 'associative' => true ) ); if ( is_array( $json ) ) { $data = array(); foreach ( array( 'lead', 'after', 'trail' ) as $key ) { $list = isset( $json[ $key ] ) && is_array( $json[ $key ] ) ? $json[ $key ] : array(); $data[ $key ] = array_values( array_filter( $list, 'is_string' ) ); } } } $cache[ $lang ] = $data; return $data; } /** * Οι λίστες που φτάνουν στον browser: ΜΟΝΟ της γλώσσας της σελίδας και * (αν διαφέρει) της φωνητικής αναγνώρισης — όχι όλες οι γλώσσες. * * @param string $query_lang Γλώσσα των πληκτρολογημένων. * @param string $voice_lang Γλώσσα αναγνώρισης ομιλίας (BCP 47). * @return array lang => lists. */ public static function fillers_config( $query_lang, $voice_lang ) { $out = array(); foreach ( array_unique( array( self::lang_code( $query_lang ), self::lang_code( $voice_lang ) ) ) as $lang ) { $data = self::load_fillers( $lang ); if ( null !== $data ) { $out[ $lang ] = $data; } } /** * Filter: οι φράσεις-γέμισμα που αφαιρούνται από την αρχή/το τέλος της * αναζήτησης, ανά γλώσσα (π.χ. πρόσθεσε «έχετε σε απόθεμα»). * * @param array $out lang => array( 'lead' => [], 'after' => [], 'trail' => [] ). * @param string $query_lang Κωδικός γλώσσας της σελίδας. */ $out = apply_filters( 'addsynapse_search_query_fillers', $out, $query_lang ); return is_array( $out ) ? $out : array(); } /** * Γλώσσα αναγνώρισης ομιλίας (BCP 47) από τη γλώσσα του site: * «el» → «el-GR», «en_US» → «en-US», «de_DE_formal» → «de-DE». * Γλώσσα χωρίς περιοχή μένει σκέτη, εκτός από λίγες γνωστές όπου ο * browser αναγνωρίζει καλύτερα με περιοχή. * * @return string */ private static function speech_lang() { $locale = self::page_locale(); $parts = explode( '_', str_replace( '-', '_', (string) $locale ) ); $lang = strtolower( preg_replace( '/[^A-Za-z]/', '', $parts[0] ) ); if ( '' === $lang ) { return 'en-US'; } $region = isset( $parts[1] ) ? strtoupper( preg_replace( '/[^A-Za-z0-9]/', '', $parts[1] ) ) : ''; if ( '' === $region ) { $known = array( 'el' => 'GR', 'en' => 'US', 'ja' => 'JP', 'ko' => 'KR', 'he' => 'IL', 'cs' => 'CZ', 'da' => 'DK', 'sv' => 'SE', ); $region = isset( $known[ $lang ] ) ? $known[ $lang ] : ''; } return '' === $region ? $lang : $lang . '-' . $region; } /** * Registers + enqueues the frontend assets, only when the license is * active. Also used by Addsynapse_Search_Shortcode / Addsynapse_Search_Block to make sure the * assets are present even if wp_enqueue_scripts already ran (e.g. a * shortcode used inside a widget rendered late). */ public static function maybe_enqueue() { if ( ! self::is_licensed() ) { return; } self::register_assets(); self::enqueue_style_if_enabled(); wp_enqueue_script( 'addsynapse-search' ); } /** * Enqueues the plugin stylesheet UNLESS the "Load plugin CSS" setting is * off (§2 της ανάθεσης: ο ιδιοκτήτης θέλει να στυλίσει ΟΛΟΚΛΗΡΟ το widget * μόνος του, βλ. docs/STYLING.md). ΚΟΙΝΟ σημείο για ΟΛΕΣ τις διαδρομές * ενεργοποίησης του widget (auto/manual attach ΕΔΩ, shortcode/block στα * δικά τους αρχεία) -- πριν ζούσε μόνο εδώ, οπότε το shortcode/block * αγνοούσαν εντελώς τη ρύθμιση και φόρτωναν το CSS ούτως ή άλλως. * * @return void */ public static function enqueue_style_if_enabled() { if ( (bool) Addsynapse_Search_Settings::get( 'load_css', true ) ) { wp_enqueue_style( 'addsynapse-search' ); } } /** * Registers (without enqueuing) the frontend assets so other classes * (shortcode/block) can call wp_enqueue_* safely even if this ran first. */ public static function register_assets() { if ( wp_script_is( 'addsynapse-search', 'registered' ) ) { return; } $base = defined( 'ADDSYNAPSE_SEARCH_PLUGIN_URL' ) ? ADDSYNAPSE_SEARCH_PLUGIN_URL : plugin_dir_url( dirname( __FILE__ ) ); // ?ver= με mtime — βλ. addsynapse_search_asset_version() (cache 10 ετών). wp_register_style( 'addsynapse-search', $base . 'assets/css/addsynapse-search.css', array(), addsynapse_search_asset_version( 'assets/css/addsynapse-search.css' ) ); wp_register_script( 'addsynapse-search', $base . 'assets/js/addsynapse-search.js', array(), addsynapse_search_asset_version( 'assets/js/addsynapse-search.js' ), true ); if ( ! wp_script_is( 'addsynapse-search', 'done' ) ) { wp_localize_script( 'addsynapse-search', 'addsynapseSearchConfig', self::get_config() ); } } /** * True when a subclass/other module has declared the license active. * Defensive: Addsynapse_Search_License is written by another agent in parallel, so we * never fatal if it is briefly missing. */ protected static function is_licensed() { return class_exists( 'Addsynapse_Search_License' ) && Addsynapse_Search_License::is_active(); } /** * Builds the array passed to the browser via wp_localize_script. * Everything here is safe to expose publicly (no license key, no * Meilisearch master key — only the scoped, expiring tenant token). * * @return array */ public static function get_config() { $shop_id = ''; $token = ''; if ( class_exists( 'Addsynapse_Search_License' ) ) { $shop_id = (string) Addsynapse_Search_License::shop_id(); $token = (string) Addsynapse_Search_License::tenant_token(); } $token_expires = '' !== $token ? self::token_expires( $token ) : 0; // Τρόπος αναζήτησης (0.6.0, βλ. Addsynapse_Search_Settings::defaults()). // Το JS συνεχίζει να διαβάζει attachMode (auto|manual|off — και τρίτος // κώδικας μέσω του φίλτρου config): 'ours' → auto ή manual(selector), // 'theme'/'manual' → off (μόνο block/shortcode· ΚΑΜΙΑ αναχαίτιση). $search_mode = (string) Addsynapse_Search_Settings::get( 'search_mode', 'ours' ); $attach_selector = trim( (string) Addsynapse_Search_Settings::get( 'attach_selector', '' ) ); if ( 'ours' === $search_mode ) { $attach_mode = '' !== $attach_selector ? 'manual' : 'auto'; } else { $attach_mode = 'off'; $attach_selector = ''; } $show_history = (bool) Addsynapse_Search_Settings::get( 'show_history', true ); $show_popular = (bool) Addsynapse_Search_Settings::get( 'show_popular', true ); $send_stats = (bool) Addsynapse_Search_Settings::get( 'send_search_stats', true ); $oos_behavior = Addsynapse_Search_Settings::get( 'oos_behavior', 'sink' ); // Μία γενική υπόδειξη (καμία εναλλαγή): ρύθμιση ιδιοκτήτη ή «Search products…». $placeholder = Addsynapse_Search_Settings::search_hint(); $popular_terms = Addsynapse_Search_Settings::get( 'popular_terms', array() ); if ( ! is_array( $popular_terms ) ) { $popular_terms = array(); } $popular_terms = array_values( array_map( 'strval', $popular_terms ) ); $currency = 'EUR'; $currency_symbol = '€'; if ( function_exists( 'get_woocommerce_currency' ) ) { $currency = get_woocommerce_currency(); } if ( function_exists( 'get_woocommerce_currency_symbol' ) ) { // Το WooCommerce δίνει HTML entities («€») — το JS γράφει με // textContent, οπότε θέλει τον ίδιο τον χαρακτήρα. $currency_symbol = html_entity_decode( get_woocommerce_currency_symbol( $currency ), ENT_QUOTES, 'UTF-8' ); } // Μορφή τιμής ΟΠΩΣ τη ρύθμισε το κατάστημα (WooCommerce → Γενικά). // Χωρίς αυτό, κατάστημα σε $ ή με 0 δεκαδικά έβλεπε «12,00 €» στα αποτελέσματα. $currency_pos = (string) get_option( 'woocommerce_currency_pos', 'right_space' ); $price_format = array( 'decimals' => function_exists( 'wc_get_price_decimals' ) ? (int) wc_get_price_decimals() : 2, 'decimalSep' => function_exists( 'wc_get_price_decimal_separator' ) ? (string) wc_get_price_decimal_separator() : ',', 'thousandSep' => function_exists( 'wc_get_price_thousand_separator' ) ? (string) wc_get_price_thousand_separator() : '.', 'symbol' => sanitize_text_field( $currency_symbol ), 'position' => in_array( $currency_pos, array( 'left', 'right', 'left_space', 'right_space' ), true ) ? $currency_pos : 'right_space', ); $mobile_breakpoint = (int) Addsynapse_Search_Settings::get( 'mobile_breakpoint', 782 ); // 🔴 Ρυθμίσεις εμφάνισης (§3 της ανάθεσης) — καθαρά frontend // συμπεριφορά, ΔΕΝ χρειάζονται server-side εφαρμογή (ίδιο σκεπτικό // με search_mode/oos_behavior πιο πάνω). $show_categories = (bool) Addsynapse_Search_Settings::get( 'show_categories', true ); $show_brands = (bool) Addsynapse_Search_Settings::get( 'show_brands', true ); $show_price = (bool) Addsynapse_Search_Settings::get( 'show_price', true ); $show_image = (bool) Addsynapse_Search_Settings::get( 'show_image', true ); $show_stock = (bool) Addsynapse_Search_Settings::get( 'show_stock', true ); $results_count = (int) Addsynapse_Search_Settings::get( 'results_count', 8 ); if ( $results_count < 1 || $results_count > 24 ) { $results_count = 8; } // Simple (dropdown) ή Advanced (πάνελ με sidebar + πλέγμα) — βλ. // Addsynapse_Search_Settings::defaults(). ΟΛΑ τα πακέτα, κανένα gating. $search_layout = 'advanced' === Addsynapse_Search_Settings::get( 'search_layout', 'simple' ) ? 'advanced' : 'simple'; // Εμφάνιση (θέμα | AddSynapse) + προεπισκόπηση `?adsy_preview_look=` (μόνο διαχειριστές). $look_config = class_exists( 'Addsynapse_Search_Look' ) ? Addsynapse_Search_Look::js_config() : array( 'look' => 'addsynapse', 'preview' => null, 'probe' => null ); $preview_layout = class_exists( 'Addsynapse_Search_Look' ) ? Addsynapse_Search_Look::preview_layout() : null; if ( null !== $preview_layout ) { $search_layout = $preview_layout; } $facets_position = 'right' === Addsynapse_Search_Settings::get( 'search_facets_position', 'left' ) ? 'right' : 'left'; // 🔴 Κατώφλι σχετικότητας Meilisearch — ΠΡΕΠΕΙ να ταξιδεύει σε ΚΑΘΕ // search request (δεν είναι index setting, δεν το επιβάλλει ο // server). Βλ. backend/config/meilisearch.php και // class-addsynapse-search-license.php::activate() για το πλήρες σκεπτικό. $ranking_score_threshold = (float) Addsynapse_Search_Settings::get( 'ranking_score_threshold', 0.6 ); // 🔴 ΣΗΜΕΙΩΣΗ: ούτε το Addsynapse_Search_License::tenant_token() ούτε το Addsynapse_Search_Settings // ορίζουν ακόμα ρητά το public Meilisearch host / analytics host — το // token API (/v1/auth/token) επιστρέφει μόνο token/ttl/plan (βλ. // Addsynapse_Search_Api_Client::token()). Τα defaults εδώ είναι το καλύτερο runtime // fallback ώστε να δουλεύει η αναζήτηση αμέσως μόλις οριστεί ρύθμιση // 'meilisearch_host' / 'analytics_endpoint' — ενημέρωσε τα αν το // backend προβλέπει άλλο hostname. $voice_lang = self::speech_lang(); $query_lang = self::lang_code( self::page_locale() ); $config = array( 'endpoint' => esc_url_raw( Addsynapse_Search_Settings::get( 'meilisearch_host', Addsynapse_Search_Settings::DEFAULT_SEARCH_HOST ) ), // Με τα στατιστικά κλειστά ΔΕΝ δίνουμε καν endpoint στον browser. 'analyticsEndpoint' => $send_stats ? esc_url_raw( Addsynapse_Search_Settings::get( 'analytics_endpoint', Addsynapse_Search_Settings::DEFAULT_API_BASE . '/v1/track' ) ) : '', 'sendStats' => $send_stats, 'token' => sanitize_text_field( $token ), 'tokenExpires' => $token_expires, // Ανανέωση token όταν η σελίδα ήρθε από cache (βλ. ajax_token()). 'tokenUrl' => esc_url_raw( admin_url( 'admin-ajax.php' ) ), 'index' => self::index_uid(), 'shopId' => sanitize_text_field( $shop_id ), 'currency' => sanitize_text_field( $currency ), 'currencySymbol' => sanitize_text_field( $currency_symbol ), 'priceFormat' => $price_format, 'attachMode' => sanitize_text_field( $attach_mode ), 'attachSelector' => sanitize_text_field( $attach_selector ), // ours|theme|manual — η αναχαίτιση του εικονιδίου αναζήτησης του // θέματος γίνεται ΜΟΝΟ στο 'ours'. 'searchMode' => in_array( $search_mode, array( 'ours', 'theme', 'manual' ), true ) ? $search_mode : 'ours', // Επιπλέον selector εικονιδίου/κουμπιού για άγνωστο θέμα (βλ. THEME_TRIGGERS στο JS). 'triggerSelector' => 'ours' === $search_mode ? sanitize_text_field( (string) Addsynapse_Search_Settings::get( 'search_trigger_selector', '' ) ) : '', 'showHistory' => $show_history, 'showPopular' => $show_popular, 'oosBehavior' => ( 'hide' === $oos_behavior ) ? 'hide' : 'sink', 'popularTerms' => $popular_terms, 'searchPageUrl' => esc_url_raw( home_url( '/' ) ), 'mobileBreakpoint' => $mobile_breakpoint, 'historyKey' => 'addsynapse_search_search_history_' . sanitize_key( $shop_id ), 'historyMax' => 8, 'debounceMs' => 150, 'minChars' => 2, 'rankingScoreThreshold' => $ranking_score_threshold, // --- Ρυθμίσεις εμφάνισης (§3) --- 'showCategories' => $show_categories, 'showBrands' => $show_brands, 'showPrice' => $show_price, 'showImage' => $show_image, 'showStock' => $show_stock, 'resultsCount' => $results_count, 'searchLayout' => $search_layout, // 'theme' = χρώματα/γραμματοσειρά του θέματος (κλάση html.wosearch-look-theme)· // 'addsynapse' = η δική μας παλέτα. 'look' => $look_config['look'], // Προεπισκόπηση της κάρτας πρώτης χρήσης: {query} — το JS «πληκτρολογεί» τον όρο. 'preview' => $look_config['preview'], // Μονός έλεγχος «υπάρχει πεδίο αναζήτησης;» (μόνο διαχειριστής, nonce) ή null. 'healthProbe' => $look_config['probe'], 'facetsPosition' => $facets_position, // Ο CSS selector της λίστας προϊόντων του θέματος (βλ. // addsynapse-search-filters.js: υιοθέτηση λίστας). 'filtersTarget' => (string) Addsynapse_Search_Settings::get( 'filters_target', Addsynapse_Search_Settings::FILTERS_TARGET_DEFAULT ), // 'ajax' | 'reload' — η καθολική ρύθμιση· attribute `ajax` σε // shortcode/block/widget την ξεπερνά (βλ. Addsynapse_Search_Filters::ajax()). 'filtersMode' => 'reload' === Addsynapse_Search_Settings::get( 'filters_mode', 'ajax' ) ? 'reload' : 'ajax', // Φωνητική αναζήτηση: το JS δείχνει μικρόφωνο ΜΟΝΟ αν υπάρχει // SpeechRecognition ΚΑΙ η σελίδα είναι https (βλ. Voice στο JS). 'voiceSearch' => (bool) Addsynapse_Search_Settings::get( 'voice_search', false ), 'voiceLang' => $voice_lang, // Σημασιολογική αναζήτηση (beta, 0.7.0): {embedder, ratio, threshold, limit} // ΜΟΝΟ όταν η ρύθμιση είναι ανοιχτή ΚΑΙ ο server είπε ready — αλλιώς null // (το JS τότε δεν κάνει ΚΑΝΕΝΑ επιπλέον αίτημα). 'semantic' => Addsynapse_Search_Settings::semantic_runtime(), // Μερική κάλυψη καταλόγου (0.8.0): μετά τα δικά μας αποτελέσματα, έως `nativeMax` // προϊόντα της κανονικής αναζήτησης του WordPress που δεν είναι στη λίστα // (admin-ajax `addsynapse_search_native`). null = πλήρης κάλυψη → κανένα επιπλέον αίτημα. 'coverage' => self::coverage_config(), // Καθαρισμός φράσεων-γεμίσματος («ψάχνω για …») πριν την αναζήτηση. 'queryLang' => $query_lang, 'fillers' => self::fillers_config( $query_lang, $voice_lang ), 'i18n' => array( 'placeholder' => $placeholder, 'noResults' => __( 'No results found', 'addsynapse-search-filter' ), 'noResultsHint' => __( 'Try different search terms.', 'addsynapse-search-filter' ), 'relatedResults' => __( 'Related results', 'addsynapse-search-filter' ), 'outOfStock' => __( 'Out of stock', 'addsynapse-search-filter' ), 'historyTitle' => __( 'Recent searches', 'addsynapse-search-filter' ), 'clearAll' => __( 'Clear all', 'addsynapse-search-filter' ), 'removeTerm' => __( 'Remove term', 'addsynapse-search-filter' ), 'popularTitle' => __( 'Popular searches', 'addsynapse-search-filter' ), 'viewAll' => __( 'View all results', 'addsynapse-search-filter' ), 'searching' => __( 'Searching…', 'addsynapse-search-filter' ), 'categories' => __( 'Categories', 'addsynapse-search-filter' ), 'brands' => __( 'Brands', 'addsynapse-search-filter' ), 'close' => __( 'Close', 'addsynapse-search-filter' ), 'clearInput' => __( 'Clear', 'addsynapse-search-filter' ), 'resultsFound' => __( 'results', 'addsynapse-search-filter' ), // Advanced πάνελ αναζήτησης. 'searchDialog' => __( 'Product search', 'addsynapse-search-filter' ), 'browseTerms' => __( 'Categories and brands in these results', 'addsynapse-search-filter' ), 'searchResults' => __( 'Search results', 'addsynapse-search-filter' ), /* translators: 1: number of results, 2: the search term. */ 'resultsFor' => __( '%1$s results for “%2$s”', 'addsynapse-search-filter' ), /* translators: 1: number of results. */ 'viewAllCount' => __( 'View all %1$s results', 'addsynapse-search-filter' ), 'sale' => __( 'Sale', 'addsynapse-search-filter' ), // Φίλτρα προϊόντων (addsynapse-search-filters.js). 'product' => __( 'product', 'addsynapse-search-filter' ), 'products' => __( 'products', 'addsynapse-search-filter' ), 'clearFilters' => __( 'Clear filters', 'addsynapse-search-filter' ), 'clearAllLabel' => __( 'Clear all filters', 'addsynapse-search-filter' ), 'clearGroup' => __( 'Clear', 'addsynapse-search-filter' ), /* translators: %s: filter name, e.g. Brand. */ 'clearGroupLabel' => __( 'Clear %s filter', 'addsynapse-search-filter' ), 'activeFilters' => __( 'Active filters', 'addsynapse-search-filter' ), /* translators: %s: the filter value, e.g. a brand name. */ 'removeFilter' => __( 'Remove filter: %s', 'addsynapse-search-filter' ), 'brand' => __( 'Brand', 'addsynapse-search-filter' ), 'price' => __( 'Price', 'addsynapse-search-filter' ), 'all' => __( 'All', 'addsynapse-search-filter' ), 'min' => __( 'Min', 'addsynapse-search-filter' ), 'max' => __( 'Max', 'addsynapse-search-filter' ), 'go' => __( 'OK', 'addsynapse-search-filter' ), 'findValue' => __( 'Search…', 'addsynapse-search-filter' ), 'noMatch' => __( 'Nothing matches.', 'addsynapse-search-filter' ), 'showAll' => __( 'Show all', 'addsynapse-search-filter' ), 'showLess' => __( 'Show less', 'addsynapse-search-filter' ), /* translators: %s: category name. Label of the button that shows/hides its subcategories. */ 'subcats' => __( 'Subcategories of %s', 'addsynapse-search-filter' ), 'loadMore' => __( 'Load more', 'addsynapse-search-filter' ), 'loadError' => __( 'Products could not be loaded right now.', 'addsynapse-search-filter' ), 'tooMany' => __( 'Too many requests. Please wait a moment and try again.', 'addsynapse-search-filter' ), 'retry' => __( 'Try again', 'addsynapse-search-filter' ), // Φωνητική αναζήτηση. 'voiceStart' => __( 'Search by voice', 'addsynapse-search-filter' ), 'voiceStop' => __( 'Stop voice search', 'addsynapse-search-filter' ), 'voiceListening' => __( 'Listening…', 'addsynapse-search-filter' ), 'voiceStopped' => __( 'Voice search stopped.', 'addsynapse-search-filter' ), 'voiceDenied' => __( 'Microphone access is blocked. Allow it in your browser settings to search by voice.', 'addsynapse-search-filter' ), 'voiceNoSpeech' => __( 'Didn’t catch that. Tap the microphone and try again.', 'addsynapse-search-filter' ), 'voiceNoMic' => __( 'No microphone was found.', 'addsynapse-search-filter' ), 'voiceNetwork' => __( 'Voice search needs an internet connection.', 'addsynapse-search-filter' ), 'voiceError' => __( 'Voice search is not available right now.', 'addsynapse-search-filter' ), ), ); /** * 🔴 ΣΗΜΕΙΟ ΕΠΕΚΤΑΣΗΣ (docs/DEVELOPERS.md): filter `addsynapse_search_search_config`. * Ό,τι επιστρέψεις εδώ φεύγει ΑΥΤΟΥΣΙΟ στον browser μέσω * wp_localize_script (window.addsynapseSearchConfig) — ΠΟΤΕ μην προσθέσεις * κάτι ευαίσθητο (το license key/master key ΔΕΝ περνάνε ποτέ από * εδώ, βλ. docblock της κλάσης). * * @param array $config Η ρύθμιση που θα δει το JS. */ return apply_filters( 'addsynapse_search_search_config', $config ); } }