self::MENU_SLUG, 'tab' => 'settings', ), admin_url( 'admin.php' ) ); $ours = array( 'addsynapse_search_settings' => sprintf( '%s', esc_url( $settings_url ), $licensed ? esc_html__( 'Settings', 'addsynapse-search-filter' ) : '' . esc_html__( 'Connect to AddSynapse', 'addsynapse-search-filter' ) . '' ), ); if ( $licensed ) { $stats_url = add_query_arg( array( 'page' => self::MENU_SLUG, 'tab' => 'stats', ), admin_url( 'admin.php' ) ); $ours['addsynapse_search_stats'] = sprintf( '%s', esc_url( $stats_url ), esc_html__( 'Stats', 'addsynapse-search-filter' ) ); } // Μπροστά από τα προεπιλεγμένα (Απενεργοποίηση), όπως κάνει το WooCommerce. return array_merge( $ours, (array) $links ); } /** * Σύνδεσμοι βοήθειας στη δεύτερη γραμμή της λίστας προσθέτων. * * @param array $meta Υπάρχουσες καταχωρήσεις. * @param string $file Το plugin που αφορά η γραμμή. * @return array */ public static function plugin_row_meta( $meta, $file ) { if ( ADDSYNAPSE_SEARCH_PLUGIN_BASENAME !== $file ) { return $meta; } $help_url = add_query_arg( array( 'page' => self::MENU_SLUG, 'tab' => 'help', ), admin_url( 'admin.php' ) ); $meta[] = sprintf( '%s', esc_url( $help_url ), esc_html__( 'Help', 'addsynapse-search-filter' ) ); return $meta; } /* ----------------------------------------------------------------- * Μενού & assets * --------------------------------------------------------------- */ /** * Το slug της σελίδας ΔΕΝ άλλαξε με τη μετακίνηση από το WooCommerce στο * «AddSynapse»: το admin.php?page=addsynapse-search δεν εξαρτάται από τον * parent, άρα σελιδοδείκτες/links (και ?tab=) συνεχίζουν να δουλεύουν χωρίς * ανακατεύθυνση. Μόνο το hook suffix άλλαξε — γι' αυτό το κρατάμε από την * επιστροφή του add_submenu_page() και δεν το γράφουμε ποτέ με το χέρι. */ public static function register_menu() { // Ο parent (+ Overview) φτιάχτηκε ήδη στο admin_menu:9 (Addsynapse_Search_Admin_Menu). self::$hook_suffix = add_submenu_page( Addsynapse_Search_Admin_Menu::PARENT_SLUG, __( 'AddSynapse – Live Product Search & Filters for WooCommerce', 'addsynapse-search-filter' ), __( 'Search & Filter', 'addsynapse-search-filter' ), 'manage_woocommerce', self::MENU_SLUG, array( __CLASS__, 'render_page' ), Addsynapse_Search_Admin_Menu::ORDER[ self::MENU_SLUG ] ); } /** * Η κάρτα μας στην Overview (φίλτρο `addsynapse_products`). ΜΟΝΟ τοπική * κατάσταση: ΟΧΙ License::is_active() — εκείνο μπορεί να ανανεώσει token * μέσω δικτύου, και η Overview δεν κάνει ποτέ κλήση δικτύου. * * @param array $products Κάρτες των άλλων plugins. * @return array */ public static function describe_product( $products ) { $products = is_array( $products ) ? $products : array(); $key = (string) Addsynapse_Search_Settings::get( 'license_key', '' ); $shop = Addsynapse_Search_Settings::get( 'shop_id', '' ); $status = Addsynapse_Search_License::status(); if ( '' === $key || empty( $shop ) ) { $state = 'not_connected'; $label = __( 'Not connected — click "Connect to AddSynapse" in Settings', 'addsynapse-search-filter' ); } elseif ( ! empty( $status['error'] ) ) { $state = 'error'; $label = __( 'Connection problem — see Status', 'addsynapse-search-filter' ); } else { $state = 'connected'; $label = Addsynapse_Search_Hub::is_development_site() ? __( 'Connected · Development site', 'addsynapse-search-filter' ) : __( 'Connected', 'addsynapse-search-filter' ); } $products['search-filter'] = array( 'id' => 'search-filter', 'name' => __( 'Search & Filter', 'addsynapse-search-filter' ), 'version' => ADDSYNAPSE_SEARCH_VERSION, 'status' => $state, 'status_label' => $label, 'settings_url' => add_query_arg( array( 'page' => self::MENU_SLUG ), admin_url( 'admin.php' ) ), 'description' => __( 'Product search and filters for WooCommerce that understand greeklish, word forms and typos.', 'addsynapse-search-filter' ), 'icon' => 'dashicons-search', 'capability' => 'manage_woocommerce', 'position' => Addsynapse_Search_Admin_Menu::ORDER[ self::MENU_SLUG ], ); return $products; } public static function enqueue_assets( $hook ) { if ( ! self::$hook_suffix || $hook !== self::$hook_suffix ) { return; } wp_enqueue_style( 'addsynapse-search-admin', plugins_url( 'assets/admin.css', __FILE__ ), array(), addsynapse_search_asset_version( 'admin/assets/admin.css' ) ); wp_enqueue_script( 'addsynapse-search-admin', plugins_url( 'assets/admin.js', __FILE__ ), array(), addsynapse_search_asset_version( 'admin/assets/admin.js' ), true ); // Μόνο ό,τι χρειάζεται το JS για το AJAX «τι μπορεί να εννοούσαν;» — καμία // ευαίσθητη τιμή (όχι license key, όχι master key· αυτά δεν φεύγουν ποτέ // από τον server, βλ. CLAUDE.md § Ops & Gotchas). wp_localize_script( 'addsynapse-search-admin', 'addsynapseSearchAdmin', array( 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'suggestNonce' => wp_create_nonce( 'addsynapse_search_suggest' ), 'runBatchNonce' => wp_create_nonce( 'addsynapse_search_run_batch' ), 'synonymNonce' => wp_create_nonce( 'addsynapse_search_synonym_group_action' ), 'synonymAction' => admin_url( 'admin-post.php' ), // «Connect to AddSynapse» σε νέα καρτέλα + πάνελ αναμονής/polling (Addsynapse_Search_Hub::js_config()). 'connect' => Addsynapse_Search_Hub::js_config(), 'i18n' => array( 'loading' => __( 'Fetching suggestions…', 'addsynapse-search-filter' ), 'noResults' => __( 'No product suggestions found for this term.', 'addsynapse-search-filter' ), 'error' => __( 'Could not fetch suggestions right now. Please try again shortly.', 'addsynapse-search-filter' ), /* translators: %s: the candidate product title */ 'createWith' => __( 'Synonym with "%s"', 'addsynapse-search-filter' ), // Καρτέλα «Κατάσταση» -- "Run now" (βλ. handleRunNowClick() στο admin.js). 'runNowStarting' => __( 'Starting…', 'addsynapse-search-filter' ), /* translators: 1: number of products indexed so far, 2: total products */ 'runNowProgress' => __( 'Indexing %1$s of %2$s…', 'addsynapse-search-filter' ), /* translators: 1: total products indexed */ 'runNowDone' => __( 'Done — indexed %1$s products.', 'addsynapse-search-filter' ), 'runNowStopped' => __( 'Stopped. Click "Run now" again to continue from where you left off.', 'addsynapse-search-filter' ), 'runNowError' => __( 'Indexing stopped because of an error:', 'addsynapse-search-filter' ), 'runNowContinue' => __( 'Continue', 'addsynapse-search-filter' ), ), ) ); } /* ----------------------------------------------------------------- * Render σελίδας * --------------------------------------------------------------- */ public static function render_page() { if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'addsynapse-search-filter' ) ); } $tabs = array( 'status' => __( 'Status', 'addsynapse-search-filter' ), 'settings' => __( 'Settings', 'addsynapse-search-filter' ), 'synonyms' => __( 'Synonyms', 'addsynapse-search-filter' ), 'stats' => __( 'Stats', 'addsynapse-search-filter' ), 'help' => __( 'Help', 'addsynapse-search-filter' ), ); $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'status'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( ! array_key_exists( $tab, $tabs ) ) { $tab = 'status'; } echo '
'; /* * Το σήμα δίπλα στον τίτλο. Inline SVG και όχι αρχείο εικόνας: δεν * προσθέτει αίτημα, κλιμακώνεται σε κάθε ανάλυση, και με * `currentColor` ακολουθεί το χρώμα του τίτλου — άρα δουλεύει και σε * τυχόν σκούρο σχήμα του admin χωρίς δεύτερο αρχείο. * * Το κίτρινο (#FFE066) μένει σταθερό: αυτό ΕΙΝΑΙ το σήμα. */ echo '

