'', 'shop_id' => '', 'index_uid' => '', // το δίνει ο server στην ενεργοποίηση (βλ. Addsynapse_Search_Frontend::index_uid()) 'api_base' => self::DEFAULT_API_BASE, // Τρόπος αναζήτησης (0.6.0) — τον διαλέγει ο ιδιοκτήτης: // 'ours' = το δικό μας πάνελ στο πεδίο ΚΑΙ στο εικονίδιο αναζήτησης // του θέματος· το πάνελ του θέματος δεν ανοίγει ποτέ // (αναχαίτιση trigger, βλ. THEME_TRIGGERS στο JS), // 'theme' = UI/αποτελέσματα του θέματος ως έχουν, αλλά τα // αποτελέσματα από τη μηχανή μας (server-side, βλ. // Addsynapse_Search_Theme_Search), // 'manual' = μόνο block/shortcode, τίποτα αυτόματο. // Αντικαθιστά το `attach_mode` (auto|manual|off) — μετάπτωση στο all(). 'search_mode' => 'ours', // Μόνο στο 'ours': CSS selector πεδίου. Κενό = αυτόματη ανίχνευση· // συμπληρωμένο = προσάρτηση ΜΟΝΟ εκεί (το παλιό attach_mode=manual). 'attach_selector' => '', // Μόνο στο 'ours': επιπλέον CSS selector για το εικονίδιο/κουμπί // αναζήτησης ενός άγνωστου θέματος (προστίθεται στον πίνακα γνωστών). 'search_trigger_selector' => '', 'synonyms' => '', 'placeholders' => '', 'show_history' => true, 'show_popular' => true, // Ανώνυμα στατιστικά αναζήτησης (όρος, πλήθος αποτελεσμάτων, id // προϊόντος που πατήθηκε) προς τον server μας. // 🔴 OPT-IN από 0.3.4 (audit F08): false για ΝΕΕΣ εγκαταστάσεις — // ενεργοποιείται ΜΟΝΟ με ρητό τικ (Ρυθμίσεις). // Υπάρχουσες εγκαταστάσεις κρατούν ό,τι έχουν ήδη αποθηκευμένο // (το option γράφεται ΟΛΟΚΛΗΡΟ σε κάθε set(), άρα το κλειδί // υπάρχει ήδη σε όποιον έσωσε έστω μία φορά). Κανένα save/ // activation δεν το ξανανάβει μόνο του. 'send_search_stats' => false, 'oos_behavior' => 'sink', // sink|hide 'index_description' => true, 'popular_terms' => array(), 'mobile_breakpoint' => 768, 'meilisearch_host' => '', // το δίνει ο server στην ενεργοποίηση άδειας 'analytics_endpoint' => '', // το δίνει ο server στην ενεργοποίηση άδειας // Κατώφλι σχετικότητας Meilisearch (0.0–1.0) — ο server το δίνει // στην ενεργοποίηση άδειας (όπως meilisearch_host/analytics_endpoint), // αλλά χρειάζεται λογικό default ΚΑΙ πριν ενεργοποιηθεί άδεια. // Βλ. backend/config/meilisearch.php για το γιατί 0.6 (chronoro: // «λάμπα» -> 387 άσχετα αποτελέσματα χωρίς αυτό το κατώφλι). 'ranking_score_threshold' => 0.6, // 🔴 "Ακριβής αναζήτηση παραλλαγών" -- read-only για το plugin. // Ο server το ελέγχει (εσωτερικό admin, ανά κατάστημα) και το // plugin το ΜΑΘΑΙΝΕΙ από τις απαντήσεις activate/stats (βλ. // Addsynapse_Search_License::activate(), Addsynapse_Search_Analytics::sync_variation_indexing_flag()). // default false ΕΔΩ ΚΑΙ πριν ενεργοποιηθεί άδεια -- η δεικτοδότηση // παραμένει ΑΚΡΙΒΩΣ όπως σήμερα (ένα έγγραφο/προϊόν) μέχρι ο // server να πει διαφορετικά. 'variation_indexing' => false, // --- Ρυθμίσεις εμφάνισης (§3/§4 της ανάθεσης) --- 'show_categories' => true, 'show_brands' => true, 'show_price' => true, 'show_image' => true, 'show_stock' => true, // Πλήθος αποτελεσμάτων στο dropdown (1–24, βλ. sanitize_pair()). 'results_count' => 8, // Τρόπος εμφάνισης αναζήτησης: 'simple' (το συμπαγές dropdown // κάτω από το πεδίο) ή 'advanced' (μεγάλο πάνελ-διάλογος: sidebar // κατηγοριών/μαρκών με πλήθη + πλέγμα καρτών). 🔴 Από 0.4.1 η // προεπιλογή είναι 'advanced' ΜΟΝΟ για νέες εγκαταστάσεις: ένα // αποθηκευμένο option χωρίς το κλειδί είναι παλιό (≤0.4.0) και // παίρνει ρητά 'simple' — βλ. all() και maybe_upgrade(). Κάτω από το `mobile_breakpoint` το advanced // πέφτει ΑΥΤΟΜΑΤΑ σε simple (βλ. addsynapse-search.js useAdvanced()). // Αντικαθιστά το παλιό `layout` (list|grid) — μετάπτωση στο all(). // Διαθέσιμο σε ΟΛΑ τα πακέτα (κανόνας 5 wp.org — κανένα gating). // 🔴 «theme look» 17/10: 'simple' για ΝΕΕΣ εγκαταστάσεις (μαζί με search_look=theme // = «κράτα την εμφάνιση του θέματος»). Υπάρχουσες κρατούν ό,τι έχουν // αποθηκευμένο (το κλειδί υπάρχει σε κάθε option που γράφτηκε από set()). 'search_layout' => 'simple', // Εμφάνιση («theme look» 17/10): 'theme' = χρώματα/γραμματοσειρά από το θέμα // (theme.json presets ή ουδέτερα, κανένα επιβεβλημένο μπλε, καμία // system font) — 'addsynapse' = η δική μας παλέτα (μπλε, system font, // σκοτεινό θέμα κατά το OS). 🔴 Αποθηκευμένο option ΧΩΡΙΣ το κλειδί = // εγκατάσταση πριν το «theme look» 17/10 → 'addsynapse' (pin_legacy_layout()). 'search_look' => 'theme', // Πλευρά του sidebar φίλτρων στο advanced: 'left' | 'right'. 'search_facets_position' => 'left', // Προεπιλεγμένο πλήθος τιμών ανά φίλτρο — κάθε widget το ξεπερνά. 'filters_max_values' => 12, // 🔴 ΠΟΥ γράφει το φιλτραρισμένο αποτέλεσμα όταν η σελίδα δεν έχει // δικό μας [addsynapse_search_products]: η λίστα προϊόντων του θέματος. Χωρίς // αυτό, τα widgets φίλτρων δεν φιλτράρουν τίποτα ορατό. // 🔴 ΚΑΙ τα δύο πλέγματα: `ul.products` (κλασικό θέμα) και // `ul.wc-block-product-template` (Product Collection σε block θέμα, // π.χ. Twenty Twenty-Five). Με μόνο το πρώτο, σε block θέμα τα // φίλτρα δεν έβρισκαν πού να γράψουν — καμία ένδειξη. 'filters_target' => self::FILTERS_TARGET_DEFAULT, // Πώς εφαρμόζονται τα φίλτρα: 'ajax' (άμεσα, χωρίς ανανέωση) ή // 'reload' (πλοήγηση στο URL με τα wf_*). Αντικαθιστά το παλιό // `filters_ajax` (1/0) — βλ. all() για τη μετάπτωση. 'filters_mode' => 'ajax', // Off: ο ιδιοκτήτης θέλει να στυλίσει ΟΛΟΚΛΗΡΟ το widget μόνος // του (βλ. docs/STYLING.md) -- το plugin CSS δεν φορτώνει καθόλου. 'load_css' => true, // Φωνητική αναζήτηση (Web Speech API του browser). 🔴 OFF by // default: σε Chrome/Edge ο browser στέλνει τον ήχο στην υπηρεσία // ομιλίας του κατασκευαστή του (Google/Microsoft) — ο ιδιοκτήτης // την ανάβει συνειδητά (βλ. readme «External services»). 'voice_search' => false, // Σημασιολογική αναζήτηση (beta, 0.7.0). 🔴 OFF by default, opt-in: // ΜΟΝΟ fallback όταν η κανονική αναζήτηση (και το Rescue) βρει 0 — // αποτελέσματα με ετικέτα «Related results». Ο server (POST /v1/settings) // ρυθμίζει τον embedder του δείκτη· η κατάσταση (έτοιμο/ποσοστό) ζει στο // option SEMANTIC_STATE_OPTION, ΟΧΙ εδώ. ΔΕΝ το λέμε «AI» (CLAUDE.md). 'semantic_search' => false, ); } /** * Option με την τελευταία κατάσταση σημασιολογικής αναζήτησης όπως τη * δίνει ο server (μπλοκ `semantic` στα /v1/settings, /v1/stats, /v1/auth/token). */ const SEMANTIC_STATE_OPTION = 'addsynapse_search_semantic_state'; /** * Αποθηκεύει το μπλοκ `semantic` μιας απάντησης του server (αν υπάρχει). * Απόν μπλοκ = παλιό backend → καμία αλλαγή (δεν σβήνουμε γνωστή κατάσταση). * * @param mixed $response Αποκωδικοποιημένη απάντηση API. * @return bool true αν αποθηκεύτηκε. */ public static function store_semantic_state( $response ) { if ( ! is_array( $response ) || ! isset( $response['semantic'] ) || ! is_array( $response['semantic'] ) ) { return false; } $b = $response['semantic']; $statuses = array( 'off', 'pending', 'indexing', 'ready', 'degraded', 'unavailable' ); $state = array( 'enabled' => ! empty( $b['enabled'] ), 'status' => ( isset( $b['status'] ) && in_array( $b['status'], $statuses, true ) ) ? $b['status'] : 'off', 'ready' => isset( $b['ready'] ) && true === $b['ready'], 'progress' => isset( $b['progress'] ) ? max( 0, min( 100, (int) $b['progress'] ) ) : 0, 'embedded' => isset( $b['embedded'] ) ? max( 0, (int) $b['embedded'] ) : 0, 'documents' => isset( $b['documents'] ) ? max( 0, (int) $b['documents'] ) : 0, 'model' => isset( $b['model'] ) && is_string( $b['model'] ) ? sanitize_text_field( $b['model'] ) : '', 'embedder' => isset( $b['embedder'] ) && is_string( $b['embedder'] ) && preg_match( '/^[A-Za-z0-9_-]{1,64}$/', $b['embedder'] ) ? $b['embedder'] : '', 'semantic_ratio' => isset( $b['semantic_ratio'] ) && is_numeric( $b['semantic_ratio'] ) ? max( 0.0, min( 1.0, (float) $b['semantic_ratio'] ) ) : 1.0, 'threshold' => isset( $b['threshold'] ) && is_numeric( $b['threshold'] ) ? max( 0.0, min( 1.0, (float) $b['threshold'] ) ) : 0.0, // 0.7.1 — διαγλωσσικό ζεύγος + σχετική αποκοπή (docs/SEMANTIC.md § Βαθμονόμηση). // Απόντα (backend < 0.7.1) → null/0 = ακριβώς η συμπεριφορά του 0.7.0. 'threshold_cross' => isset( $b['threshold_cross'] ) && is_numeric( $b['threshold_cross'] ) ? max( 0.0, min( 1.0, (float) $b['threshold_cross'] ) ) : null, 'delta' => isset( $b['delta'] ) && is_numeric( $b['delta'] ) ? max( 0.0, min( 1.0, (float) $b['delta'] ) ) : 0.0, 'delta_cross' => isset( $b['delta_cross'] ) && is_numeric( $b['delta_cross'] ) ? max( 0.0, min( 1.0, (float) $b['delta_cross'] ) ) : 0.0, 'limit' => isset( $b['limit'] ) ? max( 1, min( 24, (int) $b['limit'] ) ) : 6, // 0.8.0 — διαθέσιμο ΜΟΝΟ σε Pro/Business (και στη δοκιμή Pro). Απόν (κόμβος < 0.8.0) // = null = άγνωστο → συμπεριφορά 0.7.x (επιτρέπεται, το status λέει την αλήθεια). 'available' => array_key_exists( 'available', $b ) ? ( true === $b['available'] ) : null, 'reason' => isset( $b['reason'] ) && is_string( $b['reason'] ) && preg_match( '/^[a-z_]{1,32}$/', $b['reason'] ) ? $b['reason'] : '', 'plans' => isset( $b['plans'] ) && is_array( $b['plans'] ) ? array_values( array_filter( array_map( 'sanitize_key', array_slice( array_filter( $b['plans'], 'is_string' ), 0, 8 ) ) ) ) : array(), 'fetched_at' => time(), ); update_option( self::SEMANTIC_STATE_OPTION, $state, false ); return true; } /** * Τελευταία γνωστή κατάσταση (ή κενός πίνακας). * * @return array */ public static function semantic_state() { $state = get_option( self::SEMANTIC_STATE_OPTION, array() ); return is_array( $state ) ? $state : array(); } /** * Το πακέτο περιλαμβάνει σημασιολογική αναζήτηση; (0.8.0: Pro/Business και η δοκιμή Pro.) * Μόνο ρητό `available: false` του κόμβου το κλείνει — άγνωστο (παλιός κόμβος) = ναι. * * @return bool */ public static function semantic_available() { $state = self::semantic_state(); return ! ( array_key_exists( 'available', $state ) && false === $state['available'] ); } /** * Ρυθμίσεις του fallback για το JS/θέμα — null εκτός αν η ρύθμιση είναι * ανοιχτή ΚΑΙ ο server είπε ready === true (με έγκυρο embedder). * * @return array|null { embedder, ratio, threshold, limit, thresholdCross, delta, deltaCross, catalogLang } */ public static function semantic_runtime() { if ( ! self::get( 'semantic_search', false ) ) { return null; } $state = self::semantic_state(); if ( ! self::semantic_available() ) { return null; // Το πακέτο δεν το περιλαμβάνει (0.8.0) — ούτε ένα hybrid αίτημα. } if ( empty( $state['ready'] ) || true !== $state['ready'] || empty( $state['embedder'] ) ) { return null; } $threshold = isset( $state['threshold'] ) ? (float) $state['threshold'] : 0.0; return array( 'embedder' => (string) $state['embedder'], 'ratio' => isset( $state['semantic_ratio'] ) ? (float) $state['semantic_ratio'] : 1.0, 'threshold' => $threshold, 'limit' => isset( $state['limit'] ) ? (int) $state['limit'] : 6, 'thresholdCross' => isset( $state['threshold_cross'] ) && null !== $state['threshold_cross'] ? (float) $state['threshold_cross'] : $threshold, 'delta' => isset( $state['delta'] ) ? (float) $state['delta'] : 0.0, 'deltaCross' => isset( $state['delta_cross'] ) ? (float) $state['delta_cross'] : 0.0, 'catalogLang' => self::catalog_lang(), ); } /** * Γλώσσα καταλόγου (2 γράμματα ή '') — η ίδια που στέλνεται στο backend. * * @return string */ private static function catalog_lang() { return class_exists( 'Addsynapse_Search_Frontend' ) ? Addsynapse_Search_Frontend::catalog_lang() : ''; } /** * Διαγλωσσικό ζεύγος του σημασιολογικού fallback (0.7.1): query με ελληνικά * γράμματα σε κατάλογο που ΔΕΝ είναι ελληνικός. Μετρημένο: εκεί τα σωστά * scores του e5 πέφτουν ~0,02 (0,90–0,92 αντί 0,92–0,96)· το αντίστροφο * (λατινικά σε ελληνικό κατάλογο) όχι. Ίδιος κανόνας με το JS (Semantic.isCross). * * @param string $query Το query. * @param string $catalog_lang Κωδικός γλώσσας καταλόγου. * @return bool */ public static function semantic_is_cross( $query, $catalog_lang ) { $lang = strtolower( (string) $catalog_lang ); if ( '' === $lang || 'el' === $lang ) { return false; } return 1 === preg_match( '/[\x{0370}-\x{03FF}\x{1F00}-\x{1FFF}]/u', (string) $query ); } /** * Κατώφλι + Δ για ένα query (ζεύγος ίδιας γλώσσας ή διαγλωσσικό). * * @param array $runtime semantic_runtime(). * @param string $query Το query. * @return array { threshold: float, delta: float, cross: bool } */ public static function semantic_cut( $runtime, $query ) { $cross = self::semantic_is_cross( $query, isset( $runtime['catalogLang'] ) ? $runtime['catalogLang'] : '' ); $t = (float) $runtime['threshold']; $d = isset( $runtime['delta'] ) ? (float) $runtime['delta'] : 0.0; if ( $cross ) { $t = isset( $runtime['thresholdCross'] ) ? (float) $runtime['thresholdCross'] : $t; $d = isset( $runtime['deltaCross'] ) ? (float) $runtime['deltaCross'] : 0.0; } return array( 'threshold' => $t, 'delta' => max( 0.0, $d ), 'cross' => $cross, ); } /** * Σχετική αποκοπή: κρατά hits με _rankingScore ≥ top1 − Δ (Δ 0 = όλα). * Hits χωρίς score μένουν (παλιό Meili/απάντηση χωρίς showRankingScore). * * @param array $hits Hits ταξινομημένα κατά score. * @param float $delta Δ. * @return array */ public static function semantic_relative_cut( $hits, $delta ) { if ( $delta <= 0 || empty( $hits ) || ! isset( $hits[0]['_rankingScore'] ) ) { return $hits; } $floor = (float) $hits[0]['_rankingScore'] - (float) $delta - 1e-9; $out = array(); foreach ( $hits as $hit ) { if ( ! isset( $hit['_rankingScore'] ) || (float) $hit['_rankingScore'] >= $floor ) { $out[] = $hit; } } return $out; } /** * Όλες οι ρυθμίσεις (αποθηκευμένες + defaults). * * @return array */ public static function all() { if ( null === self::$cache ) { $stored = get_option( self::OPTION_KEY, false ); if ( ! is_array( $stored ) ) { $stored = array(); } // Μετάπτωση από το παλιό `filters_ajax` (1/0) — όποιος είχε // επιλέξει ανανέωση σελίδας δεν πρέπει να γυρίσει σιωπηλά σε AJAX. if ( ! isset( $stored['filters_mode'] ) && isset( $stored['filters_ajax'] ) ) { $stored['filters_mode'] = $stored['filters_ajax'] ? 'ajax' : 'reload'; } unset( $stored['filters_ajax'] ); $stored = self::migrate_attach_mode( $stored ); // Μετάπτωση από το παλιό `layout` (list|grid): όποιος είχε το // πλούσιο «grid» πάνελ παίρνει τον διάδοχό του, το advanced. if ( ! isset( $stored['search_layout'] ) && isset( $stored['layout'] ) ) { $stored['search_layout'] = 'grid' === $stored['layout'] ? 'advanced' : 'simple'; } unset( $stored['layout'] ); // Αποθηκευμένο option χωρίς `search_layout` = εγκατάσταση πριν το // 0.4.1, όταν η προεπιλογή ήταν 'simple'. Χωρίς αυτό, το // wp_parse_args() θα την άλλαζε ΣΙΩΠΗΛΑ σε advanced. (Καλύπτει και // την ανάγνωση του παλιού option πριν τη μετάπτωση, και επαναφορά // παλιού backup μετά το maybe_upgrade().) $stored = self::pin_legacy_layout( $stored ); // Το σκέτο `ul.products` ήταν η ΠΑΛΙΑ προεπιλογή και γραφόταν σε // κάθε αποθήκευση — δεν είναι συνειδητή επιλογή. Η νέα είναι // υπερσύνολό της (ίδια συμπεριφορά σε κλασικό θέμα). if ( isset( $stored['filters_target'] ) && 'ul.products' === trim( (string) $stored['filters_target'] ) ) { unset( $stored['filters_target'] ); } self::$cache = wp_parse_args( $stored, self::defaults() ); } return self::$cache; } /** * Μετάπτωση `attach_mode` (≤0.5.x) → `search_mode` (0.6.0). Ακριβής χάρτης: * * auto → ours (+ attach_selector = '' — στο auto ένας * αποθηκευμένος selector ΑΓΝΟΟΥΝΤΑΝ· αν τον * κρατούσαμε, θα περιόριζε ξαφνικά την προσάρτηση) * manual + selector → ours (ο selector κρατιέται: προσάρτηση μόνο εκεί) * manual χωρίς selector → manual (δεν προσαρτούσε πουθενά — μόνο block/shortcode) * off → manual (ίδιο: μόνο block/shortcode) * * Ρητό `search_mode` κερδίζει πάντα· το `attach_mode` πετιέται σε κάθε * περίπτωση (γράφεται στη βάση με την επόμενη αποθήκευση). Δημόσια για tests. * * @param array $stored Αποθηκευμένες ρυθμίσεις. * @return array */ public static function migrate_attach_mode( $stored ) { if ( ! is_array( $stored ) ) { return array(); } if ( ! isset( $stored['search_mode'] ) && isset( $stored['attach_mode'] ) ) { $selector = isset( $stored['attach_selector'] ) && is_string( $stored['attach_selector'] ) ? trim( $stored['attach_selector'] ) : ''; switch ( $stored['attach_mode'] ) { case 'manual': $stored['search_mode'] = '' !== $selector ? 'ours' : 'manual'; break; case 'off': $stored['search_mode'] = 'manual'; break; default: $stored['search_mode'] = 'ours'; $stored['attach_selector'] = ''; } } unset( $stored['attach_mode'] ); return $stored; } /** * Option που γράφει ποια αναβάθμιση σχήματος ρυθμίσεων έχει τρέξει. */ const SCHEMA_OPTION = 'addsynapse_search_settings_schema'; /** * Τρέχον σχήμα: 1 = ≤0.4.0, 2 = 0.4.1 (προεπιλογή search_layout → advanced). * 🔴 «theme look» 17/10 (search_look) ΔΕΝ ανεβάζει σχήμα: οι αποθηκευμένες ρυθμίσεις δεν * ξαναγράφονται — το «παλιό option χωρίς search_look → addsynapse» γίνεται * μόνο κατά την ανάγνωση (all() → pin_legacy_layout()). */ const SCHEMA = 2; /** * Σε αποθηκευμένες ρυθμίσεις ΠΡΙΝ το 0.4.1 που δεν έχουν `search_layout`, * γράφει ρητά την τότε προεπιλογή ('simple', ή 'advanced' αν είχαν το παλιό * `layout` = grid). Κενός πίνακας = τίποτα αποθηκευμένο = νέα εγκατάσταση. * * @param array $stored Αποθηκευμένες ρυθμίσεις. * @return array */ public static function pin_legacy_layout( $stored ) { if ( ! is_array( $stored ) || empty( $stored ) ) { return is_array( $stored ) ? $stored : array(); } if ( ! isset( $stored['search_layout'] ) ) { $stored['search_layout'] = ( isset( $stored['layout'] ) && 'grid' === $stored['layout'] ) ? 'advanced' : 'simple'; } if ( ! isset( $stored['search_facets_position'] ) ) { $stored['search_facets_position'] = 'left'; } // «theme look» 17/10: όποιος είχε ήδη αποθηκευμένες ρυθμίσεις έβλεπε τη δική μας // παλέτα — δεν του αλλάζουμε σιωπηλά την εμφάνιση. if ( ! isset( $stored['search_look'] ) ) { $stored['search_look'] = 'addsynapse'; } return $stored; } /** * Αναβάθμιση σχήματος, μία φορά (plugins_loaded:6, μετά τη μετάπτωση). * Idempotent: σημάδι στο SCHEMA_OPTION. Γράφει στη βάση ΜΟΝΟ αν υπάρχει * ήδη αποθηκευμένο option που του λείπει κάτι — νέα εγκατάσταση δεν * αποκτά option από εδώ (παίρνει τις νέες προεπιλογές). * * @return void */ public static function maybe_upgrade() { if ( (int) get_option( self::SCHEMA_OPTION, 0 ) >= self::SCHEMA ) { return; } $stored = get_option( self::OPTION_KEY, false ); if ( is_array( $stored ) && ! empty( $stored ) ) { $pinned = self::pin_legacy_layout( $stored ); if ( $pinned !== $stored ) { update_option( self::OPTION_KEY, $pinned ); self::$cache = null; } } update_option( self::SCHEMA_OPTION, self::SCHEMA ); } /** * Διαβάζει μία ρύθμιση. * * @param string $key Το κλειδί. * @param mixed $default Τιμή αν δεν υπάρχει. * @return mixed */ public static function get( $key, $default = null ) { $all = self::all(); if ( array_key_exists( $key, $all ) ) { return $all[ $key ]; } return $default; } /** * Γράφει μία ρύθμιση και σώζει το option. * * @param string $key Το κλειδί. * @param mixed $value Η τιμή. * @return bool */ public static function set( $key, $value ) { $all = self::all(); $all[ $key ] = self::sanitize_pair( $key, $value ); self::$cache = $all; return update_option( self::OPTION_KEY, $all ); } /** * Γράφει πολλές ρυθμίσεις μαζί (μία μόνο εγγραφή στη βάση). * * @param array $values Ζεύγη key => value. * @return bool */ public static function set_many( array $values ) { $all = self::all(); foreach ( $values as $key => $value ) { $all[ $key ] = self::sanitize_pair( $key, $value ); } self::$cache = $all; return update_option( self::OPTION_KEY, $all ); } /** * Το κείμενο-υπόδειξη του πεδίου αναζήτησης («theme look» 17/10): η πρώτη μη κενή γραμμή * της ρύθμισης `placeholders`, αλλιώς μια γενική, μεταφρασμένη προεπιλογή — * «Search products…» με WooCommerce, «Search…» χωρίς. Καμία εναλλαγή/κίνηση. * * @return string */ public static function search_hint() { $raw = (string) self::get( 'placeholders', '' ); foreach ( preg_split( '/\r\n|\r|\n/', $raw ) as $line ) { $line = trim( sanitize_text_field( $line ) ); if ( '' !== $line ) { return $line; } } return self::default_search_hint(); } /** * Η γενική προεπιλογή υπόδειξης (χωρίς τη ρύθμιση του ιδιοκτήτη). * * @return string */ public static function default_search_hint() { return ( class_exists( 'WooCommerce' ) || function_exists( 'WC' ) ) ? __( 'Search products…', 'addsynapse-search-filter' ) : __( 'Search…', 'addsynapse-search-filter' ); } /** * Καθαρίζει το in-memory cache (χρήσιμο σε tests / μετά από άμεση αλλαγή του option). * * @return void */ public static function reset_cache() { self::$cache = null; } /** * 🔴 ALLOWLIST hosts υπηρεσίας (audit F07). * * Τα api_base / meilisearch_host / analytics_endpoint τα ΣΤΕΛΝΕΙ ο server * (activate / token renewal) και το plugin τα ακολουθεί — στο api_base * πηγαίνει το license key ως Bearer, στο meilisearch_host ο browser του * επισκέπτη. Μια αλλοιωμένη απάντηση δεν πρέπει να μπορεί να τα στείλει * σε αυθαίρετο προορισμό. Κανόνες: * - ΜΟΝΟ https, χωρίς userinfo (user:pass@), χωρίς IP literal, * - host == weborange.gr/addsynapse.com ή subdomain τους, port κενό ή 443 * (addsynapse.com από 0.4.0: ο hub θα δίνει *.addsynapse.com — ένα plugin * που το απορρίπτει κρατά σιωπηλά τον παλιό host, MIGRATION-ADDSYNAPSE §1), * - filter `addsynapse_search_allowed_hosts`: επιπλέον επιθήματα host * («example.com» = ο ίδιος + subdomains, port 443) ή ακριβές * «host:port» (π.χ. «api.example.test:8443» για τοπική ανάπτυξη), * - σε WP_ENVIRONMENT_TYPE local/development επιτρέπεται και *.ddev.site * σε οποιαδήποτε θύρα (δικό μας τοπικό backend). * * @param mixed $url Το URL. * @return bool */ public static function is_allowed_service_url( $url ) { if ( ! is_string( $url ) ) { return false; } $url = trim( $url ); if ( '' === $url || 0 !== strpos( strtolower( $url ), 'https://' ) ) { return false; } $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['host'] ) || isset( $parts['user'] ) || isset( $parts['pass'] ) ) { return false; } if ( isset( $parts['scheme'] ) && 'https' !== strtolower( $parts['scheme'] ) ) { return false; } $host = strtolower( rtrim( (string) $parts['host'], '.' ) ); $port = isset( $parts['port'] ) ? (int) $parts['port'] : 0; // IP literals (v4 ή [v6]) ποτέ — μόνο ονόματα που ελέγχουμε εμείς. if ( false !== filter_var( trim( $host, '[]' ), FILTER_VALIDATE_IP ) || false !== strpos( $host, ':' ) ) { return false; } if ( ! preg_match( '/^[a-z0-9.-]+$/', $host ) ) { return false; } $suffixes = array( 'weborange.gr', 'addsynapse.com' ); $env = function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production'; $dev = in_array( $env, array( 'local', 'development' ), true ); /** * Επιπλέον επιτρεπτοί hosts για τα endpoints της υπηρεσίας. * * Στοιχείο «example.com» = ο host και τα subdomains του, μόνο θύρα 443. * Στοιχείο «host:port» = ακριβώς αυτός ο host σε αυτή τη θύρα. * * @param string[] $hosts Προεπιλογή: array( 'weborange.gr', 'addsynapse.com' ). */ $allowed = apply_filters( 'addsynapse_search_allowed_hosts', $suffixes ); if ( ! is_array( $allowed ) ) { $allowed = $suffixes; } foreach ( $allowed as $entry ) { if ( ! is_string( $entry ) || '' === $entry ) { continue; } $entry = strtolower( trim( $entry ) ); if ( false !== strpos( $entry, ':' ) ) { if ( $entry === $host . ':' . ( $port ? $port : 443 ) ) { return true; } continue; } if ( ( 0 === $port || 443 === $port ) && ( $host === $entry || self::ends_with( $host, '.' . $entry ) ) ) { return true; } } return $dev && self::ends_with( $host, '.ddev.site' ); } /** * @param string $haystack Κείμενο. * @param string $needle Κατάληξη. * @return bool */ private static function ends_with( $haystack, $needle ) { $len = strlen( $needle ); return $len > 0 && strlen( $haystack ) > $len && substr( $haystack, -$len ) === $needle; } /** * Ελαφριά τυποποίηση/sanitization ανά γνωστό κλειδί, ώστε ό,τι μπαίνει στο option * να είναι πάντα του σωστού τύπου, ανεξάρτητα από το ποιος καλεί το set(). * * @param string $key Το κλειδί. * @param mixed $value Η ακατέργαστη τιμή. * @return mixed */ private static function sanitize_pair( $key, $value ) { switch ( $key ) { case 'license_key': case 'shop_id': return is_string( $value ) ? sanitize_text_field( $value ) : ''; case 'api_base': // 🔴 Μόνο εγκεκριμένοι hosts (audit F07) — εκεί πάει το license key. return is_string( $value ) && '' !== $value && self::is_allowed_service_url( $value ) ? esc_url_raw( untrailingslashit( $value ) ) : self::defaults()['api_base']; case 'search_mode': return in_array( $value, array( 'ours', 'theme', 'manual' ), true ) ? $value : 'ours'; case 'attach_selector': case 'search_trigger_selector': return is_string( $value ) ? trim( sanitize_text_field( $value ) ) : ''; case 'synonyms': case 'placeholders': return is_string( $value ) ? sanitize_textarea_field( $value ) : ''; case 'show_history': case 'show_popular': case 'send_search_stats': case 'index_description': return (bool) $value; case 'oos_behavior': $allowed = array( 'sink', 'hide' ); return in_array( $value, $allowed, true ) ? $value : 'sink'; case 'popular_terms': if ( ! is_array( $value ) ) { return array(); } return array_values( array_filter( array_map( 'sanitize_text_field', $value ), 'strlen' ) ); case 'mobile_breakpoint': return absint( $value ); case 'meilisearch_host': case 'analytics_endpoint': return is_string( $value ) && '' !== $value && self::is_allowed_service_url( $value ) ? esc_url_raw( $value ) : ''; case 'ranking_score_threshold': if ( ! is_numeric( $value ) ) { return self::defaults()['ranking_score_threshold']; } return max( 0.0, min( 1.0, (float) $value ) ); case 'variation_indexing': return (bool) $value; case 'show_categories': case 'show_brands': case 'show_price': case 'show_image': case 'show_stock': case 'load_css': case 'voice_search': case 'semantic_search': return (bool) $value; case 'results_count': $count = absint( $value ); if ( $count < 1 || $count > 24 ) { return self::defaults()['results_count']; } return $count; case 'search_layout': return in_array( $value, array( 'simple', 'advanced' ), true ) ? $value : 'simple'; case 'search_look': return in_array( $value, array( 'theme', 'addsynapse' ), true ) ? $value : 'theme'; case 'search_facets_position': return in_array( $value, array( 'left', 'right' ), true ) ? $value : 'left'; case 'filters_mode': return in_array( $value, array( 'ajax', 'reload' ), true ) ? $value : 'ajax'; case 'filters_target': $value = is_string( $value ) ? trim( sanitize_text_field( $value ) ) : ''; return '' === $value ? self::FILTERS_TARGET_DEFAULT : $value; default: return $value; } } }