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 '
';
foreach ( $tabs as $slug => $label ) {
$url = add_query_arg( array( 'page' => self::MENU_SLUG, 'tab' => $slug ), admin_url( 'admin.php' ) );
$class = 'nav-tab' . ( $tab === $slug ? ' nav-tab-active' : '' );
printf( '%s ', esc_url( $url ), esc_attr( $class ), esc_html( $label ) );
}
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( '', 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( '', 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' );
?>