' . '' . esc_html__( 'AddSynapse – Live Product Search & Filters for WooCommerce', 'addsynapse-search-filter' ) . '

'; // Κάρτα πρώτης χρήσης (μετά το Connect): εμφάνιση θέματος ή AddSynapse, με προεπισκόπηση. if ( class_exists( 'Addsynapse_Search_Look' ) ) { Addsynapse_Search_Look::render_first_run_card(); } echo ''; $view_file = plugin_dir_path( __FILE__ ) . 'views/' . $tab . '.php'; if ( file_exists( $view_file ) ) { include $view_file; } echo '
'; } /* ----------------------------------------------------------------- * Ειδοποιήσεις (admin_notices) * --------------------------------------------------------------- */ private static function notice_messages() { return array( 'settings_saved' => array( 'success', __( 'Settings saved.', 'addsynapse-search-filter' ) ), 'look_theme' => array( 'success', __( 'Search keeps your theme’s look. You can change it any time in Settings → Appearance.', 'addsynapse-search-filter' ) ), 'look_addsynapse' => array( 'success', __( 'Search now uses the AddSynapse look. You can change it any time in Settings → Appearance.', 'addsynapse-search-filter' ) ), 'hub_connected' => array( 'success', __( 'Connected to AddSynapse. Search is active on this site.', 'addsynapse-search-filter' ) ), 'hub_connected_pending' => array( 'warning', __( 'Connected to AddSynapse, but search could not be set up yet. It will be retried automatically — or click "Refresh status".', 'addsynapse-search-filter' ) ), 'hub_disconnected' => array( 'success', __( 'Disconnected from AddSynapse. Search is switched off on this site until you connect again.', 'addsynapse-search-filter' ) ), 'hub_disconnected_local' => array( 'warning', __( 'Disconnected on this site. AddSynapse could not be reached to confirm — you can also remove the site from your AddSynapse account.', 'addsynapse-search-filter' ) ), 'hub_refreshed' => array( 'success', __( 'Status refreshed.', 'addsynapse-search-filter' ) ), 'hub_refresh_failed' => array( 'error', __( 'AddSynapse could not be reached right now. The last known status is shown.', 'addsynapse-search-filter' ) ), 'license_activated' => array( 'success', __( 'License activated.', 'addsynapse-search-filter' ) ), 'license_deactivated' => array( 'success', __( 'License deactivated.', 'addsynapse-search-filter' ) ), 'license_error' => array( 'error', __( 'License activation failed. Please check the key and try again.', 'addsynapse-search-filter' ) ), 'license_empty' => array( 'error', __( 'Enter a license key.', 'addsynapse-search-filter' ) ), 'nonce_error' => array( 'error', __( 'The action failed (invalid request). Please try again.', 'addsynapse-search-filter' ) ), 'billing_portal_error' => array( 'error', __( 'We could not open the billing page right now. Please try again, or contact us from the Help tab.', 'addsynapse-search-filter' ) ), 'no_billing_account' => array( 'info', __( 'There is no billing history for this store yet — you are on the free plan. Pick a paid plan first and the billing page will appear here.', 'addsynapse-search-filter' ) ), 'cap_error' => array( 'error', __( 'You do not have permission to perform this action.', 'addsynapse-search-filter' ) ), // 🔴 ΔΙΟΡΘΩΣΗ ΓΝΩΣΤΟΥ ΚΕΝΟΥ: πριν, τα συνώνυμα αποθηκεύονταν // ΜΟΝΟ τοπικά — ο πελάτης έβλεπε «αποθηκεύτηκε» και τίποτα δεν // άλλαζε στον πραγματικό δείκτη. Τώρα, αν η αποστολή στο API // αποτύχει, το λέμε ΚΑΘΑΡΑ αντί να το κρύβουμε (βλ. // sync_settings_to_server()). 'settings_sync_error' => array( 'error', __( 'Settings were saved locally, but applying them (e.g. synonyms) to the search index failed. Please try again shortly.', 'addsynapse-search-filter' ) ), 'license_activated_sync_error' => array( 'error', __( 'The license was activated, but applying the initial settings (e.g. synonyms) to the index failed. It will be retried on the next settings save.', 'addsynapse-search-filter' ) ), // Διαχειριστής συνωνύμων (καρτέλα «Συνώνυμα»). Ίδια πειθαρχία με τα // παραπάνω: ΠΟΤΕ «αποθηκεύτηκε» αν η αποστολή στο API απέτυχε. 'synonym_added' => array( 'success', __( 'Synonym group added.', 'addsynapse-search-filter' ) ), 'synonym_updated' => array( 'success', __( 'Synonym group updated.', 'addsynapse-search-filter' ) ), 'synonym_deleted' => array( 'success', __( 'Synonym group deleted.', 'addsynapse-search-filter' ) ), 'synonym_min_terms' => array( 'error', __( 'At least two terms are needed to create a synonym group.', 'addsynapse-search-filter' ) ), 'synonym_not_found' => array( 'error', __( 'Synonym group not found — it may have changed elsewhere. Refresh the page and try again.', 'addsynapse-search-filter' ) ), 'synonym_sync_error' => array( 'error', __( 'The change was saved locally, but applying it to the search index failed. Please try again shortly.', 'addsynapse-search-filter' ) ), ); } public static function render_notices() { if ( ! isset( $_GET['page'] ) || self::MENU_SLUG !== sanitize_key( wp_unslash( $_GET['page'] ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: which admin page is shown. return; } // Αποτυχία «Connect to AddSynapse» — κωδικός από Addsynapse_Search_Hub (ποτέ κείμενο από το URL). if ( isset( $_GET['addsynapse_search_connect_error'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $connect_error = sanitize_key( wp_unslash( $_GET['addsynapse_search_connect_error'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended printf( '

%s

', wp_kses( make_clickable( esc_html( Addsynapse_Search_Hub::error_message( $connect_error ) ) ), array( 'a' => array( 'href' => array(), 'rel' => array() ) ) ) ); return; } if ( ! isset( $_GET['addsynapse_search_notice'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended return; } $code = sanitize_key( wp_unslash( $_GET['addsynapse_search_notice'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $messages = self::notice_messages(); if ( ! isset( $messages[ $code ] ) ) { return; } list( $type, $text ) = $messages[ $code ]; printf( '

%s

', esc_attr( $type ), esc_html( $text ) ); } /* ----------------------------------------------------------------- * Υπο-καρτέλες της καρτέλας «Ρυθμίσεις» (μόνο αναδιάταξη· βλ. views/settings.php) * --------------------------------------------------------------- */ /** * Οι ομάδες ρυθμίσεων, με τη σειρά που εμφανίζονται: slug => label + μία γραμμή εξήγησης. * * @return array */ public static function settings_sections() { return array( 'connection' => array( 'label' => __( 'Connection', 'addsynapse-search-filter' ), 'intro' => __( 'Connect this shop to your AddSynapse account, see your plan and manage billing.', 'addsynapse-search-filter' ), ), 'general' => array( 'label' => __( 'General', 'addsynapse-search-filter' ), 'intro' => __( 'Choose how the search is added to your shop and whether search statistics are collected.', 'addsynapse-search-filter' ), ), 'appearance' => array( 'label' => __( 'Appearance', 'addsynapse-search-filter' ), 'intro' => __( 'What shoppers see when they search: the layout, how many products appear and what each result shows.', 'addsynapse-search-filter' ), ), 'results' => array( 'label' => __( 'Results & ranking', 'addsynapse-search-filter' ), 'intro' => __( 'Which products the search finds and in what order they appear.', 'addsynapse-search-filter' ), ), 'filters' => array( 'label' => __( 'Filters', 'addsynapse-search-filter' ), 'intro' => __( 'The product filters shoppers use on your shop and category pages.', 'addsynapse-search-filter' ), ), 'synonyms' => array( 'label' => __( 'Synonyms & language', 'addsynapse-search-filter' ), 'intro' => __( 'Words that should find the same products. Greeklish, accents and word endings are already handled automatically.', 'addsynapse-search-filter' ), ), 'advanced' => array( 'label' => __( 'Advanced', 'addsynapse-search-filter' ), 'intro' => __( 'Technical options for developers, or for when your theme needs help. Most shops never need to change these.', 'addsynapse-search-filter' ), ), ); } /** * Ποια υπο-καρτέλα ανοίγει: ρητό ?section= αν είναι έγκυρο· αλλιώς «Σύνδεση» όταν το * site δεν είναι συνδεδεμένο ή όταν επιστρέφουμε από ενέργεια σύνδεσης/κλειδιού· αλλιώς «Γενικά». * * @param bool $connected Συνδεδεμένο με AddSynapse ή με ενεργό κλειδί. * @return string */ public static function current_settings_section( $connected ) { // phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only: which section of our own admin page is shown. $section = isset( $_GET['section'] ) ? sanitize_key( wp_unslash( $_GET['section'] ) ) : ''; $notice = isset( $_GET['addsynapse_search_notice'] ) ? sanitize_key( wp_unslash( $_GET['addsynapse_search_notice'] ) ) : ''; $connect = isset( $_GET['addsynapse_search_connect_error'] ) || isset( $_GET['addsynapse_search_connect'] ); // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( array_key_exists( $section, self::settings_sections() ) ) { return $section; } if ( ! $connected || $connect || 0 === strpos( $notice, 'hub_' ) || 0 === strpos( $notice, 'license_' ) ) { return 'connection'; } return 'general'; } /** * URL της καρτέλας «Ρυθμίσεις», προαιρετικά σε συγκεκριμένη υπο-καρτέλα. * * @param string $section Slug από settings_sections() ή κενό. * @return string */ public static function settings_url( $section = '' ) { $args = array( 'page' => self::MENU_SLUG, 'tab' => 'settings', ); if ( '' !== $section ) { $args['section'] = $section; } return add_query_arg( $args, admin_url( 'admin.php' ) ); } /* ----------------------------------------------------------------- * Form handlers (admin-post.php) * --------------------------------------------------------------- */ private static function verify_request( $nonce_action, $nonce_name = 'addsynapse_search_nonce', $error_tab = 'status' ) { if ( ! isset( $_POST[ $nonce_name ] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST[ $nonce_name ] ) ), $nonce_action ) ) { self::redirect( $error_tab, 'nonce_error' ); } if ( ! current_user_can( 'manage_woocommerce' ) ) { self::redirect( $error_tab, 'cap_error' ); } } private static function redirect( $tab, $notice = '', $section = '' ) { $args = array( 'page' => self::MENU_SLUG, 'tab' => $tab ); if ( '' !== $section ) { $args['section'] = $section; } if ( $notice ) { $args['addsynapse_search_notice'] = $notice; } wp_safe_redirect( add_query_arg( $args, admin_url( 'admin.php' ) ) ); exit; } public static function handle_save_settings() { self::verify_request( 'addsynapse_search_save_settings' ); // phpcs:disable WordPress.Security.NonceVerification.Missing -- η επαλήθευση (wp_verify_nonce + capability) έγινε ήδη πάνω από self::verify_request(), κοινό helper για όλους τους form handlers αυτής της κλάσης· η ίδια η μέθοδος δεν βλέπεται στατικά από το εργαλείο. $mode = isset( $_POST['search_mode'] ) ? sanitize_key( wp_unslash( $_POST['search_mode'] ) ) : 'ours'; if ( ! in_array( $mode, array( 'ours', 'theme', 'manual' ), true ) ) { $mode = 'ours'; } $trigger_selector = isset( $_POST['search_trigger_selector'] ) ? sanitize_text_field( wp_unslash( $_POST['search_trigger_selector'] ) ) : ''; $oos_behavior = isset( $_POST['oos_behavior'] ) ? sanitize_key( wp_unslash( $_POST['oos_behavior'] ) ) : 'sink'; if ( ! in_array( $oos_behavior, array( 'sink', 'hide' ), true ) ) { $oos_behavior = 'sink'; } $selector = isset( $_POST['attach_selector'] ) ? sanitize_text_field( wp_unslash( $_POST['attach_selector'] ) ) : ''; $synonyms = isset( $_POST['synonyms'] ) ? sanitize_textarea_field( wp_unslash( $_POST['synonyms'] ) ) : ''; // Μία γραμμή (υπόδειξη πεδίου)· κενό = η γενική προεπιλογή (Addsynapse_Search_Settings::search_hint()). $placeholders = isset( $_POST['placeholders'] ) ? sanitize_text_field( wp_unslash( $_POST['placeholders'] ) ) : ''; $show_history = isset( $_POST['show_history'] ) ? 1 : 0; $show_popular = isset( $_POST['show_popular'] ) ? 1 : 0; $send_search_stats = isset( $_POST['send_search_stats'] ) ? 1 : 0; $index_description = isset( $_POST['index_description'] ) ? 1 : 0; // --- Ρυθμίσεις εμφάνισης (§3/§4) -- καθαρά τοπικές, ΔΕΝ στέλνονται // στο server (βλ. sync_settings_to_server() παρακάτω, ίδιο μοτίβο με // search_mode/oos_behavior: μόνο τα συνώνυμα αφορούν πραγματικά τον // δείκτη Meilisearch). --- $show_categories = isset( $_POST['show_categories'] ) ? 1 : 0; $show_brands = isset( $_POST['show_brands'] ) ? 1 : 0; $show_price = isset( $_POST['show_price'] ) ? 1 : 0; $show_image = isset( $_POST['show_image'] ) ? 1 : 0; $show_stock = isset( $_POST['show_stock'] ) ? 1 : 0; $load_css = isset( $_POST['load_css'] ) ? 1 : 0; $voice_search = isset( $_POST['voice_search'] ) ? 1 : 0; // 0.7.0: συγχρονίζεται στον server (ρυθμίζει τον embedder του δείκτη). $semantic_search = isset( $_POST['semantic_search'] ) ? 1 : 0; // 0.8.0: εκτός πακέτου (Free) ο διακόπτης είναι κλειδωμένος και δεν στέλνεται — // κρατάμε την επιλογή του πελάτη, ώστε μια αναβάθμιση να την ξαναβρεί. if ( ! Addsynapse_Search_Settings::semantic_available() ) { $semantic_search = Addsynapse_Search_Settings::get( 'semantic_search', false ) ? 1 : 0; } $results_count = isset( $_POST['results_count'] ) ? absint( wp_unslash( $_POST['results_count'] ) ) : 8; if ( $results_count < 1 || $results_count > 24 ) { $results_count = 8; } $search_layout = isset( $_POST['search_layout'] ) ? sanitize_key( wp_unslash( $_POST['search_layout'] ) ) : 'simple'; if ( ! in_array( $search_layout, array( 'simple', 'advanced' ), true ) ) { $search_layout = 'simple'; } $search_look = isset( $_POST['search_look'] ) ? sanitize_key( wp_unslash( $_POST['search_look'] ) ) : 'theme'; if ( ! in_array( $search_look, array( 'theme', 'addsynapse' ), true ) ) { $search_look = 'theme'; } $search_facets_position = isset( $_POST['search_facets_position'] ) ? sanitize_key( wp_unslash( $_POST['search_facets_position'] ) ) : 'left'; if ( ! in_array( $search_facets_position, array( 'left', 'right' ), true ) ) { $search_facets_position = 'left'; } $filters_mode = isset( $_POST['filters_mode'] ) ? sanitize_key( wp_unslash( $_POST['filters_mode'] ) ) : 'ajax'; if ( ! in_array( $filters_mode, array( 'ajax', 'reload' ), true ) ) { $filters_mode = 'ajax'; } // Κενό -> προεπιλογή (το καθαρίζει και το Addsynapse_Search_Settings::sanitize_pair()). $filters_target = isset( $_POST['filters_target'] ) ? sanitize_text_field( wp_unslash( $_POST['filters_target'] ) ) : ''; $filters_max_values = isset( $_POST['filters_max_values'] ) ? max( 3, min( 50, absint( wp_unslash( $_POST['filters_max_values'] ) ) ) ) : 12; // Μόνο για την επιστροφή στην ίδια υπο-καρτέλα — δεν αποθηκεύεται. $section = isset( $_POST['addsynapse_search_section'] ) ? sanitize_key( wp_unslash( $_POST['addsynapse_search_section'] ) ) : ''; if ( ! array_key_exists( $section, self::settings_sections() ) ) { $section = ''; } // phpcs:enable WordPress.Security.NonceVerification.Missing if ( class_exists( 'Addsynapse_Search_Settings' ) ) { try { Addsynapse_Search_Settings::set( 'search_mode', $mode ); Addsynapse_Search_Settings::set( 'attach_selector', $selector ); Addsynapse_Search_Settings::set( 'search_trigger_selector', $trigger_selector ); Addsynapse_Search_Settings::set( 'synonyms', $synonyms ); Addsynapse_Search_Settings::set( 'placeholders', $placeholders ); Addsynapse_Search_Settings::set( 'oos_behavior', $oos_behavior ); Addsynapse_Search_Settings::set( 'show_history', $show_history ); Addsynapse_Search_Settings::set( 'show_popular', $show_popular ); Addsynapse_Search_Settings::set( 'send_search_stats', $send_search_stats ); Addsynapse_Search_Settings::set( 'index_description', $index_description ); Addsynapse_Search_Settings::set( 'show_categories', $show_categories ); Addsynapse_Search_Settings::set( 'show_brands', $show_brands ); Addsynapse_Search_Settings::set( 'show_price', $show_price ); Addsynapse_Search_Settings::set( 'show_image', $show_image ); Addsynapse_Search_Settings::set( 'show_stock', $show_stock ); Addsynapse_Search_Settings::set( 'results_count', $results_count ); Addsynapse_Search_Settings::set( 'search_layout', $search_layout ); Addsynapse_Search_Settings::set( 'search_look', $search_look ); Addsynapse_Search_Settings::set( 'search_facets_position', $search_facets_position ); Addsynapse_Search_Settings::set( 'filters_max_values', $filters_max_values ); Addsynapse_Search_Settings::set( 'filters_mode', $filters_mode ); Addsynapse_Search_Settings::set( 'filters_target', $filters_target ); Addsynapse_Search_Settings::set( 'load_css', $load_css ); Addsynapse_Search_Settings::set( 'voice_search', $voice_search ); Addsynapse_Search_Settings::set( 'semantic_search', $semantic_search ); } catch ( \Throwable $e ) { self::redirect( 'settings', 'nonce_error', $section ); } } // 🔴 ΔΙΟΡΘΩΣΗ ΓΝΩΣΤΟΥ ΚΕΝΟΥ: μέχρι εδώ αποθηκεύτηκαν μόνο ΤΟΠΙΚΑ. Αν // δεν φτάσει και στο API/Meilisearch, ο πελάτης βλέπει «αποθηκεύτηκε» // και τίποτα δεν άλλαζε στην πράξη (CLAUDE.md § Έγινε ήδη μία φορά). if ( is_wp_error( self::sync_settings_to_server() ) ) { self::redirect( 'settings', 'settings_sync_error', $section ); } self::redirect( 'settings', 'settings_saved', $section ); } /** * Ανθρώπινο όνομα πακέτου για εμφάνιση (η καρτέλα «Κατάσταση»). Ο server * στέλνει τον raw κωδικό (π.χ. "free") — εδώ μόνο η μετάφραση προβολής, * καμία λογική ορίου (αυτή μένει server-side, βλ. backend PlanLimits). * * @param string $plan Ο raw κωδικός πακέτου. * @return string */ public static function plan_label( $plan ) { $plan = strtolower( trim( (string) $plan ) ); // Παλιό κλειδί (23/09) → νέο, ίδιος χάρτης με το backend // config/plan_aliases.php. 03/10: το «business» είναι ΞΑΝΑ πραγματικό // πακέτο (όχι alias του enterprise) — μένει μόνο basic → pro. $aliases = array( 'basic' => 'pro', ); if ( isset( $aliases[ $plan ] ) ) { $plan = $aliases[ $plan ]; } $labels = array( 'free' => __( 'Free', 'addsynapse-search-filter' ), 'pro' => __( 'Pro', 'addsynapse-search-filter' ), 'business' => __( 'Business', 'addsynapse-search-filter' ), 'enterprise' => __( 'Enterprise', 'addsynapse-search-filter' ), ); if ( isset( $labels[ $plan ] ) ) { return $labels[ $plan ]; } return '' !== $plan ? ucfirst( $plan ) : __( '—', 'addsynapse-search-filter' ); } /** * Ο κωδικός του τρέχοντος πακέτου από ό,τι υπάρχει ΗΔΗ τοπικά (καμία * κλήση δικτύου): hub (συνδεδεμένο) → κατάσταση άδειας → ''. * * @return string */ public static function current_plan_code() { if ( class_exists( 'Addsynapse_Search_Hub' ) && Addsynapse_Search_Hub::is_connected() ) { $hub = Addsynapse_Search_Hub::status(); if ( ! empty( $hub['entitlement']['plan'] ) && is_string( $hub['entitlement']['plan'] ) ) { return $hub['entitlement']['plan']; } } if ( class_exists( 'Addsynapse_Search_License' ) ) { $status = Addsynapse_Search_License::status(); if ( ! empty( $status['plan'] ) && is_string( $status['plan'] ) ) { return $status['plan']; } } return ''; } /** * 🔴 Ήρεμη ενημέρωση μερικής κάλυψης / δοκιμής Pro (0.8.0) — ΜΟΝΟ στις σελίδες μας * (Κατάσταση, Ρυθμίσεις· κανόνας wp.org: καμία ειδοποίηση αλλού). Λέει ρητά ότι τα * προϊόντα εκτός δείκτη βρίσκονται από την αναζήτηση του WordPress — όχι «χάθηκαν». * * @param bool $is_active Ενεργή άδεια. * @return bool true αν τυπώθηκε η ειδοποίηση μερικής κάλυψης. */ public static function render_coverage_notice( $is_active ) { if ( ! $is_active || ! class_exists( 'Addsynapse_Search_Coverage' ) ) { return false; } $addsynapse_search_cov = Addsynapse_Search_Coverage::state(); $partial = $addsynapse_search_cov['partial'] && null !== $addsynapse_search_cov['covered']; $trial = ! $partial && $addsynapse_search_cov['trial_active']; if ( ! $partial && ! $trial ) { return false; } if ( $partial ) : $addsynapse_search_up = Addsynapse_Search_Coverage::upgrade_link(); ?>


$headline, 'details' => $details, 'text' => $headline . ' ' . $details, 'url' => class_exists( 'Addsynapse_Search_License' ) ? Addsynapse_Search_License::pricing_url() : '', 'link_label' => $connected ? ( $dev_site ? __( 'Manage on AddSynapse', 'addsynapse-search-filter' ) : __( 'Manage / Upgrade on AddSynapse', 'addsynapse-search-filter' ) ) : __( 'View plans', 'addsynapse-search-filter' ), ); } public static function handle_activate_license() { self::verify_request( 'addsynapse_search_license_action' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- επαληθεύτηκε ήδη στη self::verify_request() ακριβώς από πάνω. $key = isset( $_POST['license_key'] ) ? sanitize_text_field( wp_unslash( $_POST['license_key'] ) ) : ''; if ( '' === trim( $key ) ) { self::redirect( 'settings', 'license_empty' ); } $result = false; if ( class_exists( 'Addsynapse_Search_License' ) && method_exists( 'Addsynapse_Search_License', 'activate' ) ) { try { $result = Addsynapse_Search_License::activate( $key ); } catch ( \Throwable $e ) { $result = false; } } if ( is_wp_error( $result ) || false === $result ) { self::redirect( 'settings', 'license_error' ); } // Η άδεια μόλις ενεργοποιήθηκε -> υπάρχει πλέον shop context στον // server. Στέλνουμε τις τοπικά αποθηκευμένες ρυθμίσεις (π.χ. // συνώνυμα, αν είχαν γραφτεί ΠΡΙΝ την ενεργοποίηση) αμέσως. if ( is_wp_error( self::sync_settings_to_server() ) ) { self::redirect( 'settings', 'license_activated_sync_error' ); } self::redirect( 'settings', 'license_activated' ); } public static function handle_deactivate_license() { self::verify_request( 'addsynapse_search_license_action' ); if ( class_exists( 'Addsynapse_Search_License' ) && method_exists( 'Addsynapse_Search_License', 'deactivate' ) ) { try { Addsynapse_Search_License::deactivate(); } catch ( \Throwable $e ) { // Ό,τι κι αν συμβεί, δεν κάνουμε fatal — απλώς ενημερώνουμε. unset( $e ); } } self::redirect( 'settings', 'license_deactivated' ); } /** * Στέλνει τον χειριστή στο Customer Portal του Stripe (συνδρομή, * ακύρωση, αλλαγή κάρτας, τιμολόγια). * * 🔴 Ο σύνδεσμος ζητείται ΤΗ ΣΤΙΓΜΗ που πατιέται το κουμπί και δεν * αποθηκεύεται: είναι βραχύβιος και δίνει πρόσβαση σε οικονομικά * στοιχεία. Ένας cached σύνδεσμος θα ήταν είτε ληγμένος είτε διαρροή. */ public static function handle_billing_portal() { self::verify_request( 'addsynapse_search_billing_portal_action' ); if ( ! class_exists( 'Addsynapse_Search_Api_Client' ) ) { self::redirect( 'status', 'billing_portal_error' ); } $result = Addsynapse_Search_Api_Client::billing_portal(); if ( is_wp_error( $result ) ) { // Το backend απαντά 404 με 'no_billing_account' όταν το κατάστημα // δεν έχει πληρώσει ποτέ — τότε ο σωστός προορισμός είναι τα // πακέτα, όχι ένα μήνυμα σφάλματος. // 🔴 audit F13: ο κωδικός ζει στο `code` (κοινό σχήμα, βλ. // Addsynapse_Search_Api_Client::extract_error_data()) — πριν διαβαζόταν // `error`, που δεν έφτανε ποτέ, και η ανακατεύθυνση έγραφε // `addsynapse_search_status` ενώ το render_notices() διαβάζει `addsynapse_search_notice`. $data = $result->get_error_data(); $code = is_array( $data ) && isset( $data['code'] ) ? (string) $data['code'] : ''; if ( 'no_billing_account' === $code ) { self::redirect( 'status', 'no_billing_account' ); } self::redirect( 'status', 'billing_portal_error' ); } $url = is_array( $result ) && ! empty( $result['url'] ) ? $result['url'] : ''; if ( '' === $url ) { self::redirect( 'status', 'billing_portal_error' ); } // Ο προορισμός είναι εξωτερικός (billing.stripe.com), οπότε το // wp_safe_redirect() θα τον έκοβε — εκτός αν δηλώσουμε ρητά τον host // ως επιτρεπτό. Αυτός είναι ο σωστός τρόπος: κρατάμε την προστασία // του WordPress και ανοίγουμε ΜΟΝΟ το ένα domain που χρειαζόμαστε, // αντί να την παρακάμψουμε με wp_redirect(). add_filter( 'allowed_redirect_hosts', array( __CLASS__, 'allow_stripe_redirect_host' ) ); wp_safe_redirect( esc_url_raw( $url ) ); exit; } /** * Επιτρέπει ΜΟΝΟ τον host του Customer Portal του Stripe στο * wp_safe_redirect(). Προστίθεται τη στιγμή της ανακατεύθυνσης και όχι * καθολικά, ώστε να μην είναι ανοιχτός για κανέναν άλλο κώδικα. * * @param array $hosts Επιτρεπτοί hosts. * @return array */ public static function allow_stripe_redirect_host( $hosts ) { $hosts = is_array( $hosts ) ? $hosts : array(); $hosts[] = 'billing.stripe.com'; return $hosts; } /* ----------------------------------------------------------------- * Διαχειριστής συνωνύμων (καρτέλα «Συνώνυμα») * * Η αποθήκευση είναι στην πραγματικότητα το ΙΔΙΟ πεδίο `synonyms` (μία * ομάδα ανά γραμμή, όροι χωρισμένοι με κόμμα) που διαβάζει/γράφει ήδη * το ακατέργαστο textarea της καρτέλας Ρυθμίσεις — απλώς εδώ το * παρουσιάζουμε/επεξεργαζόμαστε ομάδα-ομάδα. Καμία νέα ρύθμιση δεν * προστίθεται, άρα καμία αλλαγή στο Addsynapse_Search_Settings::defaults() χρειάζεται. * --------------------------------------------------------------- */ /** * Μετατρέπει το ακατέργαστο κείμενο συνωνύμων σε λίστα ομάδων (array από * arrays όρων). Κενές γραμμές και κενοί όροι αγνοούνται. * * @param string $text * @return array[] */ public static function parse_synonyms_text( $text ) { $groups = array(); $lines = preg_split( '/\r\n|\r|\n/', (string) $text ); foreach ( (array) $lines as $line ) { $terms = array(); foreach ( explode( ',', $line ) as $term ) { $term = trim( $term ); if ( '' !== $term ) { $terms[] = $term; } } if ( ! empty( $terms ) ) { $groups[] = $terms; } } return $groups; } /** * Ο αντίστροφος μετασχηματισμός του parse_synonyms_text(): λίστα ομάδων -> κείμενο. * * @param array[] $groups * @return string */ public static function build_synonyms_text( array $groups ) { $lines = array(); foreach ( $groups as $group ) { $terms = array(); foreach ( (array) $group as $term ) { $term = trim( (string) $term ); if ( '' !== $term ) { $terms[] = $term; } } if ( ! empty( $terms ) ) { $lines[] = implode( ', ', $terms ); } } return implode( "\n", $lines ); } /** * Ανακατασκευάζει ομάδες συνωνύμων από τη ΦΥΣΙΚΗ μορφή του Meilisearch * (όπως γυρίζει το GET /v1/settings): term => [σχετικοί όροι], αμφίδρομα — * κάθε όρος μιας ομάδας δείχνει σε ΟΛΟΥΣ τους άλλους της ίδιας ομάδας * (βλ. backend/app/Http/Controllers/Api/V1/SettingsController::parseSynonyms()). * ΔΕΝ είναι λίστα από «όρος, όρος, όρος» strings — είναι γράφημα. * Κάθε συνεκτική συνιστώσα αυτού του γράφου = μία ομάδα. * * @param array $map term => array συναφών όρων. * @return array[] Λίστα ομάδων (arrays από strings). */ public static function synonyms_map_to_groups( array $map ) { $adjacency = array(); // normalized term -> [normalized term => true]. $display = array(); // normalized term -> αρχικό κείμενο για εμφάνιση. $register = function ( $term ) use ( &$display ) { $term = trim( (string) $term ); if ( '' === $term ) { return null; } $norm = function_exists( 'mb_strtolower' ) ? mb_strtolower( $term ) : strtolower( $term ); if ( ! isset( $display[ $norm ] ) ) { $display[ $norm ] = $term; } return $norm; }; foreach ( $map as $term => $related ) { $norm = $register( $term ); if ( null === $norm ) { continue; } if ( ! isset( $adjacency[ $norm ] ) ) { $adjacency[ $norm ] = array(); } if ( ! is_array( $related ) ) { continue; } foreach ( $related as $other ) { if ( ! is_scalar( $other ) ) { continue; } $other_norm = $register( $other ); if ( null === $other_norm ) { continue; } $adjacency[ $norm ][ $other_norm ] = true; $adjacency[ $other_norm ][ $norm ] = true; if ( ! isset( $adjacency[ $other_norm ] ) ) { $adjacency[ $other_norm ] = array(); } } } $visited = array(); $groups = array(); foreach ( array_keys( $adjacency ) as $start ) { if ( isset( $visited[ $start ] ) ) { continue; } $queue = array( $start ); $component = array(); while ( ! empty( $queue ) ) { $current = array_shift( $queue ); if ( isset( $visited[ $current ] ) ) { continue; } $visited[ $current ] = true; $component[] = isset( $display[ $current ] ) ? $display[ $current ] : $current; foreach ( array_keys( isset( $adjacency[ $current ] ) ? $adjacency[ $current ] : array() ) as $neighbor ) { if ( ! isset( $visited[ $neighbor ] ) ) { $queue[] = $neighbor; } } } if ( count( $component ) >= 2 ) { $groups[] = $component; } } return $groups; } /** * Συγκρίνει δύο λίστες ομάδων συνωνύμων ΑΓΝΟΩΝΤΑΣ σειρά όρων μέσα σε * ομάδα, σειρά ομάδων, και πεζά/κεφαλαία — γιατί το Meilisearch * (synonyms_map_to_groups) δεν διατηρεί ΚΑΜΙΑ από τις δύο, ενώ το * τοπικό textarea τις διατηρεί. Χωρίς αυτό, η οθόνη θα έδειχνε * «απόκλιση» ακόμα και όταν το μόνο που άλλαξε είναι η σειρά. * * @param array[] $a * @param array[] $b * @return bool */ public static function synonyms_groups_equal( array $a, array $b ) { $normalize = function ( array $groups ) { $out = array(); foreach ( $groups as $group ) { $terms = array(); foreach ( (array) $group as $term ) { $term = trim( (string) $term ); if ( '' !== $term ) { $terms[] = function_exists( 'mb_strtolower' ) ? mb_strtolower( $term ) : strtolower( $term ); } } sort( $terms ); if ( ! empty( $terms ) ) { $out[] = implode( '|', $terms ); } } sort( $out ); return $out; }; return $normalize( $a ) === $normalize( $b ); } /** * Αναλύει το `terms` του POST (κείμενο χωρισμένο με κόμμα) σε καθαρή, * χωρίς διπλότυπα λίστα όρων. * * @param string $raw * @return string[] */ private static function parse_terms_field( $raw ) { $terms = array(); foreach ( explode( ',', (string) $raw ) as $term ) { $term = trim( sanitize_text_field( $term ) ); if ( '' !== $term && ! in_array( $term, $terms, true ) ) { $terms[] = $term; } } return $terms; } /** * Το κείμενο συνωνύμων που θα χρησιμοποιηθεί ως ΒΑΣΗ για add/update/delete: * η πραγματική κατάσταση του δείκτη (GET /v1/settings), αν είναι διαθέσιμη — * αλλιώς το τελευταίο τοπικά αποθηκευμένο. * * 🔴 Σημαντικό ΓΙΑΤΙ: η οθόνη (views/synonyms.php) δείχνει επίσης πρώτα την * κατάσταση του δείκτη, όχι το τοπικό option (μπορεί να έχουν αποκλίνει — * βλ. CLAUDE.md). Αν εδώ βασιζόμασταν στο τοπικό ενώ η οθόνη έδειχνε το * απομακρυσμένο, τα group_index θα αντιστοιχούσαν σε ΛΑΘΟΣ ομάδες και μια * «επεξεργασία» θα άλλαζε σιωπηλά διαφορετική ομάδα από αυτήν που έβλεπε * ο χρήστης. Χρησιμοποιούμε πάντα την ίδια πηγή με αυτήν που ζωγραφίστηκε. * * @return string */ private static function get_authoritative_synonyms_text() { if ( class_exists( 'Addsynapse_Search_License' ) && method_exists( 'Addsynapse_Search_License', 'is_active' ) ) { try { $active = Addsynapse_Search_License::is_active(); } catch ( \Throwable $e ) { $active = false; } if ( $active && class_exists( 'Addsynapse_Search_Api_Client' ) && method_exists( 'Addsynapse_Search_Api_Client', 'get_settings' ) ) { try { $result = Addsynapse_Search_Api_Client::get_settings(); } catch ( \Throwable $e ) { $result = null; } if ( is_array( $result ) && isset( $result['synonyms'] ) && is_array( $result['synonyms'] ) ) { return self::build_synonyms_text( self::synonyms_map_to_groups( $result['synonyms'] ) ); } } } return class_exists( 'Addsynapse_Search_Settings' ) ? (string) Addsynapse_Search_Settings::get( 'synonyms', '' ) : ''; } /** * Αποθηκεύει μια νέα λίστα ομάδων συνωνύμων: πρώτα τοπικά (Addsynapse_Search_Settings), * μετά στέλνει στο API. Αν η αποστολή στο API αποτύχει, ΔΕΝ λέμε * «αποθηκεύτηκε» — ίδια πειθαρχία με sync_settings_to_server() (βλ. εκεί * γιατί: CLAUDE.md § Έγινε ήδη μία φορά). Τερματίζει πάντα με redirect. * * @param array[] $groups * @param string $success_notice * @return void */ private static function persist_synonym_groups( array $groups, $success_notice ) { $text = self::build_synonyms_text( $groups ); if ( class_exists( 'Addsynapse_Search_Settings' ) ) { try { Addsynapse_Search_Settings::set( 'synonyms', $text ); } catch ( \Throwable $e ) { self::redirect( 'synonyms', 'synonym_sync_error' ); } } if ( is_wp_error( self::sync_settings_to_server() ) ) { self::redirect( 'synonyms', 'synonym_sync_error' ); } self::redirect( 'synonyms', $success_notice ); } public static function handle_add_synonym_group() { self::verify_request( 'addsynapse_search_synonym_group_action', 'addsynapse_search_nonce', 'synonyms' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- η επαλήθευση nonce έγινε στη self::verify_request() παραπάνω. $terms = isset( $_POST['terms'] ) ? self::parse_terms_field( sanitize_text_field( wp_unslash( $_POST['terms'] ) ) ) : array(); if ( count( $terms ) < 2 ) { self::redirect( 'synonyms', 'synonym_min_terms' ); } $text = self::get_authoritative_synonyms_text(); $groups = self::parse_synonyms_text( $text ); $groups[] = $terms; self::persist_synonym_groups( $groups, 'synonym_added' ); } public static function handle_update_synonym_group() { self::verify_request( 'addsynapse_search_synonym_group_action', 'addsynapse_search_nonce', 'synonyms' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- επαληθεύτηκε ήδη στη self::verify_request() παραπάνω. $index = isset( $_POST['group_index'] ) ? intval( wp_unslash( $_POST['group_index'] ) ) : -1; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- επαληθεύτηκε ήδη στη self::verify_request() παραπάνω. $terms = isset( $_POST['terms'] ) ? self::parse_terms_field( sanitize_text_field( wp_unslash( $_POST['terms'] ) ) ) : array(); if ( count( $terms ) < 2 ) { self::redirect( 'synonyms', 'synonym_min_terms' ); } $text = self::get_authoritative_synonyms_text(); $groups = self::parse_synonyms_text( $text ); if ( $index < 0 || ! array_key_exists( $index, $groups ) ) { self::redirect( 'synonyms', 'synonym_not_found' ); } $groups[ $index ] = $terms; self::persist_synonym_groups( $groups, 'synonym_updated' ); } public static function handle_delete_synonym_group() { self::verify_request( 'addsynapse_search_synonym_group_action', 'addsynapse_search_nonce', 'synonyms' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- επαληθεύτηκε ήδη στη self::verify_request() παραπάνω. $index = isset( $_POST['group_index'] ) ? intval( wp_unslash( $_POST['group_index'] ) ) : -1; $text = self::get_authoritative_synonyms_text(); $groups = self::parse_synonyms_text( $text ); if ( $index < 0 || ! array_key_exists( $index, $groups ) ) { self::redirect( 'synonyms', 'synonym_not_found' ); } unset( $groups[ $index ] ); $groups = array_values( $groups ); self::persist_synonym_groups( $groups, 'synonym_deleted' ); } /** * AJAX: προτάσεις προϊόντων για έναν όρο χωρίς αποτέλεσμα («τι μπορεί να * εννοούσαν;»), μέσω Addsynapse_Search_Api_Client::suggest(). Πάντα wp_send_json_* * (ΠΟΤΕ fatal) — το backend μπορεί να μην είναι έτοιμο ή να γυρίσει σφάλμα. * * @return void */ public static function ajax_suggest() { check_ajax_referer( 'addsynapse_search_suggest', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'addsynapse-search-filter' ) ), 403 ); } $term = isset( $_POST['term'] ) ? sanitize_text_field( wp_unslash( $_POST['term'] ) ) : ''; if ( '' === trim( $term ) ) { wp_send_json_error( array( 'message' => __( 'The search term is missing.', 'addsynapse-search-filter' ) ) ); } if ( ! class_exists( 'Addsynapse_Search_Api_Client' ) || ! method_exists( 'Addsynapse_Search_Api_Client', 'suggest' ) ) { wp_send_json_error( array( 'message' => __( 'The suggestions feature is not available right now.', 'addsynapse-search-filter' ) ) ); } try { $result = Addsynapse_Search_Api_Client::suggest( $term ); } catch ( \Throwable $e ) { wp_send_json_error( array( 'message' => __( 'Could not fetch suggestions right now. Please try again shortly.', 'addsynapse-search-filter' ) ) ); } if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ) ); } if ( ! is_array( $result ) ) { $result = array(); } $candidates_raw = isset( $result['candidates'] ) && is_array( $result['candidates'] ) ? $result['candidates'] : array(); $candidates = array(); foreach ( $candidates_raw as $candidate ) { if ( is_object( $candidate ) ) { $candidate = (array) $candidate; } if ( ! is_array( $candidate ) || empty( $candidate['title'] ) ) { continue; } $candidates[] = array( 'id' => isset( $candidate['id'] ) && is_scalar( $candidate['id'] ) ? sanitize_text_field( (string) $candidate['id'] ) : '', 'title' => sanitize_text_field( (string) $candidate['title'] ), 'score' => isset( $candidate['score'] ) && is_numeric( $candidate['score'] ) ? (float) $candidate['score'] : null, ); } wp_send_json_success( array( 'term' => $term, 'candidates' => $candidates, ) ); } /** * AJAX: "Run now" -- επεξεργάζεται ΜΙΑ παρτίδα προϊόντων ΣΥΓΧΡΟΝΑ (μέσα * στο ίδιο αίτημα) μέσω Addsynapse_Search_Indexer::run_batch_now(), και επιστρέφει * πραγματική πρόοδο. Το JS (admin.js) το καλεί επαναλαμβανόμενα, * προχωρώντας τη σελίδα, μέχρι `done: true`. * * 🔴 ΤΟ ΠΡΟΒΛΗΜΑ ΠΟΥ ΔΙΟΡΘΩΝΕΙ: το παλιό κουμπί απλώς προγραμμάτιζε στο * Action Scheduler («θα τρέξει σύντομα στο παρασκήνιο») -- υπόσχεση, όχι * γεγονός. Το Action Scheduler στηρίζεται στο WP-Cron, που είναι * απενεργοποιημένο σε αρκετά hosts ή τρέχει μόνο σε επίσκεψη σελίδας. Αυτό * το endpoint δεν εξαρτάται ΚΑΘΟΛΟΥ από cron -- η δουλειά γίνεται μέσα * στο ίδιο το αίτημα AJAX. * * Πάντα wp_send_json_* (ΠΟΤΕ σιωπηλή διακοπή, βλ. ανάθεση) -- κάθε σφάλμα * (χωρίς άδεια, 402 plan_limit, δίκτυο/5xx) επιστρέφει καθαρό μήνυμα ώστε * το JS να σταματήσει και να το δείξει. * * @return void */ public static function ajax_run_batch() { check_ajax_referer( 'addsynapse_search_run_batch', 'nonce' ); if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array( 'message' => __( 'You do not have permission to perform this action.', 'addsynapse-search-filter' ) ), 403 ); } if ( ! class_exists( 'Addsynapse_Search_Indexer' ) ) { wp_send_json_error( array( 'message' => __( 'Indexing is not available right now.', 'addsynapse-search-filter' ) ) ); } if ( ! class_exists( 'Addsynapse_Search_License' ) || ! Addsynapse_Search_License::is_active() ) { wp_send_json_error( array( 'message' => __( 'There is no active license.', 'addsynapse-search-filter' ), 'code' => 'license_inactive', ) ); } $page = isset( $_POST['page'] ) ? max( 1, absint( wp_unslash( $_POST['page'] ) ) ) : 1; // Η γενιά του περάσματος (τη δίνει η σελίδα 1, το JS τη στέλνει πίσω) // — χωρίς αυτή δεν γίνεται σκούπα ορφανών στο τέλος (βλ. run_batch_now()). $run = isset( $_POST['run'] ) ? preg_replace( '/[^A-Za-z0-9]/', '', sanitize_text_field( wp_unslash( $_POST['run'] ) ) ) : ''; // Δρομέας keyset (τελευταίο product id της προηγούμενης σελίδας). Χωρίς // αυτόν (παλιό JS στην cache του browser) → null → offset, όπως πριν. $cursor = isset( $_POST['cursor'] ) && '' !== sanitize_text_field( wp_unslash( $_POST['cursor'] ) ) ? absint( wp_unslash( $_POST['cursor'] ) ) : null; try { $result = Addsynapse_Search_Indexer::instance()->run_batch_now( $page, Addsynapse_Search_Indexer::AJAX_BATCH_SIZE, $run, $cursor ); } catch ( \Throwable $e ) { wp_send_json_error( array( 'message' => __( 'An unexpected error occurred while indexing. Please try again.', 'addsynapse-search-filter' ), 'code' => 'exception', ) ); } if ( isset( $result['error'] ) && is_wp_error( $result['error'] ) ) { $data = $result['error']->get_error_data(); $is_limit = is_array( $data ) && isset( $data['status'] ) && 402 === (int) $data['status']; if ( $is_limit ) { // Μήνυμα ΤΟΠΙΚΟ (γλώσσα χρήστη), ποτέ το `message` του server. $limit_code = ( isset( $data['code'] ) && 0 === strpos( (string) $data['code'], 'record_limit' ) ) ? 'record_limit' : 'plan_limit'; $msg = self::limit_message( $limit_code, isset( $data['limit'] ) ? $data['limit'] : null, isset( $data['current'] ) ? $data['current'] : null ); wp_send_json_error( array( 'message' => $msg['text'], 'code' => 'plan_limit', 'limit' => isset( $data['limit'] ) ? $data['limit'] : null, 'current' => isset( $data['current'] ) ? $data['current'] : null, 'link_url' => esc_url_raw( $msg['url'] ), 'link_label' => $msg['link_label'], ) ); } wp_send_json_error( array( 'message' => $result['error']->get_error_message(), 'code' => 'error', ) ); } // Η επόμενη ανάγνωση της καρτέλας «Κατάσταση» (indexed_count μέσω // /v1/stats) πρέπει να δείχνει φρέσκα νούμερα, όχι την cached τιμή // 15 λεπτών -- ειδικά χρήσιμο όταν η παρτίδα μόλις ολοκλήρωσε ΟΛΑ τα προϊόντα. if ( ! empty( $result['done'] ) && class_exists( 'Addsynapse_Search_Analytics' ) ) { Addsynapse_Search_Analytics::clear_cache(); } wp_send_json_success( array( 'page' => $page, 'next_page' => isset( $result['next_page'] ) ? $result['next_page'] : null, 'processed' => isset( $result['processed'] ) ? (int) $result['processed'] : 0, 'total' => isset( $result['total'] ) ? (int) $result['total'] : 0, 'done' => ! empty( $result['done'] ), 'run' => isset( $result['generation'] ) ? (string) $result['generation'] : '', 'cursor' => isset( $result['cursor'] ) ? (int) $result['cursor'] : 0, // Διαγραφές που απέτυχαν (θα ξαναδοκιμαστούν) — ΠΟΤΕ σιωπηλά (audit F03). 'warning' => isset( $result['warning'] ) ? (string) $result['warning'] : '', ) ); } /** * Στέλνει ρυθμίσεις δείκτη (προς το παρόν: συνώνυμα) στο backend API * μέσω Addsynapse_Search_Api_Client::settings(), ώστε να εφαρμοστούν στον * πραγματικό δείκτη Meilisearch — ΟΧΙ μόνο στο τοπικό option. * * 🔴 ΔΙΟΡΘΩΝΕΙ ΓΝΩΣΤΟ ΚΕΝΟ (CLAUDE.md § Έγινε ήδη μία φορά, συνώνυμα): * το `Addsynapse_Search_Settings` είχε το πεδίο `synonyms`, το backend * `Meili::provisionIndex()` δεχόταν ήδη παράμετρο synonyms, αλλά ΚΑΝΕΝΑ * endpoint δεν τα συνέδεε — ο πελάτης έβλεπε «αποθηκεύτηκε» και ο * δείκτης δεν άλλαζε ποτέ. * * Καλείται (1) με κάθε αποθήκευση ρυθμίσεων, (2) μετά από επιτυχή * ενεργοποίηση άδειας, (3) πριν την πλήρη επαναδεικτοδότηση. * * Σιωπηλή επιτυχία (true) αν δεν υπάρχει ακόμα ενεργή άδεια/shop — * δεν υπάρχει τίποτα να συγχρονιστεί στον server ακόμα, δεν είναι λάθος. * * @return true|WP_Error */ private static function sync_settings_to_server() { if ( ! class_exists( 'Addsynapse_Search_License' ) || ! method_exists( 'Addsynapse_Search_License', 'is_active' ) ) { return true; } try { $active = Addsynapse_Search_License::is_active(); } catch ( \Throwable $e ) { return true; } if ( ! $active ) { return true; } if ( ! class_exists( 'Addsynapse_Search_Api_Client' ) || ! method_exists( 'Addsynapse_Search_Api_Client', 'settings' ) || ! class_exists( 'Addsynapse_Search_Settings' ) ) { return true; } $synonyms = (string) Addsynapse_Search_Settings::get( 'synonyms', '' ); try { // 🔴 audit F09: και η επιλογή στατιστικών — ο server απορρίπτει // τα beacons καταστήματος που δεν συναίνεσε (ανοιχτές καρτέλες / // cached σελίδες με παλιό config συνεχίζουν να στέλνουν). $result = Addsynapse_Search_Api_Client::settings( array( 'synonyms' => $synonyms, 'send_search_stats' => (bool) Addsynapse_Search_Settings::get( 'send_search_stats', false ), 'semantic_search' => (bool) Addsynapse_Search_Settings::get( 'semantic_search', false ), ) ); } catch ( \Throwable $e ) { return new WP_Error( 'addsynapse_search_settings_sync_exception', $e->getMessage() ); } return is_wp_error( $result ) ? $result : true; } /* ----------------------------------------------------------------- * Βοηθητικά για τα views * --------------------------------------------------------------- */ /** * Κατάσταση άδειας από Addsynapse_Search_License::status(), αμυντικά (array ή object). * * @return array */ public static function get_license_status() { if ( class_exists( 'Addsynapse_Search_License' ) && method_exists( 'Addsynapse_Search_License', 'status' ) ) { try { $status = Addsynapse_Search_License::status(); } catch ( \Throwable $e ) { return array(); } if ( is_object( $status ) ) { $status = (array) $status; } if ( is_array( $status ) ) { return $status; } } return array(); } public static function is_license_active() { if ( class_exists( 'Addsynapse_Search_License' ) && method_exists( 'Addsynapse_Search_License', 'is_active' ) ) { try { return (bool) Addsynapse_Search_License::is_active(); } catch ( \Throwable $e ) { return false; } } return false; } /** * Νέα εγκατάσταση: ούτε σύνδεση AddSynapse ούτε (παλιό) κλειδί. Μόνο τοπικός * έλεγχος options — καμία κλήση δικτύου. * * @return bool */ public static function needs_connect() { $key = class_exists( 'Addsynapse_Search_Settings' ) ? trim( (string) Addsynapse_Search_Settings::get( 'license_key', '' ) ) : ''; if ( '' !== $key ) { return false; } return ! ( class_exists( 'Addsynapse_Search_Hub' ) && Addsynapse_Search_Hub::is_connected() ); } /** * Φιλικό «Connect to AddSynapse to start» αντί για ωμό σφάλμα «No license key…» * στις καρτέλες που χρειάζονται σύνδεση (Status/Statistics). * * @param string $context Τι ενεργοποιεί η σύνδεση σε αυτή την καρτέλα. * @return void */ public static function render_connect_prompt( $context = '' ) { $url = self::settings_url( 'connection' ); ?>