ουρά (Action Scheduler) -> API, ποτέ σύγχρονα. * * @package Addsynapse_Search_Filter */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Addsynapse_Search_Indexer * * ΚΑΝΟΝΑΣ: κανένα hook δεν κάνει ποτέ σύγχρονη κλήση δικτύου. Κάθε hook απλώς * προγραμματίζει μια ενέργεια στο Action Scheduler (που έρχεται με το WooCommerce). * Η πραγματική αποστολή στο API γίνεται μέσα στον background processor. */ class Addsynapse_Search_Indexer { const HOOK_SYNC_PRODUCT = 'addsynapse_search_sync_product'; const HOOK_DELETE_PRODUCT = 'addsynapse_search_delete_product'; const HOOK_REINDEX_ALL = 'addsynapse_search_reindex_all'; const HOOK_NIGHTLY = 'addsynapse_search_nightly_safety_sync'; const GROUP = 'addsynapse-search'; /** 0.6.3: ενέργεια ανάκτησης δείκτη που ο κόμβος ξαναέφτιαξε άδειο (needs_reindex). */ const HOOK_RECOVER_INDEX = 'addsynapse_search_recover_index'; /** Option-σημάδι της ανάκτησης: array( since, state ). Σβήνεται στην πρώτη επιτυχή ingest. */ const OPTION_NEEDS_REINDEX = 'addsynapse_search_needs_reindex'; /** Μέσα σε αυτό το διάστημα ένα νέο needs_reindex ΔΕΝ ξαναπρογραμματίζει (φρένο βρόχου). */ const NEEDS_REINDEX_GUARD = 6 * HOUR_IN_SECONDS; const BATCH_SIZE = 100; /** * Μέγεθος παρτίδας για το ΣΥΓΧΡΟΝΟ "Run now" (AJAX, βλ. Addsynapse_Search_Admin::ajax_run_batch() * / run_batch_now() παρακάτω) -- πολύ μικρότερο από το BATCH_SIZE του * cron-based reindex, ώστε κάθε αίτημα να επιστρέφει μέσα σε λίγα * δευτερόλεπτα και να μην χτυπά χρονικά όρια PHP/web server (μετρημένο * στο noimatest: βλ. CLAUDE.md/WORKLOG για τον πραγματικό χρόνο ανά παρτίδα). */ const AJAX_BATCH_SIZE = 25; /** * Transient που κρατά «χτυπήσαμε το όριο πακέτου» (402 `plan_limit` από * POST /v1/documents, βλ. backend DocumentsController::rejectIfOverPlanLimit()). * Το διαβάζει η καρτέλα «Κατάσταση» (views/status.php) για να δείξει καθαρό * μήνυμα -- ΠΟΤΕ σιωπηλή αποτυχία, βλ. handle_ingest_result(). * * @var string */ const TRANSIENT_PLAN_LIMIT = 'addsynapse_search_plan_limit_hit'; /** * Τα ονόματα ιδιοτήτων που έχουν εμφανιστεί στον κατάλογο («Χρώμα», * «Μέγεθος»). Μαθαίνονται κατά τη δεικτοδότηση — βλ. * remember_attribute_labels() για το γιατί δεν αρκεί το WooCommerce. */ const OPTION_ATTRIBUTE_LABELS = 'addsynapse_search_attribute_labels'; /** * Κατάσταση της τρέχουσας/τελευταίας ΠΛΗΡΟΥΣ επαναδεικτοδότησης: * array( generation, sent, failed, started ). * * 🔴 ΣΚΟΥΠΑ ΟΡΦΑΝΩΝ (0.3.4): κάθε έγγραφο παίρνει `sync_gen` = η * γενιά της τρέχουσας πλήρους επαναδεικτοδότησης (βλ. apply_document_filters()). * Μόλις ένα πλήρες πέρασμα τελειώσει ΧΩΡΙΣ κανένα σφάλμα, ό,τι στον * δείκτη ΔΕΝ φέρει αυτή τη γενιά δεν παρήχθη από τον τρέχοντα κατάλογο * → ορφανό (προϊόν σβησμένο με SQL/μαζικό import, παρακάμπτοντας τα * hooks) → σβήνεται από τον server με ΕΝΑ delete-by-filter (βλ. * finish_full_run() και backend DocumentsController::sweep()). */ const OPTION_SYNC_RUN = 'addsynapse_search_sync_run'; /** Action Scheduler: επανάληψη σκούπας όταν ο server απαντά «όχι ακόμα» (409). */ const HOOK_SWEEP = 'addsynapse_search_sweep_orphans'; /** Μέγιστες επαναλήψεις σκούπας για το ίδιο πέρασμα. */ const SWEEP_MAX_ATTEMPTS = 3; /** * 🔴 ΑΝΘΕΚΤΙΚΕΣ ΔΙΑΓΡΑΦΕΣ (audit F03): ids εγγράφων που ΠΡΕΠΕΙ να φύγουν * από τον δημόσιο δείκτη αλλά δεν έχει επιβεβαιωθεί ακόμα η διαγραφή. * Πριν, ένα αποτυχημένο DELETE (API πεσμένο, timeout, άδεια που δεν * ελέγχθηκε εκείνη τη στιγμή) απλώς καταγραφόταν — ο τίτλος/τιμή ενός * προϊόντος σε κάδο/πρόχειρο έμενε αναζητήσιμος επ' αόριστον. * * Σχήμα: array( ids => array( doc_id => πρώτη φορά ), attempts, last_error, * last_attempt, overflow ). Φραγμένο σε PENDING_DELETES_MAX ids· πέρα από * αυτό κρατάμε τα νεότερα και σημαδεύουμε overflow (η σκούπα του επόμενου * πλήρους περάσματος καθαρίζει ό,τι χάθηκε). */ const OPTION_PENDING_DELETES = 'addsynapse_search_pending_deletes'; /** Action Scheduler: άδειασμα της λίστας εκκρεμών διαγραφών (με backoff). */ const HOOK_FLUSH_DELETES = 'addsynapse_search_flush_deletes'; /** Ανώτατο πλήθος εκκρεμών ids. */ const PENDING_DELETES_MAX = 5000; /** Γνωστές ταξινομίες μάρκας, με σειρά προτίμησης. */ const BRAND_TAXONOMIES = array( 'product_brand', 'pwb-brand', 'yith_product_brand' ); /** * Action Scheduler: επανασυγχρονισμός των προϊόντων μιας κατηγορίας/μάρκας * (σελιδοποιημένος) όταν αλλάζει ο γονέας ή το όνομα του όρου — αλλιώς * τα `category_path_ids`/`categories` μένουν παλιά ως το νυχτερινό (audit F06). */ const HOOK_RESYNC_TERMS = 'addsynapse_search_resync_terms'; /** * 🔴 audit F03: πρόθεμα των γραμμών wp_options της λίστας διαγραφών — * ΜΙΑ γραμμή ανά id εγγράφου (βλ. pdel_put()). Το OPTION_PENDING_DELETES * κρατά πλέον ΜΟΝΟ μεταδεδομένα (προσπάθειες/τελευταίο σφάλμα). */ const PDEL_PREFIX = 'addsynapse_search_pdel_'; /** Πόσα εκκρεμή ids διαβάζει ΜΙΑ εκτέλεση του flush. */ const FLUSH_READ_LIMIT = 1000; /** Ανώτατα αιτήματα DELETE ανά εκτέλεση flush (διχοτόμηση 413/422 περιλαμβάνεται). */ const FLUSH_MAX_REQUESTS = 60; /** Όσα δέχεται ο server σε ΕΝΑ DELETE /v1/documents (DocumentsController::MAX_BATCH). */ const DELETE_CHUNK = 100; /** Ανώτατο διάστημα ανάμεσα σε επαναλήψεις (backoff cap). */ const FLUSH_MAX_DELAY = 21600; /** * Μέγιστο μήκος (χαρακτήρες) του αποσπάσματος περιγραφής που μπαίνει * στο συνδυασμένο "searchable text" (μεταγραφή/stemming) — ΟΧΙ το ίδιο * όριο με το πλήρες πεδίο `description` (1000 χαρακτήρες, βλ. * build_document()). Η περιγραφή είναι το λιγότερο αξιόπιστο κομμάτι * (marketing copy, μπορεί να περιέχει άσχετο κείμενο) και δεν πρέπει * να καταναλώνει τον περισσότερο χώρο από το όριο tokens της * greeklish_variants() (Addsynapse_Search_Transliterate::MAX_GREEKLISH_TOKENS) εις * βάρος τίτλου/κατηγοριών/brand. */ const DESCRIPTION_EXCERPT_LENGTH = 300; /** * Ελάχιστο μήκος (χαρακτήρες) μιας attribute ΤΙΜΗΣ για να θεωρηθεί * "λεκτική" (και άρα να περάσει από greeklish/stems) αντί για "κωδικός". * * ΓΥΡΟΣ 6 (21/09/2026 — διόρθωση προηγούμενης οδηγίας): η προηγούμενη * σύμβαση έλεγε «τα attributes ΔΕΝ περνούν από greeklish/stems» — σωστό * για αριθμούς/κωδικούς ("42", "K14"), ΛΑΘΟΣ για λέξεις ("Κόκκινο", * "Δερμάτινο"): «κόκκινο παπούτσι» έπρεπε να δουλεύει και σε greeklish * ("kokkino papoutsi") αλλά η τιμή "Κόκκινο" δεν συνεισέφερε πουθενά * στη μεταγραφή. * * Κανόνας διαχωρισμού (βλ. is_wordlike_attribute_value()): αν η τιμή * περιέχει έστω ένα γράμμα (ελληνικό ή λατινικό) ΚΑΙ έχει μήκος >= αυτό * το κατώφλι, είναι "λεκτική". Το «XL» είναι το οριακό παράδειγμα από * την ανάθεση: έχει γράμματα αλλά είναι πολύ κοντό — η μεταγραφή δεν * βοηθάει σε τόσο κοντά strings (και "xl"/"χλ" δεν είναι πραγματικές * λέξεις) — γι' αυτό το κατώφλι είναι 4, όχι 1. */ const ATTRIBUTE_WORDLIKE_MIN_LENGTH = 4; /** * Μέγιστο πλήθος τιμών στο πεδίο `available_options` όταν υπάρχει ΜΙΑ * μόνο διάσταση attribute (επίπεδη λίστα, βλ. collect_flat_available_options()). * Πάνω από αυτό, κόβεται και προστίθεται ένα τελευταίο στοιχείο «+N more» * — CLAUDE.md ζητά ρητά να μείνει ΜΙΚΡΟΣ ο δείκτης. */ const AVAILABLE_OPTIONS_MAX_VALUES = 15; /** * Μέγιστο πλήθος δεύτερης-διάστασης τιμών ΑΝΑ ομάδα (π.χ. νούμερα ανά * χρώμα) στη δομή συνδυασμών (βλ. collect_combo_available_options()). */ const AVAILABLE_OPTIONS_MAX_SECONDARY_VALUES = 10; /** * Μέγιστο πλήθος πρώτης-διάστασης ομάδων (π.χ. χρώματα) στη δομή * συνδυασμών -- πάνω από αυτό, οι υπόλοιπες ομάδες κόβονται και * προστίθεται μία marker-only ομάδα «+N more» (βλ. * collect_combo_available_options()). */ const AVAILABLE_OPTIONS_MAX_PRIMARY_GROUPS = 6; /** * Singleton instance. * * @var Addsynapse_Search_Indexer|null */ private static $instance = null; /** * Επιστρέφει (και δημιουργεί αν χρειάζεται) το singleton, καταχωρώντας τα hooks. * * @return Addsynapse_Search_Indexer */ public static function instance() { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Καταχωρεί όλα τα WooCommerce/WP hooks + τα Action Scheduler hooks. */ private function __construct() { // --- Συγχρονισμός καταλόγου: μόνο enqueue, ποτέ δίκτυο εδώ. --- add_action( 'woocommerce_update_product', array( $this, 'queue_product' ) ); add_action( 'woocommerce_new_product', array( $this, 'queue_product' ) ); add_action( 'woocommerce_product_set_stock', array( $this, 'queue_product_from_object' ) ); add_action( 'woocommerce_variation_set_stock', array( $this, 'queue_variation_parent_from_object' ) ); add_action( 'woocommerce_product_object_updated_props', array( $this, 'queue_product_object_updated' ), 10, 2 ); add_action( 'transition_post_status', array( $this, 'handle_transition_post_status' ), 10, 3 ); add_action( 'before_delete_post', array( $this, 'handle_delete_post' ), 10, 2 ); add_action( 'wp_trash_post', array( $this, 'handle_trash_post' ), 10, 2 ); add_action( 'edit_terms', array( $this, 'remember_term_before_edit' ), 10, 2 ); add_action( 'edited_term', array( $this, 'handle_term_edited' ), 10, 3 ); add_action( 'pre_delete_term', array( $this, 'handle_term_pre_delete' ), 10, 2 ); // 0.5.2: αλλαγές που ΔΕΝ περνούν από το WC CRUD (wp_update_post από // εισαγωγείς/μεταφράσεις/SEO plugins, wp_set_object_terms) και αλλαγή // τύπου προϊόντος. Όλα καταλήγουν στο ΙΔΙΟ de-dup enqueue. add_action( 'save_post_product', array( $this, 'handle_save_post_product' ), 10, 3 ); add_action( 'set_object_terms', array( $this, 'handle_set_object_terms' ), 10, 6 ); add_action( 'woocommerce_product_type_changed', array( $this, 'handle_product_type_changed' ), 10, 3 ); // --- Background processors (εδώ επιτρέπεται δίκτυο). --- add_action( self::HOOK_SYNC_PRODUCT, array( $this, 'process_sync_action' ) ); add_action( self::HOOK_DELETE_PRODUCT, array( $this, 'process_delete_action' ), 10, 2 ); add_action( self::HOOK_REINDEX_ALL, array( $this, 'process_reindex_page' ), 10, 4 ); add_action( self::HOOK_RECOVER_INDEX, array( __CLASS__, 'process_recover_index' ) ); add_action( self::HOOK_FLUSH_DELETES, array( $this, 'process_flush_deletes' ) ); add_action( self::HOOK_SWEEP, array( $this, 'process_sweep_action' ), 10, 3 ); add_action( self::HOOK_NIGHTLY, array( $this, 'process_nightly_sync' ) ); add_action( self::HOOK_RESYNC_TERMS, array( $this, 'process_resync_terms' ), 10, 4 ); } /* ----------------------------------------------------------------- * Hook handlers: ΜΟΝΟ enqueue, ποτέ κλήση API εδώ μέσα. * --------------------------------------------------------------- */ /** * `woocommerce_update_product` / `woocommerce_new_product` — δίνουν το product id. * * @param int $product_id Product id. * @return void */ public function queue_product( $product_id ) { $this->enqueue_product_sync( (int) $product_id ); } /** * `woocommerce_product_set_stock` — δίνει το ίδιο το WC_Product αντικείμενο. * * @param WC_Product $product Το προϊόν. * @return void */ public function queue_product_from_object( $product ) { if ( ! ( $product instanceof WC_Product ) ) { return; } $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id(); $this->enqueue_product_sync( (int) $id ); } /** * `woocommerce_variation_set_stock` — δίνει το variation· δεικτοδοτούμε τον γονιό. * * @param WC_Product_Variation $variation Η παραλλαγή. * @return void */ public function queue_variation_parent_from_object( $variation ) { if ( ! ( $variation instanceof WC_Product ) ) { return; } $parent_id = $variation->get_parent_id(); if ( $parent_id ) { $this->enqueue_product_sync( (int) $parent_id ); } } /** * `woocommerce_product_object_updated_props` — τιμές/ιδιότητες άλλαξαν. * * @param WC_Product $product Το προϊόν. * @param array $updated_props Ποιες ιδιότητες άλλαξαν (δεν μας χρειάζεται εδώ). * @return void */ public function queue_product_object_updated( $product, $updated_props ) { unset( $updated_props ); if ( ! ( $product instanceof WC_Product ) ) { return; } $id = $product->get_parent_id() ? $product->get_parent_id() : $product->get_id(); $this->enqueue_product_sync( (int) $id ); } /** * `save_post_product` — 0.5.2: αλλαγή τίτλου/slug/περιγραφής ΧΩΡΙΣ WC CRUD. * * Το `woocommerce_update_product` πυροδοτείται μόνο από `$product->save()`. * Ένα σκέτο wp_update_post() (εισαγωγείς, μεταφράσεις, SEO/bulk plugins, * Gutenberg/REST wp/v2) άλλαζε τον τίτλο στη βάση και ο δείκτης — γονέας * ΚΑΙ κάθε έγγραφο παραλλαγής, που κληρονομεί τον τίτλο — έμενε με τον * παλιό ως το νυχτερινό πέρασμα. Μόνο ενημέρωση δημοσιευμένου (η * δημοσίευση/απόσυρση περνά από το transition_post_status). * * @param int $post_id Post id. * @param WP_Post $post Το post. * @param bool $update Ενημέρωση (όχι δημιουργία). * @return void */ public function handle_save_post_product( $post_id, $post, $update ) { if ( ! $update || ! ( $post instanceof WP_Post ) || 'publish' !== $post->post_status || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { return; } $this->enqueue_product_sync( (int) $post_id ); } /** * `set_object_terms` — 0.5.2: κατηγορίες/μάρκες προϊόντος άλλαξαν ΧΩΡΙΣ WC * CRUD (wp_set_object_terms από εισαγωγείς/bulk plugins). Μόνο αν άλλαξε * πράγματι το σύνολο των όρων — το WC CRUD ξαναγράφει τους ίδιους όρους * σε κάθε save. * * @param int $object_id Object id. * @param array $terms Όροι (όπως δόθηκαν). * @param int[] $tt_ids Νέα term taxonomy ids. * @param string $taxonomy Ταξινομία. * @param bool $append Προσθήκη. * @param int[] $old_tt_ids Παλιά term taxonomy ids. * @return void */ public function handle_set_object_terms( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) { unset( $terms, $append ); if ( ! self::is_watched_taxonomy( (string) $taxonomy ) ) { return; } $new = array_map( 'intval', (array) $tt_ids ); $old = array_map( 'intval', (array) $old_tt_ids ); sort( $new ); sort( $old ); if ( $new === $old || 'product' !== get_post_type( $object_id ) || 'publish' !== get_post_status( $object_id ) ) { return; } $this->enqueue_product_sync( (int) $object_id ); } /** * `woocommerce_product_type_changed` — 0.5.2: με ακριβή αναζήτηση * παραλλαγών, ένα variable προϊόν έχει ΜΟΝΟ έγγραφα «γονέας-παραλλαγή». * Απλό → variable άφηνε το παλιό έγγραφο «» (παλιός τίτλος/τιμή/τύπος) * στον δείκτη ως το πλήρες πέρασμα· variable → απλό αφήνει τα «-» * αν κάποιο plugin κρατήσει τις παραλλαγές (φίλτρο * woocommerce_delete_variations_on_product_type_change). Μπαίνουν στη * λίστα διαγραφών — ο έλεγχος «ζωντανό;» του flush κρατά ό,τι ισχύει. * * @param WC_Product $product Το προϊόν (ήδη με τον νέο τύπο). * @param string $from Παλιός τύπος. * @param string $to Νέος τύπος. * @return void */ public function handle_product_type_changed( $product, $from, $to ) { if ( ! ( $product instanceof WC_Product ) || ! class_exists( 'Addsynapse_Search_Settings' ) || ! Addsynapse_Search_Settings::get( 'variation_indexing', false ) ) { return; } $id = (int) $product->get_id(); if ( 'variable' === $to && 'variable' !== $from ) { $this->queue_doc_deletes( array( (string) $id ) ); } elseif ( 'variable' === $from && 'variable' !== $to ) { $children = get_posts( array( 'post_type' => 'product_variation', 'post_parent' => $id, 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => -1, 'no_found_rows' => true, ) ); // Χωρίς το «» (πρώτο στοιχείο): ως απλό ξαναστέλνεται αμέσως. $this->queue_doc_deletes( array_slice( self::doc_ids_for( $id, (array) $children ), 1 ) ); } $this->enqueue_product_sync( $id ); } /** * `transition_post_status` — δημοσίευση/απόσυρση προϊόντος. * * @param string $new_status Νέο status. * @param string $old_status Παλιό status. * @param WP_Post $post Το post. * @return void */ public function handle_transition_post_status( $new_status, $old_status, $post ) { if ( $new_status === $old_status || ! isset( $post->post_type ) ) { return; } if ( 'product_variation' === $post->post_type ) { // Ενεργοποίηση/απενεργοποίηση (publish ↔ private) ή κάδος μιας // παραλλαγής: ξαναχτίζουμε τον γονέα, και με ακριβή αναζήτηση // παραλλαγών ΣΒΗΝΟΥΜΕ ρητά το δικό της έγγραφο — ο έλεγχος // «ζωντανό;» στο flush το κρατά αν τελικά είναι διαθέσιμη. $this->handle_variation_changed( (int) $post->ID, (int) $post->post_parent ); return; } if ( 'product' !== $post->post_type ) { return; } if ( 'publish' === $new_status ) { $this->enqueue_product_sync( (int) $post->ID ); } elseif ( 'publish' === $old_status ) { // Ήταν δημοσιευμένο, τώρα δεν είναι πια (draft/pending/trash/...): φεύγει από τον δείκτη. // Το προϊόν είναι ΑΚΟΜΑ φορτώσιμο εδώ (ο μόνος λόγος να άλλαξε // status είναι μια ενέργεια ΤΩΡΑ, όχι already-gone) -- πιάνουμε // τα children ΠΡΙΝ φύγει η ευκαιρία, βλ. σχόλιο στο // enqueue_product_delete()/task-brief §4 🔴 (variation ghosts). $this->enqueue_product_delete( (int) $post->ID, $this->get_variation_ids_for_product( (int) $post->ID ) ); } } /** * `before_delete_post` — οριστική διαγραφή. * * @param int $post_id Post id. * @param WP_Post|null $post Το post (από WP 5.5+). * @return void */ public function handle_delete_post( $post_id, $post = null ) { unset( $post ); $post_type = get_post_type( $post_id ); if ( 'product_variation' === $post_type ) { $this->handle_variation_changed( (int) $post_id, (int) wp_get_post_parent_id( $post_id ) ); return; } if ( 'product' !== $post_type ) { return; } // 🔴 ΠΡΕΠΕΙ να πιάσουμε τα variation ids ΤΩΡΑ, μέσα στο // before_delete_post (το προϊόν είναι ΑΚΟΜΑ στη βάση) -- μέχρι να // τρέξει το processor (async, Action Scheduler), το WordPress θα // έχει ήδη σβήσει ΚΑΙ το προϊόν ΚΑΙ τις variations του, οπότε // get_children() θα γύριζε άδειο και τα έγγραφα παραλλαγών θα // έμεναν ΦΑΝΤΑΣΜΑΤΑ στον δείκτη (task-brief §4 🔴). $this->enqueue_product_delete( (int) $post_id, $this->get_variation_ids_for_product( (int) $post_id ) ); } /** * `wp_trash_post` — μεταφορά στον κάδο. * * @param int $post_id Post id. * @param WP_Post|null $post Το post. * @return void */ public function handle_trash_post( $post_id, $post = null ) { unset( $post ); $post_type = get_post_type( $post_id ); if ( 'product_variation' === $post_type ) { $this->handle_variation_changed( (int) $post_id, (int) wp_get_post_parent_id( $post_id ) ); return; } if ( 'product' !== $post_type ) { return; } $this->enqueue_product_delete( (int) $post_id, $this->get_variation_ids_for_product( (int) $post_id ) ); } /** * Τα (τρέχοντα) variation ids ενός variable προϊόντος -- ΜΗΔΕΝ για απλά * προϊόντα. Χρησιμοποιείται ΜΟΝΟ πριν από διαγραφή/απόσυρση, ώστε ο * processor (process_delete_action()) να ξέρει ΠΟΙΑ "parent-variation" * έγγραφα πρέπει να σβήσει ΚΙΟΛΑΣ, χωρίς να χρειάζεται να ξαναφορτώσει * το προϊόν αργότερα (μπορεί να έχει ήδη σβηστεί όσο εκκρεμούσε η ουρά). * * @param int $product_id Product id. * @return int[] */ private function get_variation_ids_for_product( $product_id ) { if ( ! function_exists( 'wc_get_product' ) ) { return array(); } $product = wc_get_product( $product_id ); if ( ! ( $product instanceof WC_Product ) || ! $product->is_type( 'variable' ) ) { return array(); } return array_map( 'intval', $product->get_children() ); } /** * 🔴 audit F04: διαγραφή/κάδος/αλλαγή κατάστασης ΜΙΑΣ παραλλαγής. * * Το cleanup_stale_variation_docs() συγκρίνει μόνο τα ΣΗΜΕΡΙΝΑ * get_children() — μια ήδη σβησμένη παραλλαγή δεν υπάρχει πια εκεί για * να παραχθεί το παλιό id «γονέας-παραλλαγή», οπότε το έγγραφό της * έμενε ορφανό (αποτέλεσμα ΚΑΙ facet). Εδώ, ΤΗ ΣΤΙΓΜΗ που ξέρουμε ακόμα * γονέα + παραλλαγή, μπαίνει ρητά στη λίστα διαγραφών. * * Χωρίς ακριβή αναζήτηση παραλλαγών (parent-only) δεν υπάρχουν τέτοια * έγγραφα: μόνο resync του γονέα (αλλάζουν attributes/available_options). * * @param int $variation_id Id παραλλαγής. * @param int $parent_id Id γονικού προϊόντος. * @return void */ private function handle_variation_changed( $variation_id, $parent_id ) { if ( $variation_id <= 0 || $parent_id <= 0 ) { return; } if ( class_exists( 'Addsynapse_Search_Settings' ) && Addsynapse_Search_Settings::get( 'variation_indexing', false ) ) { $this->queue_doc_deletes( array( $parent_id . '-' . $variation_id ) ); } $this->enqueue_product_sync( $parent_id ); } /* ----------------------------------------------------------------- * Αλλαγές όρων (κατηγορίες/μάρκες) — audit F06. ΜΟΝΟ enqueue. * --------------------------------------------------------------- */ /** * Γονέας/όνομα όρων ΠΡΙΝ την ενημέρωση (το `edited_term` βλέπει μόνο τα νέα). * * @var array */ private $terms_before_edit = array(); /** * Ταξινομίες που επηρεάζουν έγγραφα: κατηγορίες (όνομα + ιεραρχία) και μάρκες (όνομα). * * @param string $taxonomy Ταξινομία. * @return bool */ private static function is_watched_taxonomy( $taxonomy ) { return 'product_cat' === $taxonomy || in_array( $taxonomy, self::BRAND_TAXONOMIES, true ); } /** * `edit_terms` — πριν το UPDATE: κρατάμε γονέα/όνομα. * * @param int $term_id Term id. * @param string $taxonomy Ταξινομία. * @return void */ public function remember_term_before_edit( $term_id, $taxonomy ) { if ( ! self::is_watched_taxonomy( $taxonomy ) ) { return; } $term = get_term( (int) $term_id, $taxonomy ); if ( $term instanceof WP_Term ) { $this->terms_before_edit[ (int) $term_id ] = array( 'parent' => (int) $term->parent, 'name' => (string) $term->name, ); } } /** * `edited_term` — αν άλλαξε ο ΓΟΝΕΑΣ μιας κατηγορίας (αλλάζουν οι * πρόγονοι του όρου ΚΑΙ όλων των απογόνων του → `category_path_ids`) ή * το ΟΝΟΜΑ (`categories`/`brand` + μεταγραφή), ξανασυγχρονίζουμε τα * προϊόντα του όρου και των απογόνων του — σελιδοποιημένα, στο παρασκήνιο. * * @param int $term_id Term id. * @param int $tt_id Term taxonomy id. * @param string $taxonomy Ταξινομία. * @return void */ public function handle_term_edited( $term_id, $tt_id, $taxonomy ) { unset( $tt_id ); $term_id = (int) $term_id; if ( ! self::is_watched_taxonomy( $taxonomy ) || ! isset( $this->terms_before_edit[ $term_id ] ) ) { return; } $before = $this->terms_before_edit[ $term_id ]; unset( $this->terms_before_edit[ $term_id ] ); clean_term_cache( $term_id, $taxonomy ); $term = get_term( $term_id, $taxonomy ); if ( ! ( $term instanceof WP_Term ) ) { return; } $parent_changed = 'product_cat' === $taxonomy && (int) $term->parent !== $before['parent']; $name_changed = (string) $term->name !== $before['name']; if ( $parent_changed || $name_changed ) { $this->schedule_resync_terms( $taxonomy, array( $term_id ), 0 ); } } /** * `pre_delete_term` — ο όρος σβήνεται: τα παιδιά του θα πάρουν νέο * γονέα (άρα νέους προγόνους) και τα προϊόντα του θα τον χάσουν. Τα * πιάνουμε ΤΩΡΑ, όσο ο όρος υπάρχει ακόμα. * * @param int $term_id Term id. * @param string $taxonomy Ταξινομία. * @return void */ public function handle_term_pre_delete( $term_id, $taxonomy ) { if ( ! self::is_watched_taxonomy( $taxonomy ) ) { return; } $term_id = (int) $term_id; if ( 'product_cat' === $taxonomy ) { $children = get_terms( array( 'taxonomy' => $taxonomy, 'parent' => $term_id, 'hide_empty' => false, 'fields' => 'ids', ) ); if ( is_array( $children ) && ! empty( $children ) ) { // include_children στο query του processor καλύπτει όλο το υποδέντρο. $this->schedule_resync_terms( $taxonomy, array_map( 'intval', $children ), 0 ); } } $product_ids = get_objects_in_term( $term_id, $taxonomy ); if ( is_array( $product_ids ) && ! empty( $product_ids ) ) { foreach ( array_chunk( array_map( 'intval', $product_ids ), self::BATCH_SIZE ) as $chunk ) { $this->schedule_resync_terms( $taxonomy, array(), 0, $chunk ); } } } /** * @param string $taxonomy Ταξινομία. * @param int[] $term_ids Όροι (με τους απογόνους τους). * @param int $after_id Δρομέας keyset. * @param int[] $product_ids Ρητά product ids (αντί για όρους). * @return void */ private function schedule_resync_terms( $taxonomy, array $term_ids, $after_id, array $product_ids = array() ) { if ( ! self::is_configured() || ! function_exists( 'as_enqueue_async_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) { return; } $args = array( 'taxonomy' => (string) $taxonomy, 'term_ids' => array_values( array_map( 'intval', $term_ids ) ), 'after_id' => (int) $after_id, 'product_ids' => array_values( array_map( 'intval', $product_ids ) ), ); // Μόνο εκκρεμής (όχι σε εκτέλεση) — βλ. has_pending_action(): δεύτερη // μετονομασία ενώ τρέχει η πρώτη σελίδα δεν πρέπει να χάνεται. if ( self::has_pending_action( self::HOOK_RESYNC_TERMS, $args ) ) { return; } as_enqueue_async_action( self::HOOK_RESYNC_TERMS, $args, self::GROUP ); } /** * Processor του HOOK_RESYNC_TERMS: ΜΙΑ σελίδα προϊόντων του όρου (και των * απογόνων του) — ξαναχτίζει και στέλνει τα έγγραφά τους· αν η σελίδα * είναι γεμάτη, προγραμματίζει την επόμενη. * * @param string $taxonomy Ταξινομία. * @param int[] $term_ids Όροι. * @param int $after_id Δρομέας keyset. * @param int[] $product_ids Ρητά product ids (διαγραφή όρου). * @return void */ public function process_resync_terms( $taxonomy, $term_ids = array(), $after_id = 0, $product_ids = array() ) { if ( ! self::is_watched_taxonomy( (string) $taxonomy ) || ! $this->license_ready( 'resync όρων ' . (string) $taxonomy ) ) { return; } $product_ids = array_slice( array_filter( array_map( 'intval', (array) $product_ids ) ), 0, self::BATCH_SIZE ); $term_ids = array_filter( array_map( 'intval', (array) $term_ids ) ); $paged = empty( $product_ids ); if ( $paged ) { if ( empty( $term_ids ) ) { return; } $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => self::BATCH_SIZE, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'suppress_filters' => false, 'addsynapse_search_after_id' => max( 0, (int) $after_id ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- παρασκήνιο, σελιδοποιημένο. 'tax_query' => array( array( 'taxonomy' => (string) $taxonomy, 'field' => 'term_id', 'terms' => array_values( $term_ids ), 'include_children' => true, ), ), ); add_filter( 'posts_where', array( __CLASS__, 'keyset_where' ), 10, 2 ); $query = new WP_Query( $args ); remove_filter( 'posts_where', array( __CLASS__, 'keyset_where' ), 10 ); $product_ids = array_map( 'intval', (array) $query->posts ); } if ( empty( $product_ids ) ) { return; } $since = self::pdel_stamp(); $docs = array(); $excluded = array(); foreach ( $product_ids as $product_id ) { $product = wc_get_product( $product_id ); if ( ! ( $product instanceof WC_Product ) || $product->is_type( 'variation' ) ) { continue; } // Φρέσκοι όροι: η cache όρων του αιτήματος μπορεί να κρατά τους παλιούς προγόνους. clean_object_term_cache( $product_id, 'product' ); if ( ! $this->product_should_be_indexed( $product ) ) { $excluded[] = $product; continue; } foreach ( $this->build_documents( $product ) as $doc ) { $docs[] = $doc; } } $this->send_batch( $docs, $since ); $this->delete_excluded( $excluded ); if ( $paged && count( $product_ids ) >= self::BATCH_SIZE ) { $this->schedule_resync_terms( (string) $taxonomy, $term_ids, (int) end( $product_ids ) ); } } /* ----------------------------------------------------------------- * Enqueue helpers — de-dup μέσω as_has_scheduled_action(). * --------------------------------------------------------------- */ /** * Προγραμματίζει (async, μία φορά) τη δεικτοδότηση ενός προϊόντος. * * @param int $product_id Product id. * @return void */ private function enqueue_product_sync( $product_id ) { if ( $product_id <= 0 || ! self::is_configured() || ! function_exists( 'as_enqueue_async_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) { return; } $args = array( 'product_id' => $product_id ); if ( self::has_pending_action( self::HOOK_SYNC_PRODUCT, $args ) ) { return; } as_enqueue_async_action( self::HOOK_SYNC_PRODUCT, $args, self::GROUP ); } /** * Υπάρχει ήδη ΕΚΚΡΕΜΗΣ (pending — όχι ήδη σε εκτέλεση) ίδια ενέργεια; * * 🔴 0.5.2 (αναφορά 02/10: «άλλαξα τον τίτλο, η αναζήτηση δείχνει τον * παλιό»): το as_has_scheduled_action() μετρά ΚΑΙ την ενέργεια που ΤΡΕΧΕΙ * (in-progress). Αν ο worker είχε ήδη φορτώσει το προϊόν (π.χ. από την * αποθήκευση παραλλαγών μέσω AJAX που προηγείται της φόρμας στο admin) * όταν ερχόταν η νέα αλλαγή, η νέα αλλαγή ΔΕΝ έμπαινε στην ουρά — και ο * worker έστελνε τον ΠΑΛΙΟ τίτλο σε γονέα και σε όλες τις παραλλαγές. * Μια εκκρεμής ενέργεια διαβάζει το προϊόν όταν ξεκινήσει, άρα αρκεί * εκείνη· μια που τρέχει ΔΕΝ αρκεί. Ίδιο μοτίβο με schedule_flush_deletes(). * * @param string $hook Hook. * @param array|null $args Ορίσματα (ακριβής ταύτιση) ή null = οποιαδήποτε. * @return bool */ private static function has_pending_action( $hook, $args = null ) { if ( function_exists( 'as_get_scheduled_actions' ) ) { $query = array( 'hook' => $hook, 'group' => self::GROUP, 'status' => 'pending', 'per_page' => 1, 'orderby' => 'none', ); if ( null !== $args ) { $query['args'] = $args; } $ids = as_get_scheduled_actions( $query, 'ids' ); return ! empty( $ids ); } return function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( $hook, $args, self::GROUP ); } /** * Προγραμματίζει (async, μία φορά) τη διαγραφή ενός προϊόντος από τον * δείκτη -- ΜΑΖΙ με τα ids των variations του (αν γνωστά ΤΗ ΣΤΙΓΜΗ ΤΗΣ * κλήσης, βλ. get_variation_ids_for_product()), ώστε ο processor να * μπορεί να σβήσει ΚΑΙ τα "parent-variation" έγγραφα (task-brief §4 🔴) * χωρίς να χρειάζεται να ξαναφορτώσει το (πιθανόν ήδη σβησμένο) προϊόν. * * @param int $product_id Product id. * @param int[] $variation_ids Γνωστά variation ids αυτού του προϊόντος (μπορεί να είναι άδειο). * @return void */ private function enqueue_product_delete( $product_id, array $variation_ids = array() ) { if ( $product_id <= 0 ) { return; } // 🔴 Από 0.3.4 κάθε διαγραφή περνά από την ΑΝΘΕΚΤΙΚΗ λίστα (βλ. // OPTION_PENDING_DELETES): αν το DELETE αποτύχει, δεν χάνεται. $this->queue_doc_deletes( self::doc_ids_for( $product_id, $variation_ids ) ); } /** * Τα ids εγγράφων ενός προϊόντος: το ίδιο + κάθε «γονέας-παραλλαγή» * (ανεξάρτητα από το αν το variation_indexing είναι on — διαγραφή * ανύπαρκτου id είναι no-op στον server). * * @param int $product_id Product id. * @param int[] $variation_ids Variation ids. * @return string[] */ private static function doc_ids_for( $product_id, $variation_ids ) { $ids = array( (string) (int) $product_id ); foreach ( (array) $variation_ids as $variation_id ) { $ids[] = (int) $product_id . '-' . (int) $variation_id; } return $ids; } /* ----------------------------------------------------------------- * Ανθεκτικές διαγραφές (audit F02/F03) — βλ. PDEL_PREFIX. * * 🔴 ΜΙΑ ΓΡΑΜΜΗ wp_options ΑΝΑ ID (0.4.2). Πριν, όλη η λίστα ζούσε σε * ΕΝΑ option (get → αλλαγή → update): δύο workers που έβαζαν/έβγαζαν * ids ταυτόχρονα έχαναν ο ένας τις αλλαγές του άλλου — ένα προϊόν που * αποσύρθηκε έμενε αναζητήσιμο. Τώρα: * - enqueue = ΕΝΑ ατομικό INSERT … ON DUPLICATE KEY UPDATE ανά id * (το UNIQUE option_name το εγγυάται η MySQL, όχι εμείς), * - forget = DELETE με ΣΥΓΚΡΙΣΗ ΤΙΜΗΣ (compare-and-delete): σβήνει τη * γραμμή ΜΟΝΟ αν δεν ξαναμπήκε στο μεταξύ. Η τιμή αρχίζει με * χρονοσφραγίδα μsec σταθερού πλάτους, άρα «παλαιότερη από Χ» είναι * απλή σύγκριση συμβολοσειρών στη βάση. * Τιμή: «<16ψήφια μsec>|||» — p = εκκρεμής, * d = dead-letter (ο server την απέρριψε ΟΡΙΣΤΙΚΑ: 422/413 σε ΜΟΝΟ id). * Οι dead-letter ΔΕΝ ξεχνιούνται ποτέ σιωπηλά: φαίνονται στην καρτέλα * «Κατάσταση» και σβήνονται μόνο όταν μια επιτυχής σκούπα/άδειασμα * αποδείξει ότι το έγγραφο δεν είναι πια στον δείκτη. * * Άμεσα queries στο wp_options: δεν υπάρχει WP API για ατομικό * insert-or-update ούτε για compare-and-delete (το add_option ελέγχει * πρώτα την cache, το update_option κάνει UPDATE που χάνεται αν η * γραμμή σβήστηκε στο μεταξύ). Οι γραμμές ΔΕΝ διαβάζονται ποτέ μέσω * get_option() — μόνο από εδώ — άρα η object cache δεν τις αφορά. * --------------------------------------------------------------- */ /** * Κατάσταση της λίστας εκκρεμών διαγραφών (για την καρτέλα «Κατάσταση»). * * @return array{count:int,dead:int,attempts:int,last_error:string,last_status:int,last_attempt:int,overflow:bool} */ public static function pending_deletes_status() { $meta = self::load_pending_meta(); return array( 'count' => self::pdel_count( 'p' ), 'dead' => self::pdel_count( 'd' ), 'attempts' => (int) $meta['attempts'], 'last_error' => (string) $meta['last_error'], 'last_status' => (int) $meta['last_status'], 'last_attempt' => (int) $meta['last_attempt'], 'overflow' => ! empty( $meta['overflow'] ), ); } /** * Μεταδεδομένα της λίστας (προσπάθειες/τελευταίο σφάλμα) — ΟΧΙ τα ids. * Μετάπτωση: μια λίστα `ids` παλιότερης έκδοσης (ή της μετάπτωσης από * WebOrange Search) μετατρέπεται εδώ σε γραμμές, μία φορά. * * @return array */ private static function load_pending_meta() { $meta = get_option( self::OPTION_PENDING_DELETES, array() ); $defaults = array( 'attempts' => 0, 'last_error' => '', 'last_status' => 0, 'last_attempt' => 0, 'overflow' => false, ); if ( ! is_array( $meta ) ) { return $defaults; } if ( ! empty( $meta['ids'] ) && is_array( $meta['ids'] ) ) { self::pdel_put( array_map( 'strval', array_keys( $meta['ids'] ) ) ); unset( $meta['ids'] ); self::save_pending_meta( array_merge( $defaults, $meta ) ); } unset( $meta['ids'] ); return array_merge( $defaults, $meta ); } /** * @param array $meta Μεταδεδομένα. * @return void */ private static function save_pending_meta( array $meta ) { unset( $meta['ids'] ); if ( empty( $meta['attempts'] ) && '' === (string) $meta['last_error'] && empty( $meta['overflow'] ) ) { delete_option( self::OPTION_PENDING_DELETES ); return; } update_option( self::OPTION_PENDING_DELETES, $meta, false ); } /** * Χρονοσφραγίδα μsec, 16 ψηφία (λεξικογραφική σειρά = χρονική). * * @return string */ public static function pdel_stamp() { return sprintf( '%016.0f', floor( microtime( true ) * 1000000 ) ); } /** * @param string $stamp Χρονοσφραγίδα. * @param string $state p|d. * @param int $status HTTP status (dead-letter). * @return string */ private static function pdel_value( $stamp, $state, $status = 0 ) { return $stamp . '|' . $state . '|' . wp_generate_password( 6, false, false ) . '|' . (int) $status; } /** * @param string $id Id εγγράφου. * @return string */ private static function pdel_name( $id ) { return self::PDEL_PREFIX . $id; } /** * ΑΤΟΜΙΚΟ enqueue: μία γραμμή ανά id, INSERT … ON DUPLICATE KEY UPDATE. * Ένα id που ήταν ήδη μέσα παίρνει ΝΕΑ τιμή → ένα flush που το είχε * διαβάσει πριν ΔΕΝ μπορεί πια να το σβήσει (compare-and-delete). * * @param string[] $ids Ids εγγράφων (ήδη έγκυρα). * @return void */ private static function pdel_put( array $ids ) { global $wpdb; $ids = array_values( array_unique( array_filter( array_map( 'strval', $ids ), array( __CLASS__, 'valid_doc_id' ) ) ) ); if ( empty( $ids ) ) { return; } $stamp = self::pdel_stamp(); foreach ( $ids as $id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- ατομικό upsert ανά id (βλ. σχόλιο ενότητας). $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no') ON DUPLICATE KEY UPDATE option_value = VALUES(option_value)", self::pdel_name( $id ), self::pdel_value( $stamp, 'p' ) ) ); } } /** * Γραμμές της λίστας: array( doc_id => τιμή ), με σειρά εισαγωγής. * * @param string $state p|d. * @param int $limit Μέγιστο πλήθος. * @return array */ private static function pdel_rows( $state, $limit ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- βλ. σχόλιο ενότητας (η τιμή αλλάζει από παράλληλους workers, cache θα έδινε παλιά εικόνα). $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value LIKE %s ORDER BY option_id ASC LIMIT %d", $wpdb->esc_like( self::PDEL_PREFIX ) . '%', '%|' . $wpdb->esc_like( $state ) . '|%', max( 1, (int) $limit ) ), ARRAY_A ); $out = array(); $len = strlen( self::PDEL_PREFIX ); foreach ( (array) $rows as $row ) { $out[ (string) substr( $row['option_name'], $len ) ] = (string) $row['option_value']; } return $out; } /** * @param string|null $state p|d ή null για όλες. * @return int */ private static function pdel_count( $state = null ) { global $wpdb; $like_state = null === $state ? '%' : '%|' . $wpdb->esc_like( $state ) . '|%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- βλ. σχόλιο ενότητας. return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value LIKE %s", $wpdb->esc_like( self::PDEL_PREFIX ) . '%', $like_state ) ); } /** * COMPARE-AND-DELETE: σβήνει κάθε γραμμή ΜΟΝΟ αν έχει ακόμα την τιμή * που διαβάσαμε (αν ξαναμπήκε στο μεταξύ, μένει). * * @param array $rows doc_id => τιμή που διαβάστηκε. * @return void */ private static function pdel_forget_exact( array $rows ) { global $wpdb; foreach ( $rows as $id => $value ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- compare-and-delete (βλ. σχόλιο ενότητας). $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value = %s", self::pdel_name( (string) $id ), (string) $value ) ); } } /** * Σβήνει τις γραμμές των $ids που μπήκαν ΠΡΙΝ από $stamp (όσες μπήκαν * μετά — π.χ. το προϊόν αποσύρθηκε ΕΝΩ στελνόταν — μένουν). $ids = null: * ΟΛΕΣ οι γραμμές (και dead-letter) πριν από $stamp. * * @param string[]|null $ids Ids ή null. * @param string $stamp Χρονοσφραγίδα (pdel_stamp()). * @return void */ private static function pdel_forget_older( $ids, $stamp ) { global $wpdb; $stamp = (string) $stamp; if ( 1 !== preg_match( '/^\d{16}$/', $stamp ) ) { return; } if ( null === $ids ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- βλ. σχόλιο ενότητας. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value < %s", $wpdb->esc_like( self::PDEL_PREFIX ) . '%', $stamp ) ); return; } foreach ( array_unique( array_map( 'strval', $ids ) ) as $id ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- βλ. σχόλιο ενότητας. $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE option_name = %s AND option_value < %s", self::pdel_name( $id ), $stamp ) ); } } /** * Dead-letter: η γραμμή μένει (ορατή), αλλά βγαίνει από τις επαναλήψεις. * Compare-and-swap — αν ξαναμπήκε στο μεταξύ, μένει εκκρεμής. * * @param array $rows doc_id => τιμή που διαβάστηκε. * @param int $status HTTP status της οριστικής απόρριψης. * @return void */ private static function pdel_mark_dead( array $rows, $status ) { global $wpdb; foreach ( $rows as $id => $value ) { $stamp = substr( (string) $value, 0, 16 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- compare-and-swap (βλ. σχόλιο ενότητας). $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s", self::pdel_value( $stamp, 'd', $status ), self::pdel_name( (string) $id ), (string) $value ) ); } } /** * Βάζει ids στη λίστα εκκρεμών διαγραφών και προγραμματίζει άδειασμα. * ΚΑΜΙΑ κλήση δικτύου εδώ — ασφαλές μέσα σε οποιοδήποτε WP hook. * * @param string[] $ids Ids εγγράφων. * @return void */ private function queue_doc_deletes( array $ids ) { // Μη συνδεδεμένο κατάστημα = κανένας δείκτης να καθαριστεί. if ( ! self::is_configured() ) { return; } $ids = array_values( array_filter( array_map( 'strval', $ids ), array( __CLASS__, 'valid_doc_id' ) ) ); if ( empty( $ids ) ) { return; } self::pdel_put( $ids ); // Φράγμα μεγέθους: πέρα από PENDING_DELETES_MAX κόβονται οι // παλαιότερες εκκρεμείς (όχι οι dead-letter) και σημαδεύεται // overflow — η σκούπα του επόμενου πλήρους περάσματος τις καλύπτει. $count = self::pdel_count( 'p' ); if ( $count > self::PENDING_DELETES_MAX ) { self::pdel_forget_exact( self::pdel_rows( 'p', $count - self::PENDING_DELETES_MAX ) ); $meta = self::load_pending_meta(); $meta['overflow'] = true; self::save_pending_meta( $meta ); } $this->schedule_flush_deletes( 0 ); } /** * Τα ids των εγγράφων που μόλις στάλθηκαν με επιτυχία φεύγουν από τη * λίστα διαγραφών (το προϊόν ξαναδημοσιεύτηκε πριν αδειάσει η ουρά). * * 🔴 ΜΟΝΟ όσα μπήκαν στη λίστα ΠΡΙΝ χτιστούν τα έγγραφα ($since): αν το * προϊόν αποσύρθηκε ΕΝΩ στελνόταν, η νεότερη διαγραφή ισχύει. * 🔴 Τα `rejected_ids` (όριο πακέτου, μερική αποδοχή) ΔΕΝ δεικτοδοτήθηκαν * — δεν τα αφαιρούμε από τη λίστα, μια εκκρεμής διαγραφή τους ισχύει. * * @param array[] $docs Έγγραφα. * @param string[] $rejected Ids που απέρριψε ο server. * @param string $since pdel_stamp() πριν χτιστούν τα έγγραφα. * @return void */ private static function forget_pending_for_docs( array $docs, array $rejected, $since ) { $ids = array(); foreach ( self::accepted_docs( $docs, $rejected ) as $doc ) { if ( isset( $doc['id'] ) ) { $ids[] = (string) $doc['id']; } } if ( ! empty( $ids ) ) { self::pdel_forget_older( $ids, $since ); } } /** * Τα έγγραφα της παρτίδας ΧΩΡΙΣ όσα απέρριψε ο server. * * @param array[] $docs Έγγραφα. * @param string[] $rejected Ids που απέρριψε ο server. * @return array[] */ private static function accepted_docs( array $docs, array $rejected ) { if ( empty( $rejected ) ) { return $docs; } $rejected = array_flip( array_map( 'strval', $rejected ) ); $kept = array(); foreach ( $docs as $doc ) { if ( isset( $doc['id'] ) && isset( $rejected[ (string) $doc['id'] ] ) ) { continue; } $kept[] = $doc; } return $kept; } /** * Ids που απέρριψε ο server σε ΕΠΙΤΥΧΗ (2xx) απάντηση ingest — μερική * αποδοχή λόγω ορίου πακέτου. Παλιός server: πάντα κενό. * * @param array|WP_Error $result Απάντηση ingest(). * @return string[] */ private static function rejected_ids( $result ) { if ( ! is_array( $result ) || empty( $result['rejected_ids'] ) || ! is_array( $result['rejected_ids'] ) ) { return array(); } $ids = array(); foreach ( $result['rejected_ids'] as $id ) { if ( is_scalar( $id ) ) { $ids[] = (string) $id; } } return $ids; } /** * Πόσα έγγραφα ΣΦΡΑΓΙΣΤΗΚΑΝ πραγματικά στον δείκτη: το `accepted` του * server, ή count($docs) σε παλιό server που δεν το στέλνει. 🔴 Αυτό * μετράει η σκούπα (expected) — αν μετρούσαμε τα απεσταλμένα, μια μερική * αποδοχή θα έδινε expected > kept → 409 sweep_unsafe για πάντα. * * @param array|WP_Error $result Απάντηση ingest(). * @param array[] $docs Η παρτίδα που στάλθηκε. * @return int */ private static function accepted_count( $result, array $docs ) { if ( is_wp_error( $result ) ) { return 0; } if ( is_array( $result ) && isset( $result['accepted'] ) && is_numeric( $result['accepted'] ) ) { return max( 0, min( count( $docs ), (int) $result['accepted'] ) ); } return count( $docs ); } /** * Αν η απάντηση είναι μερική αποδοχή (`*_partial`, 2xx). * * @param array|WP_Error $result Απάντηση ingest(). * @return bool */ private static function is_partial_ingest( $result ) { return is_array( $result ) && isset( $result['code'] ) && is_string( $result['code'] ) && '_partial' === substr( $result['code'], -8 ); } /** * 402 του ΝΕΟΥ server (IngestQuota): απορρίφθηκαν ΜΟΝΟ νέα προϊόντα, * κανένα υπάρχον έγγραφο δεν έμεινε χωρίς ενημέρωση (τα υπάρχοντα * γίνονται πάντα δεκτά). Ο παλιός server απέρριπτε ΟΛΗ την παρτίδα και * δεν έστελνε `accepted` — εκεί η σκούπα θα έσβηνε έγκυρα έγγραφα. * * @param array|WP_Error $result Απάντηση ingest(). * @return bool */ private static function is_new_only_limit( $result ) { if ( ! is_wp_error( $result ) ) { return false; } $data = $result->get_error_data(); return is_array( $data ) && isset( $data['status'] ) && 402 === (int) $data['status'] && isset( $data['accepted'] ); } /** * Προγραμματίζει το άδειασμα της λίστας (μία εκκρεμής ενέργεια τη φορά). * * @param int $delay Δευτερόλεπτα (0 = async, το συντομότερο). * @return void */ private function schedule_flush_deletes( $delay ) { if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_enqueue_async_action' ) ) { return; } // ΜΟΝΟ εκκρεμείς (pending): το as_has_scheduled_action() μετρά και την // ενέργεια που ΤΡΕΧΕΙ τώρα — από μέσα της δεν θα προγραμματιζόταν // ποτέ η επόμενη προσπάθεια (βρέθηκε στη δοκιμή). if ( function_exists( 'as_get_scheduled_actions' ) ) { $pending_ids = as_get_scheduled_actions( array( 'hook' => self::HOOK_FLUSH_DELETES, 'group' => self::GROUP, 'status' => 'pending', 'per_page' => 1, ), 'ids' ); if ( ! empty( $pending_ids ) ) { return; } } elseif ( as_has_scheduled_action( self::HOOK_FLUSH_DELETES, null, self::GROUP ) ) { return; } if ( $delay > 0 && function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + (int) $delay, self::HOOK_FLUSH_DELETES, array(), self::GROUP ); return; } as_enqueue_async_action( self::HOOK_FLUSH_DELETES, array(), self::GROUP ); } /** * Εκθετικό backoff: 1′, 2′, 4′ … έως FLUSH_MAX_DELAY (6 ώρες). * * @param int $attempts Αποτυχημένες προσπάθειες μέχρι τώρα. * @return int */ private static function flush_backoff( $attempts ) { $attempts = max( 1, min( 16, (int) $attempts ) ); return (int) min( self::FLUSH_MAX_DELAY, MINUTE_IN_SECONDS * pow( 2, $attempts - 1 ) ); } /** * Processor του HOOK_FLUSH_DELETES. * * @return void */ public function process_flush_deletes() { $result = $this->flush_pending_deletes(); if ( $result['remaining'] > 0 ) { // Σφάλμα → backoff· απλώς «έμειναν κι άλλα» (πάνω από FLUSH_READ_LIMIT) → αμέσως. $delay = $result['error'] instanceof WP_Error ? self::flush_backoff( self::load_pending_meta()['attempts'] ) : 0; $this->schedule_flush_deletes( $delay ); } } /** * Αδειάζει τη λίστα εκκρεμών διαγραφών ΤΩΡΑ (worker ή χειροκίνητο rebuild). * * 🔴 Πριν σβήσει ένα id, ελέγχει αν το έγγραφο είναι ΤΩΡΑ ζωντανό (το * προϊόν ξαναδημοσιεύτηκε όσο περίμενε η ουρά) — τότε ΔΕΝ το σβήνει, το * ξαναστέλνει. Αποτυχία ελέγχου άδειας ΔΕΝ πετά τη διαγραφή: μένει στη * λίστα για την επόμενη προσπάθεια. * * 🔴 audit F02 — ΚΑΝΕΝΑ id δεν ξεχνιέται χωρίς επιβεβαίωση (2xx): * - 413 (πολύ μεγάλο αίτημα) → η παρτίδα χωρίζεται στα δύο, ως 1 id, * - 422 (ο server απέρριψε ids, βλ. backend DocumentsController::destroy(): * «τίποτα δεν σβήστηκε» + `invalid_ids`) → διχοτόμηση ώσπου να * απομονωθεί το άκυρο id· ΜΟΝΟ αυτό πάει σε dead-letter (ορατό), * - 413/422 σε ΕΝΑ id → dead-letter (δεν διορθώνεται με επανάληψη), * - ΚΑΘΕ άλλο σφάλμα (δίκτυο, 5xx, 401/403 άδεια, 404 endpoint, 429…) * → τα ids ΜΕΝΟΥΝ, επανάληψη με backoff, το σφάλμα φαίνεται στην «Κατάσταση». * * @return array{deleted:int,remaining:int,dead:int,error:WP_Error|null} */ public function flush_pending_deletes() { self::load_pending_meta(); // Μετάπτωση παλιάς λίστας, αν υπάρχει. $rows = self::pdel_rows( 'p', self::FLUSH_READ_LIMIT ); if ( empty( $rows ) ) { return array( 'deleted' => 0, 'remaining' => 0, 'dead' => self::pdel_count( 'd' ), 'error' => null, ); } if ( ! $this->license_ready( 'άδειασμα εκκρεμών διαγραφών' ) ) { $error = new WP_Error( 'addsynapse_search_license_inactive', __( 'There is no active license.', 'addsynapse-search-filter' ) ); self::note_flush_failure( $error ); return array( 'deleted' => 0, 'remaining' => self::pdel_count( 'p' ), 'dead' => self::pdel_count( 'd' ), 'error' => $error, ); } $to_delete = array(); $live = array(); $resync = array(); foreach ( $rows as $id => $value ) { $id = (string) $id; $parent = $this->live_parent_for_doc_id( $id ); if ( $parent > 0 ) { $live[ $id ] = $value; $resync[ $parent ] = true; } else { $to_delete[ $id ] = $value; } } if ( ! empty( $live ) ) { self::pdel_forget_exact( $live ); foreach ( array_keys( $resync ) as $parent_id ) { $this->enqueue_product_sync( (int) $parent_id ); } } $deleted = 0; $error = null; $requests = 0; $queue = array_chunk( $to_delete, self::DELETE_CHUNK, true ); while ( ! empty( $queue ) ) { $chunk = array_shift( $queue ); if ( ++$requests > self::FLUSH_MAX_REQUESTS ) { break; // Φράγμα χρόνου ανά εκτέλεση — τα υπόλοιπα μένουν για την επόμενη. } $result = Addsynapse_Search_Api_Client::delete( array_map( 'strval', array_keys( $chunk ) ) ); if ( ! is_wp_error( $result ) ) { self::pdel_forget_exact( $chunk ); $deleted += count( $chunk ); continue; } $status = self::error_status( $result ); if ( in_array( $status, array( 413, 422 ), true ) ) { if ( count( $chunk ) > 1 ) { $half = (int) ceil( count( $chunk ) / 2 ); array_unshift( $queue, array_slice( $chunk, 0, $half, true ), array_slice( $chunk, $half, null, true ) ); continue; } $this->log( 'εκκρεμείς διαγραφές: ο server απέρριψε οριστικά το id ' . key( $chunk ) . ' (' . $status . ') → dead-letter' ); self::pdel_mark_dead( $chunk, $status ); continue; } $error = $result; break; } if ( $error instanceof WP_Error ) { $this->log( 'εκκρεμείς διαγραφές απέτυχαν: ' . $error->get_error_message() ); self::note_flush_failure( $error ); } else { $meta = self::load_pending_meta(); $meta['attempts'] = 0; $meta['last_error'] = ''; $meta['last_status'] = 0; $meta['overflow'] = false; self::save_pending_meta( $meta ); } if ( $deleted > 0 && class_exists( 'Addsynapse_Search_Analytics' ) ) { Addsynapse_Search_Analytics::clear_cache(); } return array( 'deleted' => $deleted, 'remaining' => self::pdel_count( 'p' ), 'dead' => self::pdel_count( 'd' ), 'error' => $error, ); } /** * HTTP status ενός WP_Error του API client (0 = δίκτυο/άγνωστο). * * @param WP_Error $error Σφάλμα. * @return int */ private static function error_status( WP_Error $error ) { $data = $error->get_error_data(); return is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; } /** * @param WP_Error $error Το σφάλμα. * @return void */ private static function note_flush_failure( WP_Error $error ) { $meta = self::load_pending_meta(); $meta['attempts'] = (int) $meta['attempts'] + 1; $meta['last_error'] = substr( sanitize_text_field( $error->get_error_message() ), 0, 200 ); $meta['last_status'] = self::error_status( $error ); $meta['last_attempt'] = time(); self::save_pending_meta( $meta ); } /** * Σβήνει ΤΩΡΑ (μέσα σε worker/AJAX), και ό,τι αποτύχει μπαίνει στη λίστα * εκκρεμών διαγραφών αντί να χαθεί (εκεί το flush χειρίζεται 413/422). * * @param string[] $ids Ids εγγράφων. * @param string $context Για το log. * @return WP_Error|null Το πρώτο σφάλμα (για να φανεί στο χειροκίνητο rebuild). */ private function delete_now_or_queue( array $ids, $context ) { $ids = array_values( array_unique( array_map( 'strval', $ids ) ) ); if ( empty( $ids ) ) { return null; } $first_error = null; $failed = array(); foreach ( array_chunk( $ids, self::DELETE_CHUNK ) as $chunk ) { // Ό,τι μπει στη λίστα ΜΕΤΑ από αυτή τη στιγμή (νέα απόσυρση) μένει. $since = self::pdel_stamp(); $result = Addsynapse_Search_Api_Client::delete( $chunk ); if ( is_wp_error( $result ) ) { $failed = array_merge( $failed, $chunk ); if ( null === $first_error ) { $first_error = $result; } } else { self::pdel_forget_older( $chunk, $since ); } } if ( ! empty( $failed ) ) { $this->log( $context . ': ' . $first_error->get_error_message() . ' — ' . count( $failed ) . ' ids στη λίστα εκκρεμών διαγραφών' ); $this->queue_doc_deletes( $failed ); } return $first_error; } /** * Αν ένα id εγγράφου αντιστοιχεί ΤΩΡΑ σε έγγραφο που ΠΡΕΠΕΙ να είναι * στον δείκτη, επιστρέφει το γονικό product id (για resync)· αλλιώς 0. * * @param string $doc_id Id εγγράφου («123» ή «123-456»). * @return int */ private function live_parent_for_doc_id( $doc_id ) { if ( ! function_exists( 'wc_get_product' ) ) { return 0; } $variation_indexing = class_exists( 'Addsynapse_Search_Settings' ) && Addsynapse_Search_Settings::get( 'variation_indexing', false ); if ( preg_match( '/^(\d+)$/', $doc_id, $m ) ) { $product = wc_get_product( (int) $m[1] ); if ( ! ( $product instanceof WC_Product ) || $product->is_type( 'variation' ) || ! $this->product_should_be_indexed( $product ) ) { return 0; } // Με ακριβή αναζήτηση παραλλαγών, ένα variable προϊόν ΔΕΝ έχει // έγγραφο με το δικό του id (μόνο «γονέας-παραλλαγή»). if ( $variation_indexing && $product->is_type( 'variable' ) ) { return 0; } return (int) $product->get_id(); } if ( $variation_indexing && preg_match( '/^(\d+)-(\d+)$/', $doc_id, $m ) ) { $product = wc_get_product( (int) $m[1] ); $variation = wc_get_product( (int) $m[2] ); if ( ! ( $product instanceof WC_Product ) || ! $product->is_type( 'variable' ) || ! $this->product_should_be_indexed( $product ) ) { return 0; } if ( ! ( $variation instanceof WC_Product ) || (int) $variation->get_parent_id() !== (int) $product->get_id() || 'publish' !== $variation->get_status() || ! $this->is_variation_available( $variation ) ) { return 0; } return (int) $product->get_id(); } return 0; } /** * Μόνο «123» ή «123-456» — ό,τι παράγει αυτός ο indexer. * * @param string $id Id εγγράφου. * @return bool */ private static function valid_doc_id( $id ) { return 1 === preg_match( '/^\d+(-\d+)?$/', (string) $id ); } /* ----------------------------------------------------------------- * Background processors — εδώ επιτρέπεται κλήση δικτύου (Action Scheduler worker). * --------------------------------------------------------------- */ /** * Processor του `addsynapse_search_sync_product`. * * @param int $product_id Product id. * @return void */ public function process_sync_action( $product_id ) { if ( ! $this->license_ready( 'sync product ' . (int) $product_id ) ) { return; } $product = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : false; if ( ! ( $product instanceof WC_Product ) ) { // Μπορεί να διαγράφηκε στο μεταξύ -- δεν ξέρουμε πια τα // variation ids του (ΔΕΝ φορτώνεται), best-effort μόνο για το // γονικό id. Η ΚΥΡΙΑ διαδρομή διαγραφής (before_delete_post, // βλ. handle_delete_post()) πιάνει τα variation ids ΝΩΡΙΤΕΡΑ, // πριν φύγει η ευκαιρία -- αυτό εδώ είναι απλώς δίχτυ ασφαλείας. $this->enqueue_product_delete( (int) $product_id ); return; } if ( ! $this->product_should_be_indexed( $product ) ) { $this->enqueue_product_delete( (int) $product_id, $this->get_variation_ids_for_product( (int) $product_id ) ); return; } $since = self::pdel_stamp(); $docs = $this->build_documents( $product ); // 🔴 0.5.2: σε παρτίδες των BATCH_SIZE (= MAX_BATCH του server). Ένα // variable προϊόν με >100 διαθέσιμες παραλλαγές έστελνε ΟΛΑ τα έγγραφα // σε ΕΝΑ αίτημα → 422 από τον server → ΚΑΝΕΝΑ έγγραφο του δεν // ενημερωνόταν ποτέ (παλιός τίτλος ως την πλήρη επαναδεικτοδότηση). if ( ! empty( $docs ) ) { $this->send_batch( $docs, $since ); } // 🔴 ΚΡΙΣΙΜΟ (task-brief §4): αλλαγή αποθέματος variation πρέπει να // ενημερώνει το ΣΩΣΤΟ έγγραφο -- αν μια variation μόλις έγινε // εξαντλημένη, build_documents() δεν την ξαναπαράγει (βλ. σχόλιο // εκεί), άρα το ΠΑΛΙΟ της έγγραφο ("parent-variation") μένει // ΦΑΝΤΑΣΜΑ στον δείκτη εκτός αν το σβήσουμε ρητά εδώ. // // 🔴 Μόνο όταν variation_indexing είναι ΟΝ ΤΩΡΑ: με το flag off, // ΠΟΤΕ δεν υπάρχουν "parent-variation" έγγραφα να γίνουν stale (βλ. // build_documents()) -- το επιπλέον DELETE θα ήταν πάντα no-op αλλά // ΘΑ ΗΤΑΝ επιπλέον HTTP κλήση σε ΚΑΘΕ sync variable προϊόντος, κάτι // που η υπόσχεση "off = ΑΚΡΙΒΩΣ όπως σήμερα" (καμία παλινδρόμηση) // δεν επιτρέπει. if ( class_exists( 'Addsynapse_Search_Settings' ) && Addsynapse_Search_Settings::get( 'variation_indexing', false ) ) { $this->cleanup_stale_variation_docs( $product, $docs ); } } /** * Σβήνει τα έγγραφα παραλλαγών που ΔΕΝ ξαναπαράχθηκαν σε αυτό το sync * (variation έγινε μη-διαθέσιμη, ή το variation_indexing είναι OFF -- * οπότε ΚΑΝΕΝΑ "parent-variation" έγγραφο δεν παράγεται πια). Απλά * προϊόντα δεν έχουν ποτέ τέτοια έγγραφα, δεν κάνει τίποτα. * * @param WC_Product $product Το προϊόν. * @param array[] $produced_docs Τα έγγραφα που μόλις στάλθηκαν (build_documents()). * @return void */ private function cleanup_stale_variation_docs( WC_Product $product, array $produced_docs ) { if ( ! $product->is_type( 'variable' ) ) { return; } $parent_id = (string) $product->get_id(); $produced_ids = array(); foreach ( $produced_docs as $produced_doc ) { if ( isset( $produced_doc['id'] ) ) { $produced_ids[ (string) $produced_doc['id'] ] = true; } } $stale_ids = array(); foreach ( $product->get_children() as $variation_id ) { $doc_id = $parent_id . '-' . $variation_id; if ( ! isset( $produced_ids[ $doc_id ] ) ) { $stale_ids[] = $doc_id; } } if ( empty( $stale_ids ) ) { return; } $this->delete_now_or_queue( $stale_ids, 'καθαρισμός παλιών εγγράφων παραλλαγών για προϊόν ' . $parent_id ); } /** * Processor του `addsynapse_search_delete_product`. * * @param int $product_id Product id. * @param int[] $variation_ids Variation ids γνωστά τη στιγμή της ουράς (βλ. enqueue_product_delete()). * @return void */ public function process_delete_action( $product_id, $variation_ids = array() ) { // Από 0.3.4 καμία νέα τέτοια ενέργεια δεν μπαίνει στην ουρά (βλ. // enqueue_product_delete()) — μένει για όσες είχαν ήδη προγραμματιστεί // πριν την αναβάθμιση. Ό,τι αποτύχει (ή άδεια που δεν ελέγχθηκε) πάει // στη λίστα εκκρεμών διαγραφών — ΠΟΤΕ σιωπηλή απώλεια (audit F03). $ids = self::doc_ids_for( (int) $product_id, (array) $variation_ids ); if ( ! $this->license_ready( 'delete product ' . (int) $product_id ) ) { $this->queue_doc_deletes( $ids ); return; } $this->delete_now_or_queue( $ids, 'delete απέτυχε για προϊόν ' . (int) $product_id ); } /** * Αν πρέπει να είναι στον δείκτη (δημοσιευμένο προϊόν). * * 🔴 ΣΗΜΕΙΟ ΕΠΕΚΤΑΣΗΣ (docs/DEVELOPERS.md): filter `addsynapse_search_should_index_product` * -- developers μπορούν να εξαιρέσουν προϊόντα (π.χ. ένα custom "hidden * from search" meta) γυρνώντας false, ΧΩΡΙΣ να αγγίξουν αυτό το αρχείο. * Το αποτέλεσμα καθορίζει ΚΑΙ την ένταξη (process_sync_action()) ΚΑΙ την * εξαίρεση (ίδια μέθοδος στο process_reindex_page()/run_batch_now()) -- * ΕΝΑ σημείο αλήθειας. * * @param WC_Product $product Το προϊόν. * @return bool */ private function product_should_be_indexed( WC_Product $product ) { $should = 'publish' === $product->get_status(); // 🔴 Κρυφά προϊόντα ΔΕΝ μπαίνουν στον δείκτη: ο δείκτης διαβάζεται // δημόσια από τον browser (tenant token), άρα ό,τι είναι εκεί το // βλέπει ο επισκέπτης. Προϊόν με κωδικό ή με ορατότητα καταλόγου // «shop only»/«hidden» θα διέρρεε τίτλο/τιμή/σύνδεσμο. if ( $should && '' !== (string) $product->get_post_password() ) { $should = false; } // 🔴 audit F04 (0.4.2): ΚΑΙ «catalog» (μόνο κατάλογος) — ο ίδιος // δείκτης τροφοδοτεί τα grids καταλόγου/φίλτρων, και ένα τέτοιο // προϊόν εξαφανιζόταν μόλις ο πελάτης ενεργοποιούσε φίλτρο. Η // αναζήτηση το εξαιρεί με `NOT catalog_visibility = "catalog"`, τα // grids εξαιρούν τα «search». Το «hidden» μένει ΠΑΝΤΑ εκτός. if ( $should && ! in_array( $product->get_catalog_visibility(), array( 'visible', 'catalog', 'search' ), true ) ) { $should = false; } /** * Φιλτράρει αν ένα προϊόν πρέπει να δεικτοδοτηθεί. * * @param bool $should Η προεπιλεγμένη απόφαση (true αν δημοσιευμένο, χωρίς κωδικό, ορατό στην αναζήτηση). * @param WC_Product $product Το προϊόν. */ return (bool) apply_filters( 'addsynapse_search_should_index_product', $should, $product ); } /* ----------------------------------------------------------------- * Document builder — ΑΚΡΙΒΩΣ τα πεδία του ARCHITECTURE.md §3. * --------------------------------------------------------------- */ /** * Χτίζει το έγγραφο δεικτοδότησης για ένα προϊόν. * * @param WC_Product $product Το προϊόν. * @return array */ public function build_document( WC_Product $product ) { $id = $product->get_id(); $title = $product->get_name(); $categories = $this->collect_categories( $product ); $category_ids = $this->collect_category_ids( $product ); $brand = $this->collect_brand( $product ); $description = ''; if ( class_exists( 'Addsynapse_Search_Settings' ) && Addsynapse_Search_Settings::get( 'index_description', true ) ) { $raw = $product->get_description(); if ( '' === trim( wp_strip_all_tags( $raw ) ) ) { $raw = $product->get_short_description(); } $plain = wp_strip_all_tags( $raw ); $description = function_exists( 'mb_substr' ) ? mb_substr( $plain, 0, 1000 ) : substr( $plain, 0, 1000 ); } $attribute_values = $this->collect_attributes( $product ); $attribute_names = $this->collect_attribute_names( $product ); $wordlike_attr_values = array_values( array_filter( $attribute_values, array( $this, 'is_wordlike_attribute_value' ) ) ); // 🔴 ΔΙΟΡΘΩΣΗ: η μεταγραφή/stemming ΔΕΝ πρέπει να βλέπει μόνο τον // τίτλο. Μετρημένο στο fitness-store: μόνο 865/5.195 (17%) τίτλων // έχουν καν ελληνικούς χαρακτήρες (πολλά είναι αγγλικά, π.χ. "Olimp // Creatine Monohydrate") — το ταίριασμα «κρεατίνη» γίνεται μέσω // ΚΑΤΗΓΟΡΙΑΣ («Κρεατίνες»), που πριν δεν έμπαινε ΚΑΘΟΛΟΥ σε // greeklish/stems. Αποτέλεσμα πριν: "kreatini" -> 0 προϊόντα. // // ΓΥΡΟΣ 6: προστέθηκαν και τα ΛΕΚΤΙΚΑ attributes (τιμές ΚΑΙ ονόματα // — π.χ. "Κόκκινο", "Χρώμα", "Νούμερο") — βλ. σχόλιο στο // ATTRIBUTE_WORDLIKE_MIN_LENGTH. Οι αριθμητικές/κωδικές τιμές ("42", // "XL") ΔΕΝ μπαίνουν εδώ — μένουν μόνο στο πεδίο `attributes` // (καμία μεταγραφή, θα ήταν θόρυβος, βλ. is_wordlike_attribute_value()). // Σειρά προτεραιότητας ΣΚΟΠΙΜΗ, δες build_searchable_text(). $searchable_text = $this->build_searchable_text( $title, $categories, $brand, $attribute_names, $wordlike_attr_values, $description, $product ); $transliterated = $this->transliterate_fields( $searchable_text ); $greeklish = $transliterated['greeklish']; $greek_spelling = $transliterated['greek_spelling']; $stems = $transliterated['stems']; $doc = array( 'id' => (string) $id, 'title' => $title, 'greeklish' => $greeklish, 'greek_spelling' => $greek_spelling, 'stems' => $stems, 'attributes' => $attribute_values, // Ομαδοποιημένα ανά ιδιότητα — τα χρησιμοποιούν ΜΟΝΟ τα φίλτρα // (βλ. collect_attributes_by_name()). 'attribute_pairs' => self::flatten_attribute_pairs( $this->collect_attributes_by_name( $product ) ), 'sku' => (string) $product->get_sku(), 'barcodes' => $this->collect_barcodes( $product ), 'brand' => $brand, 'categories' => $categories, // 🔴 audit F06: ΣΤΑΘΕΡΑ ids, όχι ονόματα (δύο ομώνυμες υποκατηγορίες // σε άλλους κλάδους δεν μπερδεύονται). `category_path_ids` = άμεσες + // ΟΛΟΙ οι πρόγονοι: το archive της «Α» φιλτράρει // `category_path_ids = <Α>` και βρίσκει και το προϊόν που είναι // ΜΟΝΟ στην υποκατηγορία «Β». Τα ονόματα μένουν για εμφάνιση/facets. 'category_ids' => $category_ids['direct'], 'category_path_ids' => $category_ids['path'], 'brand_ids' => $this->collect_brand_ids( $product ), 'description' => $description, 'price' => $this->display_price( $product ), // Σήμα «Προσφορά» (advanced πλέγμα, addsynapse-search.js): ΜΟΝΟ σε // προσφορά, αλλιώς null. Χωρίς αυτό το σήμα δεν εμφανιζόταν ποτέ. 'regular_price' => $this->display_regular_price( $product ), 'in_stock' => (bool) $product->is_in_stock(), // 0.8.0 — «σημαντικότερα» προϊόντα (κλάδεμα σε υποβάθμιση/Free, backend // App\Services\Coverage): πωλήσεις → διαθεσιμότητα → νεότερα. Δεν αναζητούνται, δεν εμφανίζονται. 'total_sales' => max( 0, (int) $product->get_total_sales() ), 'date_created' => self::created_timestamp( $product ), 'is_active' => 'publish' === $product->get_status(), // «visible», «catalog» ή «search» (το «hidden» δεν δεικτοδοτείται, // βλ. product_should_be_indexed()). Αναζήτηση/autocomplete: // `NOT catalog_visibility = "catalog"`· grids καταλόγου/φίλτρων: // `NOT catalog_visibility = "search"` (audit F04). 'catalog_visibility' => $this->catalog_visibility( $product ), 'permalink' => (string) get_permalink( $id ), 'image' => $this->get_image_url( $product ), ); // 🔴 ΕΠΙΠΛΕΟΝ πεδίο, ΜΟΝΟ για εμφάνιση (βλ. collect_available_options()) — // ΔΕΝ αντικαθιστά ούτε αγγίζει το `attributes` πιο πάνω, που το // χρησιμοποιεί η ίδια η αναζήτηση. Απών/κενό για απλά προϊόντα χωρίς // παραλλαγές (CLAUDE.md § Δύο γνωστά όρια, σημείο 2: "μην προσθέσεις θόρυβο"). $available_options = $this->collect_available_options( $product ); if ( ! empty( $available_options ) ) { $doc['available_options'] = $available_options; } return $this->apply_document_filters( $doc, $product ); } /** * Ημερομηνία δημιουργίας ως unix timestamp (0 = άγνωστη). * * @param WC_Product $product Προϊόν. * @return int */ private static function created_timestamp( WC_Product $product ) { $date = $product->get_date_created(); return $date instanceof WC_DateTime ? max( 0, (int) $date->getTimestamp() ) : 0; } /** * Η τιμή όπως τη δείχνει το κατάστημα σε ΑΝΩΝΥΜΟ επισκέπτη (audit F12). * * 🔴 Πριν στέλναμε (float) get_price(): σε κατάστημα με τιμές ΧΩΡΙΣ ΦΠΑ * που εμφανίζει ΜΕ ΦΠΑ, η κάρτα αναζήτησης έδειχνε άλλη τιμή από τη * σελίδα προϊόντος· και άτιμο προϊόν γινόταν «0» (= δωρεάν). * * - Κενή τιμή → null (δεν εμφανίζεται)· πραγματικό 0 μένει 0. * - wc_get_price_to_display() = ρύθμιση `woocommerce_tax_display_shop`. * - Φόρος με βάση τη ΔΙΕΥΘΥΝΣΗ ΤΟΥ ΚΑΤΑΣΤΗΜΑΤΟΣ (ό,τι βλέπει ο ανώνυμος * επισκέπτης), ΟΧΙ του διαχειριστή που τυχαίνει να τρέχει το AJAX * rebuild — ο δείκτης είναι κοινός για όλους. * - Variable προϊόν: get_price() = η ελάχιστη ενεργή τιμή («από»). * * Όριο (readme FAQ): τιμές ανά χώρα πελάτη, πολλαπλά νομίσματα ή τιμές * ανά ρόλο ΔΕΝ αποτυπώνονται — ο δημόσιος δείκτης κρατά ΜΙΑ τιμή. * * @param WC_Product $product Προϊόν ή παραλλαγή. * @return float|null */ private function display_price( WC_Product $product, $raw = null ) { $raw = null === $raw ? $product->get_price() : $raw; if ( '' === (string) $raw || ! is_numeric( $raw ) ) { return null; } if ( ! function_exists( 'wc_get_price_to_display' ) ) { return (float) $raw; } add_filter( 'woocommerce_get_tax_location', array( __CLASS__, 'guest_tax_location' ), 999 ); try { $price = wc_get_price_to_display( $product, array( 'price' => $raw ) ); } catch ( \Throwable $e ) { $price = $raw; } remove_filter( 'woocommerce_get_tax_location', array( __CLASS__, 'guest_tax_location' ), 999 ); return is_numeric( $price ) ? (float) $price : null; } /** * Κανονική τιμή (ίδιοι κανόνες ΦΠΑ με display_price()) ΜΟΝΟ όταν το * προϊόν είναι σε προσφορά και η κανονική είναι μεγαλύτερη· αλλιώς null. * Μεταβλητά προϊόντα: null — το «από» της τιμής και η ελάχιστη κανονική * μπορεί να ανήκουν σε ΔΙΑΦΟΡΕΤΙΚΕΣ παραλλαγές (ψευδές σήμα). * * @param WC_Product $product Προϊόν ή παραλλαγή. * @return float|null */ private function display_regular_price( WC_Product $product ) { if ( $product->is_type( 'variable' ) || $product->is_type( 'grouped' ) || ! $product->is_on_sale() ) { return null; } $regular = $product->get_regular_price(); if ( '' === (string) $regular || ! is_numeric( $regular ) ) { return null; } $regular = $this->display_price( $product, $regular ); $price = $this->display_price( $product ); return null !== $regular && null !== $price && $regular > $price ? $regular : null; } /** * Filter `woocommerce_get_tax_location` ΜΟΝΟ όσο υπολογίζεται η τιμή * δείκτη: η τοποθεσία ενός ΝΕΟΥ ανώνυμου επισκέπτη (όχι του διαχειριστή * που τρέχει το rebuild). Ίδιοι κανόνες με WC_Tax::get_tax_location() * χωρίς πελάτη· η γεωτοποθέτηση αντιστοιχίζεται στη βάση του καταστήματος. * * @param array $location Τοποθεσία που υπολόγισε το WooCommerce. * @return array */ public static function guest_tax_location( $location ) { if ( ! function_exists( 'WC' ) || ! WC()->countries ) { return $location; } $default_address = (string) get_option( 'woocommerce_default_customer_address' ); $based_on = (string) get_option( 'woocommerce_tax_based_on' ); $prices_incl = function_exists( 'wc_prices_include_tax' ) && wc_prices_include_tax(); if ( $prices_incl || '' !== $default_address || 'base' === $based_on ) { return array( WC()->countries->get_base_country(), WC()->countries->get_base_state(), WC()->countries->get_base_postcode(), WC()->countries->get_base_city(), ); } return array(); } /** * @param WC_Product $product Το (γονικό) προϊόν. * @return string «visible», «catalog» ή «search» (το «hidden» δεν δεικτοδοτείται). */ private function catalog_visibility( WC_Product $product ) { $visibility = $product->get_catalog_visibility(); return in_array( $visibility, array( 'catalog', 'search' ), true ) ? $visibility : 'visible'; } /** * 🔴 ΣΗΜΕΙΟ ΕΠΕΚΤΑΣΗΣ #1, το κύριο (docs/DEVELOPERS.md): filter * `addsynapse_search_product_document`. Developers προσθέτουν ΔΙΚΑ ΤΟΥΣ πεδία μέσα * στο δεσμευμένο `custom` object (π.χ. `$doc['custom']['brand_name'] = * '...'`) -- βλ. backend DocumentsController::store() για τον κανόνα * validation (`documents.*.custom` => nullable|array) και Meili:: * provisionIndex() για ποια custom.* κλειδιά γίνονται searchable/ * filterable. ΠΟΤΕ πειράζουμε core πεδία εδώ -- μόνο εφαρμόζουμε το * φίλτρο και καθαρίζουμε ένα άδειο `custom` (μην στέλνουμε θόρυβο σε * προϊόντα χωρίς κανένα custom πεδίο). * * @param array $doc Το ήδη-χτισμένο έγγραφο. * @param WC_Product $product Το προϊόν (ή η variation -- βλ. build_variation_document()). * @return array */ private function apply_document_filters( array $doc, WC_Product $product ) { // 🔴 «&» στο dropdown κατηγοριών (0.3.4): το WordPress αποθηκεύει // ονόματα όρων (κατηγορίες, brands, attribute terms) ΚΩΔΙΚΟΠΟΙΗΜΕΝΑ // σε HTML («Παπούτσια & Αθλητικά»). Στέλναμε το ωμό κείμενο, το JS // το ξανα-escape-άριζε σωστά → ο επισκέπτης έβλεπε «&». Το // έγγραφο κρατά ΚΑΘΑΡΟ κείμενο· το escaping γίνεται ΜΟΝΟ στο render. foreach ( array( 'title', 'brand', 'description', 'categories', 'attributes', 'attribute_pairs', 'available_options' ) as $text_field ) { if ( isset( $doc[ $text_field ] ) ) { $doc[ $text_field ] = self::decode_deep( $doc[ $text_field ] ); } } /** * Φιλτράρει το τελικό έγγραφο δεικτοδότησης πριν σταλεί στο API. * * @param array $doc Το έγγραφο (id/title/greeklish/.../custom/...). * @param WC_Product $product Το προϊόν (ή variation) που το παρήγαγε. */ $doc = apply_filters( 'addsynapse_search_product_document', $doc, $product ); if ( ! is_array( $doc ) ) { // Αμυντικά -- ένα φίλτρο που επιστρέφει κάτι λάθος δεν πρέπει να // ρίξει fatal error παρακάτω στο call stack (json_encode κλπ.). return array(); } if ( isset( $doc['custom'] ) && ( ! is_array( $doc['custom'] ) || empty( $doc['custom'] ) ) ) { unset( $doc['custom'] ); } // ΜΕΤΑ το φίλτρο, ώστε κανένα φίλτρο να μην το χάσει κατά λάθος: // χωρίς σφραγίδα, το έγγραφο θα σβηνόταν στην επόμενη σκούπα. $doc['sync_gen'] = self::current_generation(); return $doc; } /** * Αποκωδικοποιεί HTML entities (& – " …) σε καθαρό UTF-8. * Το non-breaking space γίνεται απλό κενό (αλλιώς κολλάει λέξεις στη * μεταγραφή/stemming). * * @param mixed $value Κείμενο. * @return string */ public static function decode_text( $value ) { $value = html_entity_decode( (string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return str_replace( "\xC2\xA0", ' ', $value ); } /** * decode_text() αναδρομικά σε πίνακες — ΚΑΙ στα κλειδιά (το * available_options έχει ετικέτες ιδιοτήτων ως κλειδιά). * * @param mixed $value Κείμενο ή πίνακας. * @return mixed */ private static function decode_deep( $value ) { if ( is_string( $value ) ) { return self::decode_text( $value ); } if ( ! is_array( $value ) ) { return $value; } $out = array(); foreach ( $value as $key => $item ) { $key = is_string( $key ) ? self::decode_text( $key ) : $key; $out[ $key ] = self::decode_deep( $item ); } return $out; } /* ----------------------------------------------------------------- * «Ακριβής αναζήτηση παραλλαγών» -- ΕΝΑ έγγραφο ανά variation, βλ. * CLAUDE.md § Δύο γνωστά όρια, σημείο 2 (Reebok Classic: "κόκκινο 39" * γυρνούσε το προϊόν παρότι το κόκκινο 39 είναι εξαντλημένο -- ζήτημα * ΖΕΥΓΟΥΣ που δεν λύνεται με ένα έγγραφο ανά προϊόν, όσο κι αν * ρυθμιστεί ο δείκτης). * --------------------------------------------------------------- */ /** * Χτίζει ΟΛΑ τα έγγραφα δεικτοδότησης ενός προϊόντος -- ΕΝΑ για απλά * προϊόντα ή όταν `variation_indexing` είναι OFF (🔴 ΑΚΡΙΒΩΣ η ίδια * έξοδος με build_document(), καμία παλινδρόμηση), ή ΕΝΑ ΑΝΑ VARIATION * όταν είναι ON και το προϊόν έχει παραλλαγές. * * Αυτό είναι το σημείο εισόδου που πρέπει να καλούν ΟΛΟΙ οι callers * που στέλνουν έγγραφα στο API (process_sync_action(), process_reindex_page(), * run_batch_now()) -- ΟΧΙ απευθείας το build_document(), αλλιώς * παραλλαγές δεν δεικτοδοτούνται ποτέ όσο κι αν είναι ενεργό το flag. * * @param WC_Product $product Το προϊόν. * @return array[] Λίστα εγγράφων (μπορεί να είναι άδεια αν το προϊόν * είναι variable και ΚΑΜΙΑ variation δεν είναι * αγοράσιμη αυτή τη στιγμή -- βλ. σχόλιο παρακάτω). */ public function build_documents( WC_Product $product ) { $variation_indexing = class_exists( 'Addsynapse_Search_Settings' ) && Addsynapse_Search_Settings::get( 'variation_indexing', false ); if ( ! $variation_indexing ) { return array( $this->build_document( $product ) ); } $parent_id = (string) $product->get_id(); if ( ! $product->is_type( 'variable' ) ) { $doc = $this->build_document( $product ); $doc['parent_id'] = $parent_id; // 🔴 0.8.3: με variation_indexing οι επιλογές/πλήθη των φίλτρων διαβάζονται ΜΟΝΟ // από το `facet_pairs` — χωρίς αυτό τα απλά προϊόντα έλειπαν από τα φίλτρα. return self::add_facet_pairs( array( $doc ) ); } // Το "έγγραφο-σκελετός" δίνει τα ΚΛΗΡΟΝΟΜΟΥΜΕΝΑ πεδία (task-brief §2): // title/greeklish/greek_spelling/stems/categories/brand/description/ // available_options -- ΜΙΑ φορά, όχι ανά variation. Τα ΥΠΟΛΟΙΠΑ πεδία // (attributes/sku/barcodes/price/in_stock/is_active/permalink/image) // αγνοούνται εδώ -- τα ξαναχτίζει build_variation_document() ανά // variation, ΔΕΝ κληρονομούνται. $parent_doc = $this->build_document( $product ); $docs = array(); foreach ( $product->get_children() as $variation_id ) { $variation = function_exists( 'wc_get_product' ) ? wc_get_product( $variation_id ) : false; if ( ! ( $variation instanceof WC_Product ) || ! $this->is_variation_available( $variation ) ) { // 🔴 ΣΚΟΠΙΜΑ ΚΑΝΕΝΑ έγγραφο για μη-αγοράσιμες variations: αν // η "κόκκινο 39" combo υπήρχε ως έγγραφο με in_stock=false, // η αναζήτηση "κόκκινο 39" θα την έβρισκε ΚΑΙ ΠΑΛΙ (ταίριασμα // ακριβούς combo, πολύ υψηλό score) και θα εμφανιζόταν έστω // υποβιβασμένη (oos_behavior=sink, η προεπιλογή) -- αντίθετο // από το ζητούμενο "δεν πρέπει να εμφανίζεται πια" (task-brief, // τεστ αποδοχής). Το να ΜΗΝ υπάρχει καν το έγγραφο είναι ο // μόνος τρόπος να είναι το in_stock πραγματικά ακριβές ΣΕ // ΕΠΙΠΕΔΟ ΣΥΝΔΥΑΣΜΟΥ, ανεξάρτητα από τη ρύθμιση oos_behavior. continue; } $docs[] = $this->build_variation_document( $product, $variation, $parent_doc, $parent_id ); } return self::add_facet_pairs( $docs ); } /** * Γράφει σε ΚΑΘΕ έγγραφο παραλλαγής το `facet_pairs` = η ΕΝΩΣΗ των * `attribute_pairs` όλων των (διαθέσιμων) παραλλαγών του προϊόντος. * Απλό προϊόν (με variation_indexing ON): ένα έγγραφο → facet_pairs = attribute_pairs * (0.8.3 — πριν έλειπε, και το προϊόν δεν μετρούσε στις επιλογές των φίλτρων). * * 🔴 ΓΙΑΤΙ (demo 29/09, Coffee: φίλτρο «Grind» έδειχνε ΜΟΝΟ «Whole Bean», * «Weight» ΜΟΝΟ «250 g»): με `distinctAttribute: parent_id` το Meilisearch * μετρά το facetDistribution ΜΟΝΟ πάνω στο έγγραφο-εκπρόσωπο κάθε * προϊόντος (την πρώτη παραλλαγή), όχι σε όλες. Τα δεδομένα ήταν σωστά * (φίλτρο `Grind:Espresso` → 7 προϊόντα), αλλά οι επιλογές που ΒΛΕΠΕΙ ο * πελάτης ήταν μόνο της πρώτης παραλλαγής. Κανένα σφάλμα πουθενά. * * Γι' αυτό δύο πεδία, δύο ρόλοι: * - `attribute_pairs` = ΜΟΝΟ ο συνδυασμός της παραλλαγής → το ΦΙΛΤΡΟ * (ακριβές «Espresso + 1 kg», ο λόγος ύπαρξης των εγγράφων παραλλαγής), * - `facet_pairs` = ό,τι έχει το προϊόν → οι ΕΠΙΛΟΓΕΣ/πλήθη των * φίλτρων (ο εκπρόσωπος τα κουβαλά όλα). * Ένωση ΜΟΝΟ των παραγόμενων εγγράφων (όχι των όρων του γονέα): μια τιμή * που υπάρχει μόνο σε εξαντλημένη παραλλαγή δεν έχει έγγραφο να βρει το * φίλτρο, άρα δεν πρέπει ούτε να προσφέρεται. * * Μπαίνει ΜΕΤΑ το filter `addsynapse_search_product_document` (χρειάζεται * όλα τα έγγραφα) — ό,τι πρόσθεσε εκεί developer στο attribute_pairs * περνά κανονικά στην ένωση. * * @param array[] $docs Έγγραφα παραλλαγών ενός προϊόντος. * @return array[] */ private static function add_facet_pairs( array $docs ) { $union = array(); foreach ( $docs as $doc ) { foreach ( ( isset( $doc['attribute_pairs'] ) ? (array) $doc['attribute_pairs'] : array() ) as $pair ) { $union[ (string) $pair ] = true; } } // strval: ίδιο gotcha με collect_flat_available_options() (numeric keys → int). $union = array_map( 'strval', array_keys( $union ) ); foreach ( $docs as $i => $doc ) { $docs[ $i ]['facet_pairs'] = $union; } return $docs; } /** * Χτίζει το έγγραφο ΜΙΑΣ variation -- κληρονομεί ό,τι λέει το * task-brief §2 από το $parent_doc, ξαναχτίζει τα υπόλοιπα ΜΟΝΟ από * τη variation. * * 🔴 ΔΙΟΡΘΩΣΗ ΜΕΤΑ ΑΠΟ ΠΡΑΓΜΑΤΙΚΟ ΤΕΣΤ (noimatest, «Reebok Classic»): * τα greeklish/greek_spelling/stems ΔΕΝ αντιγράφονται αυτούσια από το * $parent_doc, παρόλο που το task-brief τα αναφέρει στη λίστα * "κληρονομούμενων". Το build_document() του γονέα (βλ. εκεί) χτίζει * το searchable text από τις ΛΕΚΤΙΚΕΣ τιμές ΟΛΩΝ των variations μαζί * (collect_attributes() -> wordlike_attr_values, π.χ. "Κόκκινο" ΚΑΙ * "Λευκό" και τα δύο). Αν το αντιγράφαμε αυτούσιο σε ΚΑΘΕ variation * doc, ΚΑΘΕ έγγραφο (ανεξαρτήτως χρώματος) θα περιείχε "kokkino" ΚΑΙ * "leuko" στο greeklish/stems του -- η αναζήτηση "κόκκινο 39" θα * ξανάβρισκε τη Λευκό-39 παραλλαγή (πραγματικά παρατηρήθηκε: score * 0.85, doc "489-495" = Λευκό/39, μέσω του μεταγραμμένου "κοκκιν" στο * ΔΙΚΟ ΤΗΣ stems) -- ΑΚΡΙΒΩΣ το ίδιο πρόβλημα ζεύγους που φτιάχνει όλο * αυτό το feature, από άλλη πόρτα. Ξαναχτίζουμε λοιπόν το searchable * text ΕΔΩ με ΜΟΝΟ τη ΔΙΚΗ ΤΗΣ λεκτική τιμή (π.χ. μόνο "Κόκκινο") -- * τίτλος/κατηγορίες/brand/περιγραφή/ονόματα attribute ΠΑΡΑΜΕΝΟΥΝ ίδια * με τον γονέα (αυτά ΕΙΝΑΙ γνήσια προϊόντος, όχι πηγή διαρροής). * * @param WC_Product $product Το γονικό (variable) προϊόν. * @param WC_Product $variation Η παραλλαγή. * @param array $parent_doc Το έγγραφο-σκελετός (βλ. build_documents()). * @param string $parent_id (string) $product->get_id(), ήδη υπολογισμένο. * @return array */ private function build_variation_document( WC_Product $product, WC_Product $variation, array $parent_doc, $parent_id ) { $variation_attribute_values = $this->collect_variation_attribute_values( $variation ); $variation_wordlike_values = array_values( array_filter( $variation_attribute_values, array( $this, 'is_wordlike_attribute_value' ) ) ); $variation_searchable_text = $this->build_searchable_text( $parent_doc['title'], $parent_doc['categories'], $parent_doc['brand'], $this->collect_attribute_names( $product ), $variation_wordlike_values, $parent_doc['description'], $variation ); $transliterated = $this->transliterate_fields( $variation_searchable_text ); $doc = array( 'id' => $parent_id . '-' . $variation->get_id(), 'parent_id' => $parent_id, // --- Κληρονομούνται ΑΥΤΟΥΣΙΑ από τον γονέα (task-brief §2). --- 'title' => $parent_doc['title'], 'categories' => $parent_doc['categories'], 'category_ids' => isset( $parent_doc['category_ids'] ) ? $parent_doc['category_ids'] : array(), 'category_path_ids' => isset( $parent_doc['category_path_ids'] ) ? $parent_doc['category_path_ids'] : array(), 'brand_ids' => isset( $parent_doc['brand_ids'] ) ? $parent_doc['brand_ids'] : array(), 'brand' => $parent_doc['brand'], 'description' => $parent_doc['description'], // --- Ξαναχτισμένα με ΜΟΝΟ τη ΔΙΚΗ ΤΗΣ λεκτική τιμή (βλ. docblock). --- 'greeklish' => $transliterated['greeklish'], 'greek_spelling' => $transliterated['greek_spelling'], 'stems' => $transliterated['stems'], // --- Ανά παραλλαγή, ΔΙΚΑ ΤΗΣ (task-brief §2). --- 'attributes' => $variation_attribute_values, 'attribute_pairs' => self::flatten_attribute_pairs( $this->collect_variation_attributes_by_name( $variation ) ), 'sku' => (string) $variation->get_sku(), 'barcodes' => $this->collect_variation_barcodes( $variation ), 'price' => $this->display_price( $variation ), 'regular_price' => $this->display_regular_price( $variation ), 'in_stock' => $this->is_variation_available( $variation ), // Του ΓΟΝΕΑ: η σημασία μετριέται ανά προϊόν (ο κόμβος ομαδοποιεί ανά parent_id). 'total_sales' => isset( $parent_doc['total_sales'] ) ? (int) $parent_doc['total_sales'] : 0, 'date_created' => isset( $parent_doc['date_created'] ) ? (int) $parent_doc['date_created'] : 0, 'is_active' => $this->product_should_be_indexed( $product ), 'catalog_visibility' => $this->catalog_visibility( $product ), 'permalink' => $this->build_variation_permalink( $product, $variation ), 'image' => $this->get_variation_image_url( $product, $variation ), ); if ( isset( $parent_doc['available_options'] ) ) { $doc['available_options'] = $parent_doc['available_options']; } // Ίδιο φίλτρο addsynapse_search_product_document με το build_document() -- εδώ // το $product context είναι η ΙΔΙΑ η variation (πιο συγκεκριμένο για // τον developer από το γονικό $product, π.χ. για custom πεδία ανά // χρώμα/μέγεθος). return $this->apply_document_filters( $doc, $variation ); } /** * Οι τιμές attribute ΜΙΑΣ variation -- ΜΟΝΟ ο δικός της συνδυασμός * (π.χ. ["Κόκκινο","39"]), ΟΧΙ όλες οι τιμές όλων των variations του * γονέα (αυτό κάνει η collect_attributes(), χρησιμοποιείται ΜΟΝΟ όταν * variation_indexing=OFF). Ίδια λογική ταξινομικό-slug -> term name με * το variation-branch της collect_attributes(). * * @param WC_Product $variation Η παραλλαγή. * @return string[] */ /** * Ιδιότητες ΠΑΡΑΛΛΑΓΗΣ, ομαδοποιημένες ανά όνομα. * * 🔴 Χρειάζεται ΞΕΧΩΡΙΣΤΑ από το collect_attributes_by_name(): όταν η * ακριβής αναζήτηση παραλλαγών είναι ΕΝΕΡΓΗ, τα έγγραφα είναι ένα ανά * παραλλαγή — και ένα πεδίο που μπαίνει μόνο στο έγγραφο ΠΡΟΪΟΝΤΟΣ * απλώς δεν υπάρχει πουθενά. Ακριβώς αυτό συνέβη στην πρώτη υλοποίηση: * τα φίλτρα ζητούσαν `attributes_by_name.Χρώμα` και το Meilisearch * απαντούσε ότι δεν είναι filterable, επειδή ΚΑΝΕΝΑ έγγραφο δεν το είχε. * * @param WC_Product $variation * @return array */ private function collect_variation_attributes_by_name( WC_Product $variation ) { $grouped = array(); $parent = function_exists( 'wc_get_product' ) ? wc_get_product( $variation->get_parent_id() ) : null; foreach ( $variation->get_attributes() as $attr_name => $attr_value ) { $attr_value = (string) $attr_value; $label = function_exists( 'wc_attribute_label' ) ? wc_attribute_label( $attr_name, $parent instanceof WC_Product ? $parent : null ) : $attr_name; if ( '' === $attr_value ) { // «Οποιοδήποτε» (Any Grind…): η παραλλαγή πουλιέται σε ΟΛΕΣ τις // τιμές της ιδιότητας του γονέα. Χωρίς αυτό, το φίλτρο // «Grind:Espresso» δεν την έβρισκε ποτέ, ούτε ο πελάτης που // διάλεγε Espresso έβλεπε το προϊόν. $label = trim( (string) $label ); if ( '' !== $label && $parent instanceof WC_Product ) { foreach ( $this->parent_attribute_values( $parent, (string) $attr_name ) as $any_value ) { if ( ! isset( $grouped[ $label ] ) ) { $grouped[ $label ] = array(); } if ( ! in_array( $any_value, $grouped[ $label ], true ) ) { $grouped[ $label ][] = $any_value; } } } continue; } // Ταξινομική ιδιότητα: το αποθηκευμένο είναι slug, θέλουμε όνομα. if ( taxonomy_exists( $attr_name ) && function_exists( 'get_term_by' ) ) { $term = get_term_by( 'slug', $attr_value, $attr_name ); if ( $term && ! is_wp_error( $term ) ) { $attr_value = (string) $term->name; } } $label = trim( (string) $label ); if ( '' === $label ) { continue; } if ( ! isset( $grouped[ $label ] ) ) { $grouped[ $label ] = array(); } if ( ! in_array( $attr_value, $grouped[ $label ], true ) ) { $grouped[ $label ][] = $attr_value; } } if ( $grouped ) { self::remember_attribute_labels( array_keys( $grouped ) ); } return $grouped; } /** * Όλες οι (αναγνώσιμες) τιμές ΜΙΑΣ ιδιότητας του γονέα, από το κλειδί * που χρησιμοποιεί η παραλλαγή (`pa_grind` ή sanitize_title του custom * ονόματος, π.χ. `grind` για «Grind»). * * @param WC_Product $parent Γονικό προϊόν. * @param string $attr_key Κλειδί από $variation->get_attributes(). * @return string[] */ private function parent_attribute_values( WC_Product $parent, $attr_key ) { foreach ( $parent->get_attributes() as $key => $attribute ) { if ( ! is_object( $attribute ) || ! method_exists( $attribute, 'get_name' ) ) { continue; } if ( (string) $key !== $attr_key && sanitize_title( $attribute->get_name() ) !== $attr_key ) { continue; } if ( method_exists( $attribute, 'is_taxonomy' ) && $attribute->is_taxonomy() ) { $terms = function_exists( 'wc_get_product_terms' ) ? wc_get_product_terms( $parent->get_id(), $attribute->get_name(), array( 'fields' => 'names' ) ) : array(); return array_values( array_filter( array_map( 'strval', (array) $terms ), 'strlen' ) ); } return array_values( array_filter( array_map( 'trim', array_map( 'strval', (array) $attribute->get_options() ) ), 'strlen' ) ); } return array(); } private function collect_variation_attribute_values( WC_Product $variation ) { $values = array(); foreach ( $variation->get_attributes() as $attr_name => $attr_value ) { $attr_value = (string) $attr_value; if ( '' === $attr_value ) { continue; // "Οποιοδήποτε" -- καμία συγκεκριμένη τιμή επιλεγμένη. } if ( taxonomy_exists( $attr_name ) && function_exists( 'get_term_by' ) ) { $term = get_term_by( 'slug', $attr_value, $attr_name ); if ( $term && ! is_wp_error( $term ) ) { $values[] = (string) $term->name; continue; } } $values[] = $attr_value; } return array_values( array_unique( array_filter( $values, 'strlen' ) ) ); } /** * sku/EAN/barcode ΜΟΝΟ αυτής της variation -- ΟΧΙ όλων των variations * του γονέα (αυτό κάνει η collect_barcodes()). * * @param WC_Product $variation Η παραλλαγή. * @return string[] */ private function collect_variation_barcodes( WC_Product $variation ) { $codes = array(); $meta_keys = array( '_barcode', '_ean', '_gtin', '_upc' ); $sku = $variation->get_sku(); if ( '' !== $sku ) { $codes[] = $sku; } foreach ( $meta_keys as $meta_key ) { $value = $variation->get_meta( $meta_key, true ); if ( is_scalar( $value ) && '' !== (string) $value ) { $codes[] = (string) $value; } } return array_values( array_unique( array_filter( $codes, 'strlen' ) ) ); } /** * Deep-link στη σελίδα προϊόντος με query args ώστε το WooCommerce * (variation form JS) να προεπιλέγει ΑΥΤΗ τη variation -- ο πελάτης που * έψαξε "κόκκινο 42" προσγειώνεται με χρώμα/μέγεθος ΗΔΗ επιλεγμένα * (task-brief §2 🔴), όχι σε μια γενική σελίδα προϊόντος όπου πρέπει να * τα ξαναδιαλέξει. * * Χρησιμοποιεί τις ΩΜΕΣ τιμές variation->get_attributes() (slug για * ταξινομικά, raw για custom) -- ΟΧΙ τα αναγνώσιμα names (π.χ. * variation_display_value()) -- γιατί αυτό ΑΚΡΙΒΩΣ περιμένει η * τυπική μορφή `attribute_{key}=` που διαβάζει το ίδιο το * WooCommerce (add-to-cart-variation.js) για προεπιλογή από URL. * * @param WC_Product $product Το γονικό προϊόν. * @param WC_Product $variation Η παραλλαγή. * @return string */ private function build_variation_permalink( WC_Product $product, WC_Product $variation ) { $base = (string) get_permalink( $product->get_id() ); $query_args = array(); foreach ( $variation->get_attributes() as $attr_key => $attr_value ) { $attr_value = (string) $attr_value; if ( '' === $attr_value ) { continue; } $key = function_exists( 'sanitize_title' ) ? sanitize_title( $attr_key ) : strtolower( (string) $attr_key ); if ( '' === $key ) { continue; } $query_args[ 'attribute_' . $key ] = $attr_value; } if ( empty( $query_args ) ) { return $base; } return function_exists( 'add_query_arg' ) ? add_query_arg( $query_args, $base ) : ( $base . '?' . http_build_query( $query_args ) ); } /** * Εικόνα ΜΙΑΣ variation -- τη δική της αν έχει, αλλιώς του γονέα * (task-brief §2: "εικόνα της variation αν έχει, αλλιώς του γονέα"). * * @param WC_Product $product Το γονικό προϊόν (fallback). * @param WC_Product $variation Η παραλλαγή. * @return string */ private function get_variation_image_url( WC_Product $product, WC_Product $variation ) { $variation_image_id = $variation->get_image_id(); if ( $variation_image_id ) { $url = wp_get_attachment_image_url( $variation_image_id, 'medium' ); if ( $url ) { return $url; } } return $this->get_image_url( $product ); } /** * Ενώνει τίτλο + κατηγορίες + brand + ΛΕΚΤΙΚΑ attributes (ονόματα + * τιμές) + απόσπασμα περιγραφής σε ΕΝΑ κείμενο, με ΑΥΣΤΗΡΗ σειρά * προτεραιότητας, για να τροφοδοτήσει τη μεταγραφή/stemming * (greeklish_variants()/greek_spelling()/stems()). * * Η σειρά έχει σημασία: αν το συνολικό κείμενο ξεπεράσει το όριο * tokens της greeklish_variants() (Addsynapse_Search_Transliterate::MAX_GREEKLISH_TOKENS), * κόβεται ό,τι έρχεται ΤΕΛΕΥΤΑΙΟ — δηλαδή το λιγότερο σημαντικό μέρος * (περιγραφή), ΠΟΤΕ ο τίτλος. Αυτό δουλεύει επειδή η * greeklish_variants()/stems() επεξεργάζονται τις λέξεις με τη σειρά * που εμφανίζονται στο κείμενο — δεν χρειάζεται ξεχωριστή λογική * κοπής εδώ. * * Τα ονόματα/λεκτικές τιμές attribute μπαίνουν ΜΕΤΑ το brand και ΠΡΙΝ * την περιγραφή — πιο σημαντικά από ελεύθερο marketing κείμενο, λιγότερο * από τίτλο/κατηγορία/brand (ΓΥΡΟΣ 6, βλ. ATTRIBUTE_WORDLIKE_MIN_LENGTH). * * @param string $title Τίτλος προϊόντος. * @param string[] $categories Ονόματα κατηγοριών. * @param string $brand Όνομα brand (μπορεί να είναι κενό). * @param string[] $attribute_names Ονόματα attribute (π.χ. "Χρώμα", "Νούμερο"). * @param string[] $wordlike_attr_values Λεκτικές τιμές attribute (π.χ. "Κόκκινο") — ΟΧΙ κωδικοί/αριθμοί. * @param string $description Ήδη κομμένη περιγραφή (ως 1000 χαρακτήρες, βλ. build_document()). * @param WC_Product $product Το προϊόν (ή variation) -- context για το φίλτρο addsynapse_search_searchable_text. * @return string */ private function build_searchable_text( $title, array $categories, $brand, array $attribute_names, array $wordlike_attr_values, $description, WC_Product $product ) { $parts = array(); if ( '' !== trim( (string) $title ) ) { $parts[] = (string) $title; } if ( ! empty( $categories ) ) { $parts[] = implode( ' ', $categories ); } if ( '' !== trim( (string) $brand ) ) { $parts[] = (string) $brand; } if ( ! empty( $attribute_names ) ) { $parts[] = implode( ' ', $attribute_names ); } if ( ! empty( $wordlike_attr_values ) ) { $parts[] = implode( ' ', $wordlike_attr_values ); } if ( '' !== trim( (string) $description ) ) { $excerpt = function_exists( 'mb_substr' ) ? mb_substr( $description, 0, self::DESCRIPTION_EXCERPT_LENGTH ) : substr( $description, 0, self::DESCRIPTION_EXCERPT_LENGTH ); $parts[] = $excerpt; } // Καθαρό κείμενο (όχι «&») ΠΡΙΝ τη μεταγραφή/stemming — αλλιώς // παράγονται tokens όπως «amp» στα greeklish/stems. $text = self::decode_text( implode( ' ', $parts ) ); /** * 🔴 ΣΗΜΕΙΟ ΕΠΕΚΤΑΣΗΣ (docs/DEVELOPERS.md): filter `addsynapse_search_searchable_text`. * Το επιστρεφόμενο κείμενο περνάει ΑΠΕΥΘΕΙΑΣ στη μεταγραφή/στελέχωση * (greeklish/greek_spelling/stems, βλ. transliterate_fields()) -- ό,τι * προσθέσεις εδώ γίνεται αναζητήσιμο ΚΑΙ σε greeklish/λάθη * ορθογραφίας. ΔΕΝ αγγίζει το ξεχωριστό πεδίο `title` του εγγράφου. * * @param string $text Το ήδη συνενωμένο κείμενο (τίτλος + κατηγορίες + brand + attributes + περιγραφή). * @param WC_Product $product Το προϊόν (ή variation) που το παρήγαγε. */ return (string) apply_filters( 'addsynapse_search_searchable_text', $text, $product ); } /** * Τρέχει τη μεταγραφή/στελέχωση πάνω σε ένα ήδη-χτισμένο "searchable * text" (βλ. build_searchable_text()) -- κοινή λογική ανάμεσα στο * build_document() (όλο το προϊόν) και το build_variation_document() * (ΜΟΝΟ η δική της λεκτική τιμή -- βλ. εκεί για ΓΙΑΤΙ δεν αρκεί να * αντιγραφούν αυτούσια τα greeklish/stems του γονέα). * * @param string $searchable_text Το ήδη συνενωμένο κείμενο. * @return array{greeklish: string[], greek_spelling: string, stems: string[]} */ private function transliterate_fields( $searchable_text ) { $greeklish = array(); $greek_spelling = ''; $stems = array(); if ( class_exists( 'Addsynapse_Search_Transliterate' ) ) { if ( method_exists( 'Addsynapse_Search_Transliterate', 'greeklish_variants' ) ) { // Πολλαπλές παραλλαγές μεταγραφής (π.χ. "μπάρα" -> mpara/bara), // ώστε να μην χάνονται αναζητήσεις με τη «λάθος» λατινική γραφή. $variants = Addsynapse_Search_Transliterate::greeklish_variants( $searchable_text ); $greeklish = is_array( $variants ) ? array_values( array_filter( array_map( 'strval', $variants ), 'strlen' ) ) : array(); } elseif ( method_exists( 'Addsynapse_Search_Transliterate', 'greeklish' ) ) { // Fallback όσο δεν υπάρχει ακόμα η greeklish_variants(): μονοστοιχειακό array. $single = (string) Addsynapse_Search_Transliterate::greeklish( $searchable_text ); if ( '' !== $single ) { $greeklish = array( $single ); } } if ( method_exists( 'Addsynapse_Search_Transliterate', 'greek_spelling' ) ) { $greek_spelling = (string) Addsynapse_Search_Transliterate::greek_spelling( $searchable_text ); } if ( method_exists( 'Addsynapse_Search_Transliterate', 'stems' ) ) { $stems = Addsynapse_Search_Transliterate::stems( $searchable_text ); } } return array( 'greeklish' => $greeklish, 'greek_spelling' => $greek_spelling, 'stems' => $stems, ); } /** * Μαζεύει sku/EAN/barcode του προϊόντος ΚΑΙ όλων των παραλλαγών του. * * @param WC_Product $product Το προϊόν. * @return array */ private function collect_barcodes( WC_Product $product ) { $codes = array(); $meta_keys = array( '_barcode', '_ean', '_gtin', '_upc' ); $sku = $product->get_sku(); if ( '' !== $sku ) { $codes[] = $sku; } foreach ( $meta_keys as $meta_key ) { $value = $product->get_meta( $meta_key, true ); if ( is_scalar( $value ) && '' !== (string) $value ) { $codes[] = (string) $value; } } if ( $product->is_type( 'variable' ) ) { foreach ( $product->get_children() as $variation_id ) { $variation = wc_get_product( $variation_id ); if ( ! ( $variation instanceof WC_Product ) ) { continue; } $vsku = $variation->get_sku(); if ( '' !== $vsku ) { $codes[] = $vsku; } foreach ( $meta_keys as $meta_key ) { $value = $variation->get_meta( $meta_key, true ); if ( is_scalar( $value ) && '' !== (string) $value ) { $codes[] = (string) $value; } } } } return array_values( array_unique( array_filter( $codes, 'strlen' ) ) ); } /** * Μαζεύει τις τιμές όλων των attributes — γονικό ΚΑΙ variations * (μεγέθη, χρώματα, νούμερα κ.λπ., π.χ. ["42","43","44","Μαύρο"]). * * Καλύπτει ένα κενό: «παπούτσια 42» έβγαζε 0 αποτελέσματα επειδή το * «42» υπάρχει ΜΟΝΟ ως attribute τιμή σε μια variation (π.χ. attribute * "Νούμερο" = 42, SKU SZ-NIKE-42) — ούτε στον τίτλο ούτε στο sku/ * barcodes του γονικού προϊόντος, που είναι τα μόνα πεδία που * μαζεύαμε πριν από τις variations. * * 🔴 ΟΙ ΤΙΜΕΣ ΑΥΤΕΣ ΔΕΝ περνάνε από greeklish/greek_spelling/stems * (build_searchable_text() δεν τις συμπεριλαμβάνει) — είναι αριθμοί * και κωδικοί, η μεταγραφή δεν έχει νόημα και θα πρόσθετε μόνο θόρυβο * στο ταίριασμα ελεύθερου κειμένου. * * @param WC_Product $product Το προϊόν. * @return string[] */ /** * Ιδιότητες ΟΜΑΔΟΠΟΙΗΜΕΝΕΣ ανά όνομα: { "Χρώμα": ["Κόκκινο"], "Νούμερο": ["42"] }. * * 🔴 Γιατί ΔΙΠΛΑ στο επίπεδο `attributes` και όχι αντ' αυτού: το επίπεδο * το χρησιμοποιεί η ΑΝΑΖΗΤΗΣΗ (ένας χρήστης γράφει «κόκκινο 42» χωρίς να * ξέρει ποια ιδιότητα είναι τι). Το ομαδοποιημένο το χρησιμοποιούν τα * ΦΙΛΤΡΑ, όπου «Χρώμα: Κόκκινο» και «Νούμερο: 42» πρέπει να είναι * ξεχωριστές ομάδες. Δύο χρήσεις, δύο σχήματα. * * @param WC_Product $product * @return array */ private function collect_attributes_by_name( WC_Product $product ) { $grouped = array(); $add = function ( $label, $value ) use ( &$grouped ) { $label = trim( (string) $label ); $value = trim( (string) $value ); if ( '' === $label || '' === $value ) { return; } if ( ! isset( $grouped[ $label ] ) ) { $grouped[ $label ] = array(); } if ( ! in_array( $value, $grouped[ $label ], true ) ) { $grouped[ $label ][] = $value; } }; foreach ( $product->get_attributes() as $attribute ) { if ( ! is_object( $attribute ) || ! method_exists( $attribute, 'get_name' ) ) { continue; } // Η αναγνώσιμη ετικέτα («Χρώμα»), όχι το slug («pa_xroma»). $label = function_exists( 'wc_attribute_label' ) ? wc_attribute_label( $attribute->get_name(), $product ) : $attribute->get_name(); if ( method_exists( $attribute, 'is_taxonomy' ) && $attribute->is_taxonomy() ) { $terms = function_exists( 'wc_get_product_terms' ) ? wc_get_product_terms( $product->get_id(), $attribute->get_name(), array( 'fields' => 'names' ) ) : array(); foreach ( (array) $terms as $term_name ) { $add( $label, $term_name ); } } elseif ( method_exists( $attribute, 'get_options' ) ) { foreach ( (array) $attribute->get_options() as $option ) { $add( $label, $option ); } } } // 🔴 Μαθαίνουμε τις ετικέτες ΚΑΤΑ ΤΗ ΔΕΙΚΤΟΔΟΤΗΣΗ και τις κρατάμε. // // Τα φίλτρα χρειάζονται ρητά ονόματα πεδίων για το Meilisearch, και // το wc_get_attribute_taxonomies() ΔΕΝ αρκεί: τα περισσότερα μικρά // καταστήματα χρησιμοποιούν custom (μη-ταξινομικές) ιδιότητες, που // δεν εμφανίζονται εκεί καθόλου. Εδώ τις βλέπουμε όλες, όποιου τύπου. if ( $grouped ) { self::remember_attribute_labels( array_keys( $grouped ) ); } return $grouped; } /** * Συγκεντρώνει τα ονόματα ιδιοτήτων που έχουν εμφανιστεί στον κατάλογο. * * Γράφει ΜΟΝΟ όταν εμφανιστεί καινούργιο όνομα — μια πλήρης * επαναδεικτοδότηση 5.000 προϊόντων κάνει λίγες εγγραφές, όχι 5.000. * * @param string[] $labels * @return void */ /** * Μετατρέπει { "Χρώμα": ["Κόκκινο"] } σε ["Χρώμα:Κόκκινο"]. * * 🔴 ΓΙΑΤΙ ΕΠΙΠΕΔΟ ΚΑΙ ΟΧΙ ΕΜΦΩΛΕΥΜΕΝΟ ΑΝΤΙΚΕΙΜΕΝΟ: * δοκιμάστηκε πρώτα `attributes_by_name` ως object με * `attributes_by_name.*` στα filterableAttributes. Το Meilisearch 1.53 * δέχεται το μοτίβο στις ρυθμίσεις ΑΛΛΑ δεν το ταιριάζει σε πραγματικό * πεδίο: «Attribute `attributes_by_name.Χρώμα` is not filterable», ακόμα * και με master key. Ένα επίπεδο πεδίο δεν έχει αυτό το πρόβλημα, θέλει * ΕΝΑ facet αντί για ένα ανά ιδιότητα, και η ομαδοποίηση γίνεται στον * browser με split στο πρώτο «:». * * @param array $grouped * @return string[] */ private static function flatten_attribute_pairs( array $grouped ) { $pairs = array(); foreach ( $grouped as $label => $values ) { foreach ( (array) $values as $value ) { $pair = $label . ':' . $value; if ( ! in_array( $pair, $pairs, true ) ) { $pairs[] = $pair; } } } return $pairs; } private static function remember_attribute_labels( array $labels ) { $labels = array_map( array( __CLASS__, 'decode_text' ), $labels ); $known = get_option( self::OPTION_ATTRIBUTE_LABELS, array() ); $known = is_array( $known ) ? array_map( array( __CLASS__, 'decode_text' ), $known ) : array(); $new = array_values( array_diff( $labels, $known ) ); if ( ! $new ) { return; } update_option( self::OPTION_ATTRIBUTE_LABELS, array_values( array_unique( array_merge( $known, $new ) ) ), false ); } /** * @return string[] */ public static function attribute_labels() { $known = get_option( self::OPTION_ATTRIBUTE_LABELS, array() ); // decode: ετικέτες που αποθηκεύτηκαν από εκδόσεις πριν την 0.3.4 ως «A & B». return is_array( $known ) ? array_values( array_unique( array_filter( array_map( array( __CLASS__, 'decode_text' ), $known ) ) ) ) : array(); } private function collect_attributes( WC_Product $product ) { $values = array(); foreach ( $product->get_attributes() as $attribute ) { if ( is_object( $attribute ) && method_exists( $attribute, 'is_taxonomy' ) && $attribute->is_taxonomy() ) { $terms = function_exists( 'wc_get_product_terms' ) ? wc_get_product_terms( $product->get_id(), $attribute->get_name(), array( 'fields' => 'names' ) ) : array(); foreach ( (array) $terms as $term_name ) { $values[] = (string) $term_name; } } elseif ( is_object( $attribute ) && method_exists( $attribute, 'get_options' ) ) { // Custom (μη-ταξινομικό) attribute — τα options είναι ήδη // οι αναγνώσιμες τιμές, όχι slugs. foreach ( (array) $attribute->get_options() as $option ) { $values[] = (string) $option; } } } if ( $product->is_type( 'variable' ) ) { foreach ( $product->get_children() as $variation_id ) { $variation = function_exists( 'wc_get_product' ) ? wc_get_product( $variation_id ) : false; if ( ! ( $variation instanceof WC_Product ) ) { continue; } foreach ( $variation->get_attributes() as $attr_name => $attr_value ) { $attr_value = (string) $attr_value; if ( '' === $attr_value ) { continue; // "Οποιοδήποτε" -- καμία συγκεκριμένη τιμή επιλεγμένη. } // Taxonomy attributes αποθηκεύονται στη variation ως term // SLUG (π.χ. "42", "mayro") -- χρειάζεται lookup για το // αναγνώσιμο name όταν διαφέρει (π.χ. προσβάσιμα greeklish // slugs). Custom attributes είναι ήδη η ακριβής τιμή. if ( taxonomy_exists( $attr_name ) && function_exists( 'get_term_by' ) ) { $term = get_term_by( 'slug', $attr_value, $attr_name ); if ( $term && ! is_wp_error( $term ) ) { $values[] = (string) $term->name; continue; } } $values[] = $attr_value; } } } return array_values( array_unique( array_filter( $values, 'strlen' ) ) ); } /** * Μαζεύει τις ΔΙΑΘΕΣΙΜΕΣ (in-stock ΚΑΙ purchasable) επιλογές, ΜΟΝΟ από * variations — τροφοδοτεί το πεδίο `available_options` που δείχνει το * widget κάτω από τον τίτλο, ΠΡΙΝ ο πελάτης κάνει κλικ. * * 🔴 ΔΙΟΡΘΩΣΗ (μετά από πραγματικό παράδειγμα — «Reebok Classic», βλ. * CLAUDE.md § Δύο γνωστά όρια, σημείο 2): μια ΕΠΙΠΕΔΗ λίστα ανά attribute * («Χρώμα: Κόκκινο,Λευκό» / «Νούμερο: 39,40,41,42,43») ΔΕΝ αρκεί — * αν το Κόκκινο υπάρχει σε 40-43 και το 39 υπάρχει μόνο σε Λευκό, μια * επίπεδη λίστα δείχνει ΚΑΙ τα δύο ως "διαθέσιμα" και ο πελάτης που * έψαξε "κόκκινο 39" εξακολουθεί να μπερδεύεται. Χρειάζεται ΣΥΝΔΥΑΣΜΟΣ, * όχι δύο χωριστές λίστες. * * Δομή: * - ΜΙΑ διάσταση που μεταβάλλεται στις variations (π.χ. μόνο Μέγεθος): * επίπεδη λίστα, `{ "Νούμερο": ["40","42","44"] }`. * - ΔΥΟ+ διαστάσεις: ομαδοποίηση στις ΔΥΟ ΠΡΩΤΕΣ (με τη σειρά που τις * δίνει το WooCommerce), `{ "primary": "Χρώμα", "secondary": "Νούμερο", * "combos": { "Κόκκινο": ["40","41","42","43"], "Λευκό": ["39","40","41","42"] } }`. * Μια 3η+ διάσταση (π.χ. υλικό) ΔΕΝ μπαίνει στον συνδυασμό — ΓΝΩΣΤΟ ΟΡΙΟ, * σκόπιμα, βλ. task brief. Η «πρώτη» διάσταση προκύπτει από τη σειρά * κλειδιών της ΠΡΩΤΗΣ variation με attributes -- στην πράξη ταυτίζεται * με τη σειρά ορισμού στο γονικό προϊόν, χωρίς να χρειάζεται εύθραυστο * ταίριασμα ονόματος parent-attribute <-> variation-key (taxonomy slug * έναντι custom name δεν γράφονται πάντα ίδια). * * Μια τιμή/συνδυασμός μπαίνει ΜΟΝΟ αν προέρχεται από ΤΟΥΛΑΧΙΣΤΟΝ μία * variation που είναι ΚΑΙ in_stock() ΚΑΙ purchasable(). * * @param WC_Product $product Το προϊόν. * @return array Άδειο array για απλά προϊόντα χωρίς variations. */ private function collect_available_options( WC_Product $product ) { if ( ! $product->is_type( 'variable' ) ) { return array(); } $dimension_keys = $this->find_variation_dimension_keys( $product ); if ( empty( $dimension_keys ) ) { return array(); } if ( 1 === count( $dimension_keys ) ) { return $this->collect_flat_available_options( $product, $dimension_keys[0] ); } return $this->collect_combo_available_options( $product, $dimension_keys[0], $dimension_keys[1] ); } /** * Η σειρά των attribute keys (π.χ. "pa_color", "νούμερο") όπως τα δίνει * η ΠΡΩΤΗ variation με attributes -- ορίζει ποια διάσταση είναι "πρώτη" * (primary) και ποια "δεύτερη" (secondary) στο collect_combo_available_options(). * * @param WC_Product $product Το γονικό προϊόν. * @return string[] Λίστα attribute keys, με τη σειρά πρώτης εμφάνισης. */ private function find_variation_dimension_keys( WC_Product $product ) { foreach ( $product->get_children() as $variation_id ) { $variation = function_exists( 'wc_get_product' ) ? wc_get_product( $variation_id ) : false; if ( ! ( $variation instanceof WC_Product ) ) { continue; } $keys = array(); foreach ( $variation->get_attributes() as $attr_name => $attr_value ) { unset( $attr_value ); $attr_name = (string) $attr_name; if ( '' !== $attr_name && ! in_array( $attr_name, $keys, true ) ) { $keys[] = $attr_name; } } if ( ! empty( $keys ) ) { return $keys; } } return array(); } /** * ΜΙΑ διάσταση: επίπεδη λίστα διαθέσιμων τιμών, `{ label: [values] }`. * * @param WC_Product $product Το γονικό προϊόν. * @param string $dimension_key Το (μοναδικό) attribute key. * @return array */ private function collect_flat_available_options( WC_Product $product, $dimension_key ) { $label = $this->attribute_label( $dimension_key, $product ); if ( '' === $label ) { return array(); } $values_set = array(); foreach ( $product->get_children() as $variation_id ) { $variation = function_exists( 'wc_get_product' ) ? wc_get_product( $variation_id ) : false; if ( ! ( $variation instanceof WC_Product ) || ! $this->is_variation_available( $variation ) ) { continue; } foreach ( $this->variation_display_values( $product, $variation, $dimension_key ) as $value ) { $values_set[ $value ] = true; } } if ( empty( $values_set ) ) { return array(); } // 🔴 array_map('strval', ...): οι τιμές ("40","42"...) χρησιμοποιήθηκαν // ως array KEYS για dedup (πιο πάνω) -- η PHP μετατρέπει ΑΥΤΟΜΑΤΑ ένα // numeric-string key σε int (π.χ. '40' -> 40). Χωρίς το strval() εδώ, // json_encode() θα έστελνε ΑΡΙΘΜΟΥΣ αντί για strings στο Meilisearch. return array( $label => $this->cap_values( array_map( 'strval', array_keys( $values_set ) ), self::AVAILABLE_OPTIONS_MAX_VALUES ) ); } /** * ΔΥΟ+ διαστάσεις: ομαδοποίηση στις δύο πρώτες, `{ primary, secondary, combos }`. * * @param WC_Product $product Το γονικό προϊόν. * @param string $primary_key Attribute key της πρώτης διάστασης (π.χ. χρώμα). * @param string $secondary_key Attribute key της δεύτερης διάστασης (π.χ. νούμερο). * @return array{primary:string,secondary:string,combos:array}|array{} */ private function collect_combo_available_options( WC_Product $product, $primary_key, $secondary_key ) { $primary_label = $this->attribute_label( $primary_key, $product ); $secondary_label = $this->attribute_label( $secondary_key, $product ); if ( '' === $primary_label || '' === $secondary_label ) { return array(); } $combos = array(); // primary_value => [ secondary_value => true ] (σειρά πρώτης εμφάνισης). foreach ( $product->get_children() as $variation_id ) { $variation = function_exists( 'wc_get_product' ) ? wc_get_product( $variation_id ) : false; if ( ! ( $variation instanceof WC_Product ) || ! $this->is_variation_available( $variation ) ) { continue; } // «Οποιοδήποτε» (κενή τιμή) = ΟΛΕΣ οι τιμές της ιδιότητας του γονέα // — αλλιώς π.χ. «Filter / Any Weight» χανόταν ολόκληρο από την κάρτα. $primary_values = $this->variation_display_values( $product, $variation, $primary_key ); $secondary_values = $this->variation_display_values( $product, $variation, $secondary_key ); foreach ( $primary_values as $primary_value ) { if ( ! isset( $combos[ $primary_value ] ) ) { $combos[ $primary_value ] = array(); } foreach ( $secondary_values as $secondary_value ) { $combos[ $primary_value ][ $secondary_value ] = true; } } } if ( empty( $combos ) ) { return array(); } $more_groups = 0; if ( count( $combos ) > self::AVAILABLE_OPTIONS_MAX_PRIMARY_GROUPS ) { $more_groups = count( $combos ) - self::AVAILABLE_OPTIONS_MAX_PRIMARY_GROUPS; $combos = array_slice( $combos, 0, self::AVAILABLE_OPTIONS_MAX_PRIMARY_GROUPS, true ); } $result_combos = array(); foreach ( $combos as $primary_value => $secondary_set ) { // Ίδιο strval() gotcha με το collect_flat_available_options() πιο πάνω. $secondary_values = array_map( 'strval', array_keys( $secondary_set ) ); if ( empty( $secondary_values ) ) { continue; } $result_combos[ $primary_value ] = $this->cap_values( $secondary_values, self::AVAILABLE_OPTIONS_MAX_SECONDARY_VALUES ); } if ( empty( $result_combos ) ) { return array(); } if ( $more_groups > 0 ) { // Marker-only "ομάδα" χωρίς τιμές -- το widget το δείχνει σαν // bare text στο τέλος της γραμμής (βλ. buildComboOptionsLine() στο JS). /* translators: %d: how many more attribute-value groups exist beyond the ones shown. */ $result_combos[ sprintf( __( '+%d more', 'addsynapse-search-filter' ), $more_groups ) ] = array(); } return array( 'primary' => $primary_label, 'secondary' => $secondary_label, 'combos' => $result_combos, ); } /** * Αν μια variation είναι ΠΡΑΓΜΑΤΙΚΑ αγοράσιμη ΤΩΡΑ -- ίδιο κριτήριο με * «ο πελάτης μπορεί να την προσθέσει στο καλάθι». * * @param WC_Product $variation Η παραλλαγή. * @return bool */ private function is_variation_available( WC_Product $variation ) { $in_stock = ! method_exists( $variation, 'is_in_stock' ) || $variation->is_in_stock(); $purchasable = ! method_exists( $variation, 'is_purchasable' ) || $variation->is_purchasable(); return $in_stock && $purchasable; } /** * Η αναγνώσιμη τιμή ενός attribute key πάνω σε ΜΙΑ variation -- ίδια * λογική με collect_attributes() (taxonomy slug -> term name lookup), * αλλά για ένα μεμονωμένο key αντί να μαζεύει τα πάντα. * * @param WC_Product $variation Η παραλλαγή. * @param string $attr_key Το attribute key. * @return string Κενό string αν λείπει ή είναι "Οποιοδήποτε". */ private function variation_display_value( WC_Product $variation, $attr_key ) { $attrs = $variation->get_attributes(); if ( ! isset( $attrs[ $attr_key ] ) ) { return ''; } $attr_value = (string) $attrs[ $attr_key ]; if ( '' === $attr_value ) { return ''; } if ( taxonomy_exists( $attr_key ) && function_exists( 'get_term_by' ) ) { $term = get_term_by( 'slug', $attr_value, $attr_key ); if ( $term && ! is_wp_error( $term ) ) { return (string) $term->name; } } return $attr_value; } /** * Όπως variation_display_value(), αλλά μια κενή τιμή («Οποιοδήποτε») * επεκτείνεται σε ΟΛΕΣ τις τιμές της ιδιότητας του γονέα. * * @param WC_Product $product Γονικό προϊόν. * @param WC_Product $variation Η παραλλαγή. * @param string $attr_key Το attribute key. * @return string[] */ private function variation_display_values( WC_Product $product, WC_Product $variation, $attr_key ) { $value = $this->variation_display_value( $variation, $attr_key ); if ( '' !== $value ) { return array( $value ); } $attrs = $variation->get_attributes(); return isset( $attrs[ $attr_key ] ) ? $this->parent_attribute_values( $product, (string) $attr_key ) : array(); } /** * Κόβει μια λίστα τιμών στο δοσμένο όριο, προσθέτοντας «+N more» ως * τελευταίο στοιχείο αν κόπηκε κάτι. * * @param string[] $values Λίστα τιμών (χωρίς επαναλήψεις). * @param int $max Μέγιστο πλήθος πραγματικών τιμών πριν το «+N more». * @return string[] */ private function cap_values( array $values, $max ) { $values = array_values( $values ); if ( count( $values ) <= $max ) { return $values; } $remaining = count( $values ) - $max; $values = array_slice( $values, 0, $max ); /* translators: %d: how many more available values exist beyond the ones shown. */ $values[] = sprintf( __( '+%d more', 'addsynapse-search-filter' ), $remaining ); return $values; } /** * Το αναγνώσιμο label ενός attribute name — χρησιμοποιεί wc_attribute_label() * (δουλεύει σωστά ΚΑΙ για ταξινομικά ΚΑΙ για custom attributes, βλ. * WooCommerce core), με fallback στο ίδιο το raw name. * * @param string $attr_name Raw attribute name/key (π.χ. "pa_color" ή custom name). * @param WC_Product $product Το γονικό προϊόν (context για wc_attribute_label()). * @return string */ private function attribute_label( $attr_name, WC_Product $product ) { $attr_name = (string) $attr_name; if ( '' === $attr_name ) { return ''; } if ( function_exists( 'wc_attribute_label' ) ) { $label = trim( (string) wc_attribute_label( $attr_name, $product ) ); if ( '' !== $label ) { return $label; } } return trim( $attr_name ); } /** * Μαζεύει τα ΟΝΟΜΑΤΑ (labels) των attributes του προϊόντος — π.χ. * "Χρώμα", "Νούμερο", "Μέγεθος" — ΟΧΙ τις τιμές τους (αυτές τις μαζεύει * η collect_attributes()). * * ΓΥΡΟΣ 6: καλύπτει ένα δεύτερο κενό πάνω στο ίδιο σενάριο παπουτσιών: * η τιμή "42" μόνη της βρίσκει «42» αλλά όχι «42 νούμερο» — αν ο * χρήστης γράψει και τη ΛΕΞΗ του attribute, το όνομα πρέπει να υπάρχει * κάπου στο έγγραφο. Παίρνει το label μόνο από τα attributes του * ΓΟΝΙΚΟΥ προϊόντος (WC_Product_Attribute) — οι variations χρησιμοποιούν * πάντα το ίδιο όνομα με τον γονιό, δεν χρειάζεται ξεχωριστό πέρασμα. * * @param WC_Product $product Το προϊόν. * @return string[] */ private function collect_attribute_names( WC_Product $product ) { $names = array(); foreach ( $product->get_attributes() as $attribute ) { if ( ! is_object( $attribute ) || ! method_exists( $attribute, 'get_name' ) ) { continue; } $raw_name = (string) $attribute->get_name(); $is_taxonomy = method_exists( $attribute, 'is_taxonomy' ) && $attribute->is_taxonomy(); // Τα ταξινομικά attributes αποθηκεύουν σαν "όνομα" το taxonomy // slug (π.χ. "pa_color") — το αναγνώσιμο label ("Χρώμα") το δίνει // το wc_attribute_label(). Τα custom attributes έχουν ήδη το // αναγνώσιμο όνομα ως get_name(). if ( $is_taxonomy && function_exists( 'wc_attribute_label' ) ) { $label = wc_attribute_label( $raw_name, $product ); } else { $label = $raw_name; } $label = trim( (string) $label ); if ( '' !== $label ) { $names[] = $label; } } return array_values( array_unique( array_filter( $names, 'strlen' ) ) ); } /** * Αν μια attribute ΤΙΜΗ είναι "λεκτική" (πρέπει να περάσει από * greeklish/stems) ή "κωδικός/αριθμός" (μένει ως έχει). * * Κανόνας (βλ. ATTRIBUTE_WORDLIKE_MIN_LENGTH): περιέχει έστω ένα γράμμα * (ελληνικό ή λατινικό — \p{L} καλύπτει και τα δύο) ΚΑΙ έχει μήκος * τουλάχιστον ATTRIBUTE_WORDLIKE_MIN_LENGTH χαρακτήρες. Καθαρά αριθμητικές * τιμές ("42") αποτυγχάνουν στο πρώτο κριτήριο, πολύ κοντά κωδικά με * γράμματα ("XL") στο δεύτερο. * * @param string $value Η τιμή attribute. * @return bool */ private function is_wordlike_attribute_value( $value ) { $value = (string) $value; if ( '' === $value ) { return false; } if ( ! preg_match( '/[\p{L}]/u', $value ) ) { return false; } $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value, 'UTF-8' ) : strlen( $value ); return $length >= self::ATTRIBUTE_WORDLIKE_MIN_LENGTH; } /** * Η μάρκα του προϊόντος, αν υπάρχει σχετική ταξινομία εγκατεστημένη. * * @param WC_Product $product Το προϊόν. * @return string */ private function collect_brand( WC_Product $product ) { $terms = $this->brand_terms( $product ); return empty( $terms ) ? '' : $terms[0]->name; } /** * Οι όροι μάρκας του προϊόντος — από την ΠΡΩΤΗ γνωστή ταξινομία που * έχει όρους (ίδια πηγή για `brand` και `brand_ids`). * * @param WC_Product $product Το προϊόν. * @return WP_Term[] */ private function brand_terms( WC_Product $product ) { foreach ( self::BRAND_TAXONOMIES as $taxonomy ) { if ( ! taxonomy_exists( $taxonomy ) ) { continue; } $terms = get_the_terms( $product->get_id(), $taxonomy ); if ( is_array( $terms ) && ! empty( $terms ) && ! is_wp_error( $terms ) ) { return array_values( $terms ); } } return array(); } /** * Τα ids των ΑΜΕΣΩΝ όρων μάρκας (audit F06). * * @param WC_Product $product Το προϊόν. * @return int[] */ private function collect_brand_ids( WC_Product $product ) { return self::unique_ids( wp_list_pluck( $this->brand_terms( $product ), 'term_id' ) ); } /** * Term ids κατηγοριών (audit F06): `direct` = οι όροι του προϊόντος, * `path` = direct + ΟΛΟΙ οι πρόγονοί τους (χωρίς διπλότυπα). * * @param WC_Product $product Το προϊόν. * @return array{direct:int[],path:int[]} */ private function collect_category_ids( WC_Product $product ) { $terms = get_the_terms( $product->get_id(), 'product_cat' ); if ( ! is_array( $terms ) || is_wp_error( $terms ) ) { return array( 'direct' => array(), 'path' => array(), ); } $direct = self::unique_ids( wp_list_pluck( $terms, 'term_id' ) ); $path = $direct; foreach ( $direct as $term_id ) { foreach ( (array) get_ancestors( $term_id, 'product_cat', 'taxonomy' ) as $ancestor ) { $path[] = (int) $ancestor; } } return array( 'direct' => $direct, 'path' => self::unique_ids( $path ), ); } /** * @param array $ids Ids. * @return int[] Θετικοί ακέραιοι, χωρίς διπλότυπα, ως λίστα. */ private static function unique_ids( array $ids ) { $out = array(); foreach ( $ids as $id ) { $id = (int) $id; if ( $id > 0 ) { $out[ $id ] = $id; } } return array_values( $out ); } /** * Οι κατηγορίες του προϊόντος. * * @param WC_Product $product Το προϊόν. * @return array */ private function collect_categories( WC_Product $product ) { $terms = get_the_terms( $product->get_id(), 'product_cat' ); if ( ! is_array( $terms ) || is_wp_error( $terms ) ) { return array(); } return array_values( wp_list_pluck( $terms, 'name' ) ); } /** * Η κύρια εικόνα του προϊόντος. * * @param WC_Product $product Το προϊόν. * @return string */ private function get_image_url( WC_Product $product ) { $image_id = $product->get_image_id(); if ( $image_id ) { $url = wp_get_attachment_image_url( $image_id, 'medium' ); if ( $url ) { return $url; } } return function_exists( 'wc_placeholder_img_src' ) ? wc_placeholder_img_src( 'medium' ) : ''; } /* ----------------------------------------------------------------- * Πλήρης επαναδεικτοδότηση — σελιδοποιημένη, ΠΟΤΕ όλα μαζί στη μνήμη. * --------------------------------------------------------------- */ /** * Ξεκινά πλήρη επαναδεικτοδότηση από την πρώτη σελίδα (καλείται από admin κουμπί). * * @return void */ public static function start_reindex_all() { self::instance()->schedule_reindex_page( 1, self::BATCH_SIZE ); // Κάθε σύνδεση/ενεργοποίηση περνά από εδώ — το νυχτερινό ξεκινά ΜΟΝΟ τότε. self::schedule_cron(); } /** * Αν ο διαχειριστής έχει συνδέσει το κατάστημα (υπάρχει κλειδί άδειας). * * 🔴 Guideline 7 (review 0.8.1): πριν από αυτό το plugin δεν προγραμματίζει * ΤΙΠΟΤΑ (νυχτερινό, sync, διαγραφές) — ούτε καν ενέργειες που θα * σταματούσαν αργότερα στο license_ready(). Τοπικός, φθηνός έλεγχος * (μόνο το option· ΚΑΜΙΑ κλήση δικτύου, άρα ασφαλής μέσα σε WC hooks). * * @return bool */ public static function is_configured() { if ( ! class_exists( 'Addsynapse_Search_Settings' ) ) { return false; } $key = Addsynapse_Search_Settings::get( 'license_key', '' ); return is_string( $key ) && '' !== trim( $key ); } /** * Ο κόμβος απάντησε `needs_reindex: true` (ο δείκτης σβήστηκε λόγω αδράνειας και ξαναφτιάχτηκε άδειος). * Προγραμματίζει ΜΙΑ ενέργεια ανάκτησης στο Action Scheduler — καμία κλήση δικτύου εδώ (μπορεί να * τρέχουμε μέσα σε αίτημα σελίδας). Dedupe: όχι αν εκκρεμεί ήδη, ούτε ξανά μέσα στο NEEDS_REINDEX_GUARD. * * @return bool true αν προγραμματίστηκε τώρα. */ public static function note_needs_reindex() { $flag = get_option( self::OPTION_NEEDS_REINDEX, false ); if ( is_array( $flag ) && ! empty( $flag['since'] ) && ( time() - (int) $flag['since'] ) < self::NEEDS_REINDEX_GUARD ) { return false; } if ( ! function_exists( 'as_enqueue_async_action' ) || ! function_exists( 'as_has_scheduled_action' ) ) { return false; } update_option( self::OPTION_NEEDS_REINDEX, array( 'since' => time(), 'state' => 'scheduled', ), false ); if ( ! as_has_scheduled_action( self::HOOK_RECOVER_INDEX, null, self::GROUP ) ) { as_enqueue_async_action( self::HOOK_RECOVER_INDEX, array(), self::GROUP ); } return true; } /** * Τέλος ανάκτησης: η πρώτη επιτυχής αποστολή εγγράφων. * * @return void */ public static function clear_needs_reindex() { if ( false !== get_option( self::OPTION_NEEDS_REINDEX, false ) ) { delete_option( self::OPTION_NEEDS_REINDEX ); } } /** * Η ανάκτηση εκκρεμεί/τρέχει (για το notice της καρτέλας «Κατάσταση»). * * @return bool */ public static function is_recovering_index() { return is_array( get_option( self::OPTION_NEEDS_REINDEX, false ) ); } /** * Action Scheduler: ξαναστέλνει τις ρυθμίσεις του δείκτη (συνώνυμα κ.λπ. χάθηκαν μαζί του) και * ξεκινά πλήρη επαναδεικτοδότηση — εκτός αν τρέχει ήδη μία. * * @return void */ public static function process_recover_index() { if ( ! class_exists( 'Addsynapse_Search_Api_Client' ) ) { return; } $flag = get_option( self::OPTION_NEEDS_REINDEX, array() ); $flag = is_array( $flag ) ? $flag : array( 'since' => time() ); $flag['state'] = 'running'; update_option( self::OPTION_NEEDS_REINDEX, $flag, false ); Addsynapse_Search_Api_Client::settings( array( 'synonyms' => (string) Addsynapse_Search_Settings::get( 'synonyms', '' ), 'send_search_stats' => (bool) Addsynapse_Search_Settings::get( 'send_search_stats', false ), 'semantic_search' => (bool) Addsynapse_Search_Settings::get( 'semantic_search', false ), ) ); if ( function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( self::HOOK_REINDEX_ALL, null, self::GROUP ) ) { return; } self::start_reindex_all(); } /** * Τα ids της επόμενης σελίδας δημοσιευμένων προϊόντων. * * 🔴 KEYSET, όχι offset (audit extra #1): με `paged` ένα προϊόν που * σβήνεται/δημοσιεύεται ΕΝΩ τρέχει το πέρασμα μετακινεί όλα τα επόμενα * κατά μία θέση → ένα προϊόν παραλείπεται (και η σκούπα του τέλους θα το * έσβηνε ως «ορφανό»). Με «ID > τελευταίο» κάθε προϊόν εμφανίζεται μία * φορά ανεξάρτητα από αλλαγές πριν από τον δρομέα. * * @param int $after_id Τελευταίο id της προηγούμενης σελίδας (0 = αρχή). * @param int $per_page Μέγεθος σελίδας. * @param int|null $offset ΜΟΝΟ για ενέργειες/AJAX παλιότερων εκδόσεων χωρίς δρομέα. * @return int[] */ private function query_page_ids( $after_id, $per_page, $offset = null ) { $args = array( 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => (int) $per_page, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'suppress_filters' => false, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, 'addsynapse_search_after_id' => max( 0, (int) $after_id ), ); if ( null !== $offset ) { $args['offset'] = max( 0, (int) $offset ); } add_filter( 'posts_where', array( __CLASS__, 'keyset_where' ), 10, 2 ); $query = new WP_Query( $args ); remove_filter( 'posts_where', array( __CLASS__, 'keyset_where' ), 10 ); return array_map( 'intval', (array) $query->posts ); } /** * `posts_where` ΜΟΝΟ για το query του query_page_ids(). * * @param string $where WHERE. * @param WP_Query $query Το query. * @return string */ public static function keyset_where( $where, $query ) { $after = $query instanceof WP_Query ? (int) $query->get( 'addsynapse_search_after_id' ) : 0; if ( $after > 0 ) { global $wpdb; $where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", $after ); } return $where; } /** * Πλήθος δημοσιευμένων προϊόντων (μόνο για την μπάρα προόδου). * * @return int */ private static function count_published_products() { $counts = wp_count_posts( 'product' ); return isset( $counts->publish ) ? (int) $counts->publish : 0; } /** * Processor του `addsynapse_search_reindex_all`: επεξεργάζεται ΜΙΑ σελίδα και προγραμματίζει την επόμενη. * * @param int $page Αριθμός σελίδας (ξεκινά από 1) — μόνο για log/συμβατότητα. * @param int $per_page Μέγεθος σελίδας (έως 100). * @param string $generation Η γενιά του περάσματος (κενό στη σελίδα 1 = νέο πέρασμα). * @param int|null $after_id Δρομέας keyset (null = ενέργεια παλιάς έκδοσης, με offset). * @return void */ public function process_reindex_page( $page = 1, $per_page = self::BATCH_SIZE, $generation = '', $after_id = null ) { $page = max( 1, (int) $page ); $per_page = max( 1, min( self::BATCH_SIZE, (int) $per_page ) ); if ( ! $this->license_ready( 'reindex σελίδα ' . $page ) ) { return; } // Σελίδα 1 = νέο πέρασμα, νέα γενιά. Επόμενες σελίδες: αν στο μεταξύ // ξεκίνησε ΑΛΛΟ πέρασμα (νυχτερινό + χειροκίνητο), αυτή η αλυσίδα // σταματά — το νεότερο καλύπτει ούτως ή άλλως όλο τον κατάλογο, και // δύο αλυσίδες που σκουπίζουν με διαφορετική γενιά θα έσβηναν // προσωρινά έγκυρα έγγραφα η μία της άλλης. if ( 1 === $page ) { $generation = $this->begin_full_run(); $after_id = 0; } elseif ( '' !== (string) $generation && ! $this->is_current_run( $generation ) ) { $this->log( 'reindex σελίδα ' . $page . ': νεότερο πέρασμα σε εξέλιξη, η αλυσίδα σταματά' ); return; } // 🔴 ΣΗΜΕΙΟ ΕΠΕΚΤΑΣΗΣ (docs/DEVELOPERS.md): action `addsynapse_search_before_reindex`, // ΜΙΑ φορά ανά πλήρη επαναδεικτοδότηση -- η 1η σελίδα είναι το μόνο // αξιόπιστο σημείο "ξεκίνησε τώρα" (start_reindex_all() και το // νυχτερινό cron καταλήγουν κι τα δύο εδώ, βλ. process_nightly_sync()). if ( 1 === $page ) { do_action( 'addsynapse_search_before_reindex' ); } // 0.8.0: πρώτα το πρελούδιο προτεραιότητας (αν υπάρχει) — ο δρομέας keyset ΔΕΝ προχωρά. $prelude = null === $after_id ? array() : $this->take_prelude_chunk( $generation, $per_page ); if ( ! empty( $prelude ) ) { $this->send_prelude( $prelude ); $this->schedule_reindex_page( $page + 1, $per_page, $generation, (int) $after_id ); return; } $ids = null === $after_id ? $this->query_page_ids( 0, $per_page, ( $page - 1 ) * $per_page ) : $this->query_page_ids( (int) $after_id, $per_page ); if ( empty( $ids ) ) { $this->log( 'reindex ολοκληρώθηκε στη σελίδα ' . $page . ' (καμία εγγραφή)' ); $this->complete_full_run( $generation, true ); return; } $since = self::pdel_stamp(); $docs = array(); $excluded = array(); $scanned = 0; foreach ( $ids as $product_id ) { $product = wc_get_product( $product_id ); if ( ! ( $product instanceof WC_Product ) ) { continue; } ++$scanned; if ( ! $this->product_should_be_indexed( $product ) ) { $excluded[] = $product; continue; } // build_documents(): ΕΝΑ έγγραφο (απλό προϊόν/flag off) ή ΕΝΑ ΑΝΑ // VARIATION (flag on + variable προϊόν) -- βλ. εκεί. foreach ( $this->build_documents( $product ) as $doc ) { $docs[] = $doc; } } // 🔴 Μετράμε τα ΔΕΚΤΑ (σφραγισμένα) έγγραφα, όχι τα απεσταλμένα — βλ. // accepted_count(). Ένα 402 του νέου server (μόνο νέα προϊόντα // απορρίφθηκαν) ΔΕΝ μπλοκάρει τη σκούπα: ό,τι ήταν ήδη στον δείκτη // έγινε δεκτό και σφραγίστηκε· τα απορριφθέντα δεν υπάρχουν εκεί. $sent = $this->send_batch( $docs, $since ); $this->note_run_progress( $generation, $sent['accepted'], $sent['failed'], $sent['limited'], $scanned, count( $docs ) ); $this->delete_excluded( $excluded ); if ( count( $ids ) >= $per_page ) { $this->schedule_reindex_page( $page + 1, $per_page, $generation, (int) end( $ids ) ); } else { $this->log( 'reindex ολοκληρώθηκε, τελευταία σελίδα ' . $page ); $this->complete_full_run( $generation, true ); } } /** * Τέλος πλήρους περάσματος (AS ή AJAX): σκούπα (αν ασφαλής), άδειασμα * εκκρεμών διαγραφών, και `addsynapse_search_after_reindex` με ΡΗΤΗ ένδειξη * επιτυχίας — «τελείωσε η επανάληψη» ≠ «συγχρονίστηκαν όλα» (audit extra #1). * * @param string $generation Γενιά. * @param bool $retry_in_queue Βλ. finish_full_run(). * @return bool Αν το πέρασμα ολοκληρώθηκε ΧΩΡΙΣ σφάλμα αποστολής. */ private function complete_full_run( $generation, $retry_in_queue ) { $run = get_option( self::OPTION_SYNC_RUN, array() ); $success = is_array( $run ) && isset( $run['generation'] ) && (string) $generation === (string) $run['generation'] && empty( $run['failed'] ); // Το μήνυμα ορίου σβήνει ΜΟΝΟ εδώ: ολόκληρος ο κατάλογος πέρασε και // ο server δεν απέρριψε τίποτα (π.χ. μετά από αναβάθμιση πακέτου). if ( $success && empty( $run['limited'] ) ) { $this->clear_plan_limit_flag(); } $this->finish_full_run( $generation, $retry_in_queue ); if ( $retry_in_queue ) { $this->schedule_flush_deletes( 0 ); } /** * Μετά από κάθε πλήρες πέρασμα επαναδεικτοδότησης. * * @param bool $success true αν ΟΛΑ τα έγγραφα στάλθηκαν χωρίς σφάλμα * (από 0.3.4· παλιότεροι listeners το αγνοούν). */ do_action( 'addsynapse_search_after_reindex', $success ); return $success; } /** * Processor ΣΥΓΧΡΟΝΟΥ "Run now" (καλείται από Addsynapse_Search_Admin::ajax_run_batch()) — * επεξεργάζεται ΜΙΑ παρτίδα ΤΩΡΑ, μέσα στο ίδιο αίτημα AJAX, ΧΩΡΙΣ να * προγραμματίσει τίποτα στο Action Scheduler. * * 🔴 ΓΙΑΤΙ υπάρχει ξεχωριστά από process_reindex_page(): το Action Scheduler * στηρίζεται στο WP-Cron, που είναι απενεργοποιημένο σε αρκετά hosts ή * τρέχει μόνο σε επίσκεψη σελίδας -- ένα νέο κατάστημα χωρίς κίνηση μπορεί * να μην δει ΠΟΤΕ την ουρά να προχωράει. Το JS (admin.js) καλεί αυτή τη * μέθοδο (μέσω AJAX) επαναλαμβανόμενα, μία σελίδα τη φορά, δίνοντας * πραγματική πρόοδο ανεξάρτητη από cron. * * Η κλήση είναι ιδιοδύναμη (idempotent) — ξαναστέλνει τα ίδια έγγραφα αν * ξανακληθεί με τον ίδιο δρομέα, όπως και το process_reindex_page(). * * @param int $page Αριθμός σελίδας (ξεκινά από 1). * @param int $per_page Μέγεθος σελίδας (έως AJAX_BATCH_SIZE). * @param string $generation Η γενιά που επέστρεψε η σελίδα 1 (το JS τη στέλνει πίσω). * @param int|null $after_id Δρομέας keyset που επέστρεψε η προηγούμενη σελίδα (null = παλιό JS, offset). * @return array{processed:int,total:int,done:bool,next_page:int|null,error:WP_Error|null,generation:string,cursor:int,warning:string} */ public function run_batch_now( $page, $per_page = self::AJAX_BATCH_SIZE, $generation = '', $after_id = null ) { $page = max( 1, (int) $page ); $per_page = max( 1, min( self::AJAX_BATCH_SIZE, (int) $per_page ) ); if ( ! $this->license_ready( 'run now σελίδα ' . $page ) ) { return array( 'processed' => 0, 'total' => 0, 'done' => true, 'next_page' => null, 'error' => new WP_Error( 'addsynapse_search_license_inactive', __( 'There is no active license.', 'addsynapse-search-filter' ) ), 'generation' => '', 'cursor' => 0, 'warning' => '', ); } // Ίδιο σκεπτικό με process_reindex_page(): η 1η σελίδα είναι το μόνο // αξιόπιστο σημείο "ξεκίνησε τώρα" για το χειροκίνητο "Run now". if ( 1 === $page ) { $generation = $this->begin_full_run(); $after_id = 0; do_action( 'addsynapse_search_before_reindex' ); } $generation = (string) $generation; $total = self::count_published_products(); // 0.8.0: πρελούδιο προτεραιότητας πρώτα (βλ. process_reindex_page()). $prelude = null === $after_id ? array() : $this->take_prelude_chunk( $generation, $per_page ); if ( ! empty( $prelude ) ) { $this->send_prelude( $prelude ); return array( 'processed' => 0, 'total' => $total, 'done' => false, 'next_page' => $page + 1, 'error' => null, 'generation' => $generation, 'cursor' => (int) $after_id, 'warning' => '', ); } $ids = null === $after_id ? $this->query_page_ids( 0, $per_page, ( $page - 1 ) * $per_page ) : $this->query_page_ids( (int) $after_id, $per_page ); if ( empty( $ids ) ) { return $this->finish_batch_run( $generation, $total, 0, null, '', (int) $after_id ); } $since = self::pdel_stamp(); $docs = array(); $products_handled = 0; $excluded = array(); $scanned = 0; foreach ( $ids as $product_id ) { $product = wc_get_product( $product_id ); if ( ! ( $product instanceof WC_Product ) ) { continue; } ++$scanned; if ( ! $this->product_should_be_indexed( $product ) ) { $excluded[] = $product; continue; } // build_documents(): ΕΝΑ έγγραφο ή ΕΝΑ ΑΝΑ VARIATION -- βλ. εκεί. // 'processed' παρακάτω μετράει ΠΡΟΪΟΝΤΑ (ίδια μονάδα με 'total'), // ΟΧΙ έγγραφα -- αλλιώς η μπάρα προόδου θα ξεπερνούσε το 100% // για καταστήματα με πολλές παραλλαγές. foreach ( $this->build_documents( $product ) as $doc ) { $docs[] = $doc; } ++$products_handled; } $accepted = 0; $limited = false; $error = $this->send_batch_collect_error( $docs, $accepted, $limited, $since ); // Εδώ ΚΑΘΕ σφάλμα (και 402) σταματά το πέρασμα → οι επόμενες σελίδες // δεν σφραγίζονται → failed=true, καμία σκούπα. $this->note_run_progress( $generation, $accepted, is_wp_error( $error ), $limited, $scanned, count( $docs ) ); $warning = ''; $delete_error = $this->delete_excluded( $excluded ); if ( is_wp_error( $delete_error ) ) { $warning = self::delete_warning_text( $delete_error ); } $cursor = (int) end( $ids ); if ( is_wp_error( $error ) || count( $ids ) < $per_page ) { // Με σφάλμα, finish_full_run() ΔΕΝ σκουπίζει (failed=true). return $this->finish_batch_run( $generation, $total, $products_handled, $error, $warning, $cursor ); } return array( 'processed' => $products_handled, 'total' => $total, 'done' => false, 'next_page' => $page + 1, 'error' => $error, 'generation' => $generation, 'cursor' => $cursor, 'warning' => $warning, ); } /** * Τέλος του χειροκίνητου "Run now": σκούπα + ΣΥΓΧΡΟΝΟ άδειασμα εκκρεμών * διαγραφών, ώστε ο διαχειριστής να ΔΕΙ αν κάποια διαγραφή απέτυχε * (audit F03: τα σφάλματα διαγραφής φτάνουν στο αποτέλεσμα του rebuild). * * @param string $generation Γενιά. * @param int $total Σύνολο προϊόντων. * @param int $processed Προϊόντα αυτής της σελίδας. * @param WP_Error|null $error Σφάλμα ingest. * @param string $warning Προειδοποίηση διαγραφής αυτής της σελίδας. * @param int $cursor Δρομέας. * @return array */ private function finish_batch_run( $generation, $total, $processed, $error, $warning, $cursor ) { $this->complete_full_run( $generation, false ); $flush = $this->flush_pending_deletes(); if ( $flush['remaining'] > 0 ) { $this->schedule_flush_deletes( self::flush_backoff( max( 1, self::load_pending_meta()['attempts'] ) ) ); if ( '' === $warning ) { $warning = $flush['error'] instanceof WP_Error ? self::delete_warning_text( $flush['error'], $flush['remaining'] ) : ''; } } return array( 'processed' => $processed, 'total' => $total, 'done' => true, 'next_page' => null, 'error' => $error, 'generation' => $generation, 'cursor' => (int) $cursor, 'warning' => $warning, ); } /** * @param WP_Error $error Σφάλμα διαγραφής. * @param int $remaining Πόσα ids περιμένουν (0 = άγνωστο). * @return string */ private static function delete_warning_text( WP_Error $error, $remaining = 0 ) { $remaining = $remaining > 0 ? $remaining : self::pdel_count( 'p' ); return sprintf( /* translators: 1: number of products/records waiting to be removed, 2: error message */ _n( '%1$s hidden or deleted item could not be removed from search yet (%2$s). It will be retried automatically.', '%1$s hidden or deleted items could not be removed from search yet (%2$s). They will be retried automatically.', max( 1, $remaining ), 'addsynapse-search-filter' ), number_format_i18n( max( 1, $remaining ) ), $error->get_error_message() ); } /** * Σβήνει από τον δείκτη δημοσιευμένα προϊόντα που ΔΕΝ πρέπει να είναι εκεί * (κωδικός, κρυφή ορατότητα, filter `addsynapse_search_should_index_product`). * Χωρίς αυτό, ένα προϊόν που δεικτοδοτήθηκε πριν κρυφτεί θα έμενε ορατό * στην αναζήτηση για πάντα — η πλήρης επαναδεικτοδότηση απλώς το προσπερνούσε. * Διαγραφή ανύπαρκτου id είναι no-op στον server. Αποτυχία → λίστα * εκκρεμών διαγραφών (audit F03), ΚΑΙ επιστρέφεται για να φανεί. * * @param WC_Product[] $products Τα προϊόντα προς αφαίρεση. * @return WP_Error|null */ private function delete_excluded( array $products ) { if ( empty( $products ) ) { return null; } $ids = array(); foreach ( $products as $product ) { $product_id = (int) $product->get_id(); $ids = array_merge( $ids, self::doc_ids_for( $product_id, $product->is_type( 'variable' ) ? $product->get_children() : array() ) ); } return $this->delete_now_or_queue( $ids, 'αφαίρεση κρυφών προϊόντων' ); } /** * Σαν send_batch(), αλλά επιστρέφει το ΠΡΩΤΟ WP_Error που συνάντησε (αντί * για bool) — το χρειάζεται το run_batch_now() για να ξεχωρίσει 402 * `plan_limit` (καθαρό μήνυμα ορίου) από κάθε άλλο σφάλμα (δίκτυο/5xx), * ώστε το AJAX/JS να σταματήσει καθαρά αντί για σιωπηλή διακοπή. * * @param array $docs Λίστα εγγράφων. * @param int $accepted (out) Πόσα έγγραφα ΔΕΧΤΗΚΕ ο server (βλ. accepted_count()). * @param bool $limited (out) Αν κάποια παρτίδα χτύπησε όριο πακέτου (μερική ή 402). * @param string $since pdel_stamp() πριν χτιστούν τα έγγραφα (βλ. forget_pending_for_docs()). * @return WP_Error|null */ private function send_batch_collect_error( array $docs, &$accepted = 0, &$limited = false, $since = '' ) { $accepted = 0; $limited = false; foreach ( array_chunk( $docs, self::BATCH_SIZE ) as $chunk ) { $result = Addsynapse_Search_Api_Client::ingest( $chunk ); if ( is_wp_error( $result ) ) { $limited = self::is_new_only_limit( $result ) || $limited; $this->handle_ingest_result( $result, 'run now: ingest απέτυχε (' . count( $chunk ) . ' έγγραφα)' ); return $result; } $this->handle_ingest_result( $result, 'run now' ); $rejected = self::rejected_ids( $result ); $accepted += self::accepted_count( $result, $chunk ); $limited = self::is_partial_ingest( $result ) || $limited; self::forget_pending_for_docs( $chunk, $rejected, $since ); $this->fire_document_indexed_actions( self::accepted_docs( $chunk, $rejected ) ); } return null; } /** * Προγραμματίζει το επόμενο κομμάτι της επαναδεικτοδότησης. * * @param int $page Σελίδα. * @param int $per_page Μέγεθος σελίδας. * @param string $generation Γενιά περάσματος (κενό για τη σελίδα 1). * @param int $after_id Δρομέας keyset (τελευταίο id της προηγούμενης σελίδας). * @return void */ private function schedule_reindex_page( $page, $per_page = self::BATCH_SIZE, $generation = '', $after_id = 0 ) { if ( ! function_exists( 'as_enqueue_async_action' ) ) { return; } as_enqueue_async_action( self::HOOK_REINDEX_ALL, array( 'page' => (int) $page, 'per_page' => (int) $per_page, 'generation' => (string) $generation, 'after_id' => (int) $after_id, ), self::GROUP ); } /* ----------------------------------------------------------------- * Σκούπα ορφανών εγγράφων (0.3.4) — βλ. OPTION_SYNC_RUN. * --------------------------------------------------------------- */ /** * Η γενιά με την οποία σφραγίζεται ΚΑΘΕ έγγραφο που στέλνεται τώρα * (πλήρες πέρασμα Ή μεμονωμένο sync από hook). Ένα προϊόν που * αποθηκεύεται ΕΝΩ τρέχει το πέρασμα παίρνει την ίδια γενιά → δεν σβήνεται. * * @return string */ public static function current_generation() { $run = get_option( self::OPTION_SYNC_RUN, array() ); if ( is_array( $run ) && isset( $run['generation'] ) && self::valid_generation( $run['generation'] ) ) { return (string) $run['generation']; } // Πρώτη φορά: γενιά χωρίς πέρασμα. Καμία σκούπα δεν τρέχει με αυτή // (sent=0), απλώς δίνει σφραγίδα στα μεμονωμένα sync. $generation = self::new_generation(); update_option( self::OPTION_SYNC_RUN, array( 'generation' => $generation, 'sent' => 0, 'failed' => true, 'started' => 0, ), false ); return $generation; } /** * Μόνο αλφαριθμητικά: η γενιά μπαίνει σε φίλτρο Meilisearch στον server. * * @param mixed $generation Γενιά. * @return bool */ private static function valid_generation( $generation ) { return is_string( $generation ) && 1 === preg_match( '/^[A-Za-z0-9]{8,64}$/', $generation ); } /** @return string */ private static function new_generation() { return gmdate( 'ymdHis' ) . wp_generate_password( 10, false, false ); } /** * Ξεκινά νέο πλήρες πέρασμα: νέα γενιά, μετρητής μηδέν. * * @return string Η νέα γενιά. */ private function begin_full_run() { $generation = self::new_generation(); update_option( self::OPTION_SYNC_RUN, array( 'generation' => $generation, 'sent' => 0, 'failed' => false, 'started' => time(), // Για τη λίστα διαγραφών: μετά από επιτυχή σκούπα, ό,τι μπήκε // εκεί ΠΡΙΝ από αυτή τη στιγμή έχει καλυφθεί (βλ. run_sweep()). 'started_us' => self::pdel_stamp(), 'scanned' => 0, 'built' => 0, // 0.8.0 — πρελούδιο προτεραιότητας (βλ. take_prelude_chunk()). 'prelude' => self::prelude_ids(), 'prelude_pos' => 0, ), false ); return $generation; } /** * 0.8.0 — Τα «σημαντικότερα» προϊόντα για την αρχή ενός πλήρους περάσματος, ΜΟΝΟ όταν ο * κατάλογος ξεπερνά το γνωστό όριο του πακέτου (αλλιώς []). Στέλνονται πρώτα με * `priority`, ώστε ο κόμβος να κρατήσει αυτά (και όχι τα πρώτα κατά id) όταν κλαδεύει. * * @return int[] */ private static function prelude_ids() { if ( ! class_exists( 'Addsynapse_Search_Coverage' ) ) { return array(); } $limit = Addsynapse_Search_Coverage::priority_limit(); return $limit > 0 ? Addsynapse_Search_Coverage::priority_ids( $limit ) : array(); } /** * Επόμενο κομμάτι του πρελουδίου του τρέχοντος περάσματος ([] = τελείωσε/δεν υπάρχει). * * @param string $generation Γενιά. * @param int $size Μέγεθος κομματιού. * @return int[] */ private function take_prelude_chunk( $generation, $size ) { $run = get_option( self::OPTION_SYNC_RUN, array() ); if ( ! is_array( $run ) || ! isset( $run['generation'] ) || (string) $generation !== (string) $run['generation'] || empty( $run['prelude'] ) || ! is_array( $run['prelude'] ) ) { return array(); } $pos = isset( $run['prelude_pos'] ) ? max( 0, (int) $run['prelude_pos'] ) : 0; $chunk = array_slice( $run['prelude'], $pos, max( 1, (int) $size ) ); if ( empty( $chunk ) ) { return array(); } $run['prelude_pos'] = $pos + count( $chunk ); update_option( self::OPTION_SYNC_RUN, $run, false ); return array_map( 'intval', $chunk ); } /** * Στέλνει ένα κομμάτι του πρελουδίου με `priority`. ΔΕΝ μετρά στο `sent` του περάσματος * (τα ίδια προϊόντα ξαναστέλνονται στο κανονικό πέρασμα — αλλιώς η σκούπα θα * περίμενε διπλάσια έγγραφα και θα αρνιόταν). Σφάλμα = μόνο log: το κανονικό πέρασμα * τα καλύπτει ούτως ή άλλως. * * @param int[] $ids Ids προϊόντων. * @return int Έγγραφα που δέχτηκε ο κόμβος. */ private function send_prelude( array $ids ) { $docs = array(); foreach ( $ids as $product_id ) { $product = wc_get_product( $product_id ); if ( ! ( $product instanceof WC_Product ) || ! $this->product_should_be_indexed( $product ) ) { continue; } foreach ( $this->build_documents( $product ) as $doc ) { $docs[] = $doc; } } $accepted = 0; foreach ( array_chunk( $docs, self::BATCH_SIZE ) as $chunk ) { $result = Addsynapse_Search_Api_Client::ingest( $chunk, true ); if ( is_wp_error( $result ) ) { $this->log( 'πρελούδιο προτεραιότητας: ' . $result->get_error_message() ); continue; } $accepted += self::accepted_count( $result, $chunk ); $this->fire_document_indexed_actions( self::accepted_docs( $chunk, self::rejected_ids( $result ) ) ); } return $accepted; } /** * @param string $generation Γενιά. * @return bool Αν αυτό είναι ακόμα το τρέχον πέρασμα. */ private function is_current_run( $generation ) { $run = get_option( self::OPTION_SYNC_RUN, array() ); return is_array( $run ) && isset( $run['generation'] ) && (string) $generation === (string) $run['generation']; } /** * Καταγράφει πρόοδο: πόσα έγγραφα ΕΠΙΒΕΒΑΙΩΜΕΝΑ στάλθηκαν, και αν κάτι απέτυχε. * * @param string $generation Γενιά του περάσματος. * @param int $sent Έγγραφα που δέχτηκε ο server. * @param bool $failed Αν απέτυχε κάποια αποστολή. * @param bool $limited Αν ο server απέρριψε νέα έγγραφα λόγω ορίου πακέτου. * @param int $scanned Προϊόντα που ΔΙΑΒΑΣΤΗΚΑΝ σε αυτή τη σελίδα (κάθε ορατότητα). * @param int $built Έγγραφα που ΧΤΙΣΤΗΚΑΝ (πριν την αποστολή). * @return void */ private function note_run_progress( $generation, $sent, $failed, $limited = false, $scanned = 0, $built = 0 ) { $run = get_option( self::OPTION_SYNC_RUN, array() ); if ( ! is_array( $run ) || ! isset( $run['generation'] ) || (string) $generation !== (string) $run['generation'] ) { return; // Άγνωστο/παλιό πέρασμα — δεν αγγίζουμε τον μετρητή του τρέχοντος. } $run['sent'] = (int) $run['sent'] + (int) $sent; $run['scanned'] = ( isset( $run['scanned'] ) ? (int) $run['scanned'] : 0 ) + (int) $scanned; $run['built'] = ( isset( $run['built'] ) ? (int) $run['built'] : 0 ) + (int) $built; if ( $failed ) { $run['failed'] = true; } if ( $limited ) { $run['limited'] = true; } update_option( self::OPTION_SYNC_RUN, $run, false ); } /** * Τέλος πλήρους περάσματος: σκούπα ορφανών, ΜΟΝΟ αν είναι απολύτως ασφαλές. * * 🔴 ΠΟΤΕ σκούπα όταν: * - η γενιά δεν είναι του τρέχοντος περάσματος (άλλο ξεκίνησε στο μεταξύ, * ή το JS/παλιά ενέργεια AS δεν έστειλε γενιά), * - απέτυχε ΟΠΟΙΑΔΗΠΟΤΕ αποστολή (δίκτυο, 5xx, 402 ΠΑΛΙΟΥ server) — τα * έγγραφα που δεν ξαναστάλθηκαν έχουν ΠΑΛΙΑ σφραγίδα και θα σβήνονταν, * Μερική αποδοχή / 402 του νέου server ΔΕΝ μπλοκάρει: απορρίπτονται μόνο * ΝΕΑ έγγραφα (που δεν είναι στον δείκτη, άρα δεν σβήνονται), τα υπάρχοντα * γίνονται πάντα δεκτά και σφραγίζονται· το `sent` μετρά μόνο τα δεκτά. * - δεν στάλθηκε ΚΑΝΕΝΑ έγγραφο (απαρίθμηση που γύρισε 0 απροσδόκητα). * Ο server ελέγχει ξανά ανεξάρτητα (έγγραφα με αυτή τη γενιά >= expected). * * @param string $generation Γενιά του περάσματος. * @param bool $retry_in_queue Σε 409 «όχι ακόμα», ξαναδοκίμασε μέσω Action Scheduler. * @return void */ private function finish_full_run( $generation, $retry_in_queue ) { $run = get_option( self::OPTION_SYNC_RUN, array() ); if ( ! self::valid_generation( $generation ) || ! is_array( $run ) || ! isset( $run['generation'] ) || (string) $generation !== (string) $run['generation'] ) { $this->log( 'σκούπα παραλείπεται: άγνωστη ή παλιά γενιά περάσματος' ); return; } if ( ! empty( $run['failed'] ) ) { $this->log( 'σκούπα παραλείπεται: το πέρασμα είχε σφάλματα αποστολής' ); return; } $expected = isset( $run['sent'] ) ? (int) $run['sent'] : 0; if ( $expected < 1 ) { // 🔴 audit F02: ΑΔΕΙΟΣ ΚΑΤΑΛΟΓΟΣ. «Δεν στάλθηκε τίποτα» ΔΕΝ αρκεί // για άδειασμα (απαρίθμηση που γύρισε 0 από λάθος, ή όλα // απορρίφθηκαν από όριο πακέτου). Άδειασμα ΜΟΝΟ όταν ΑΠΟΔΕΔΕΙΓΜΕΝΑ: // - το πέρασμα δεν είχε κανένα σφάλμα (ελέγχθηκε πιο πάνω), // - δεν ΧΤΙΣΤΗΚΕ κανένα έγγραφο (όχι «απορρίφθηκαν»), // - διαβάστηκαν ΤΟΥΛΑΧΙΣΤΟΝ όσα δημοσιευμένα προϊόντα μετρά // τώρα ανεξάρτητα το WordPress (κρυφά/με κωδικό = διαβάστηκαν // και εξαιρέθηκαν, άρα ο δείκτης πρέπει να είναι άδειος), // και ο server το δέχεται ΜΟΝΟ με ρητό `confirm_empty` (βλ. run_sweep()). $scanned = isset( $run['scanned'] ) ? (int) $run['scanned'] : -1; $built = isset( $run['built'] ) ? (int) $run['built'] : -1; if ( 0 !== $built || $scanned < 0 || $scanned < self::count_published_products() ) { $this->log( 'σκούπα παραλείπεται: δεν στάλθηκε κανένα έγγραφο (χτίστηκαν ' . $built . ', διαβάστηκαν ' . $scanned . ')' ); return; } $run['purge_ok'] = true; update_option( self::OPTION_SYNC_RUN, $run, false ); $this->log( 'άδειος κατάλογος: ασφαλές άδειασμα δείκτη (διαβάστηκαν ' . $scanned . ' προϊόντα, 0 επιλέξιμα)' ); } $this->run_sweep( $generation, $expected, 1, $retry_in_queue || function_exists( 'as_schedule_single_action' ) ); } /** * Processor του HOOK_SWEEP (επανάληψη μετά από 409). * * @param string $generation Γενιά. * @param int $expected Πλήθος εγγράφων που στάλθηκαν. * @param int $attempt Αριθμός προσπάθειας. * @return void */ public function process_sweep_action( $generation, $expected, $attempt = 1 ) { if ( ! $this->license_ready( 'σκούπα ορφανών' ) ) { return; } if ( ! $this->is_current_run( $generation ) ) { return; // Νεότερο πέρασμα — θα σκουπίσει εκείνο. } if ( (int) $expected < 1 ) { $run = get_option( self::OPTION_SYNC_RUN, array() ); if ( ! is_array( $run ) || empty( $run['purge_ok'] ) ) { return; // Άδειασμα ΜΟΝΟ αν το αποφάσισε το finish_full_run() αυτού του περάσματος. } } $this->run_sweep( (string) $generation, (int) $expected, (int) $attempt, true ); } /** * @param string $generation Γενιά. * @param int $expected Πλήθος εγγράφων που στάλθηκαν σε αυτό το πέρασμα. * @param int $attempt Αριθμός προσπάθειας. * @param bool $can_retry Αν επιτρέπεται επανάληψη μέσω Action Scheduler. * @return void */ private function run_sweep( $generation, $expected, $attempt, $can_retry ) { if ( (int) $expected < 1 ) { // Άδειος κατάλογος (βλ. finish_full_run()): ίδιο endpoint, με ρητή // επιβεβαίωση — ο server αρνείται expected 0 χωρίς αυτήν. $result = Addsynapse_Search_Api_Client::request( 'POST', '/v1/documents/sweep', array( 'generation' => (string) $generation, 'expected' => 0, 'confirm_empty' => true, ) ); } else { $result = Addsynapse_Search_Api_Client::sweep( $generation, $expected ); } if ( ! is_wp_error( $result ) ) { $deleted = isset( $result['deleted'] ) ? (int) $result['deleted'] : 0; $this->log( 'σκούπα ορφανών: σβήστηκαν ' . $deleted . ' έγγραφα (γενιά ' . $generation . ')' ); // Ό,τι μπήκε στη λίστα διαγραφών ΠΡΙΝ ξεκινήσει αυτό το πέρασμα // έχει πλέον καλυφθεί: ή ξαναστάλθηκε ζωντανό (και ξεχάστηκε), ή // δεν έφερε τη γενιά και το έσβησε η σκούπα. Αυτός είναι ο ΜΟΝΟΣ // δρόμος που καθαρίζει και τα dead-letter (audit F02). $run = get_option( self::OPTION_SYNC_RUN, array() ); if ( is_array( $run ) && isset( $run['generation'], $run['started_us'] ) && (string) $generation === (string) $run['generation'] ) { self::pdel_forget_older( null, (string) $run['started_us'] ); } if ( $deleted > 0 && class_exists( 'Addsynapse_Search_Analytics' ) ) { Addsynapse_Search_Analytics::clear_cache(); } return; } $data = $result->get_error_data(); $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0; $this->log( 'σκούπα ορφανών απέτυχε (προσπάθεια ' . $attempt . '): ' . $result->get_error_message() ); // 409 = «ο δείκτης επεξεργάζεται ακόμα / ο έλεγχος ασφαλείας δεν // πέρασε ακόμα». Ξαναδοκιμάζουμε αργότερα· κάθε άλλο σφάλμα (404 // παλιός server, 4xx) → τίποτα, το επόμενο πέρασμα θα ξαναδοκιμάσει. if ( 409 === $status && $can_retry && $attempt < self::SWEEP_MAX_ATTEMPTS && function_exists( 'as_schedule_single_action' ) ) { as_schedule_single_action( time() + 2 * MINUTE_IN_SECONDS, self::HOOK_SWEEP, array( 'generation' => (string) $generation, 'expected' => (int) $expected, 'attempt' => (int) $attempt + 1, ), self::GROUP ); } } /** * Στέλνει ένα σύνολο εγγράφων στο API, σε παρτίδες έως BATCH_SIZE. * * @param array $docs Λίστα εγγράφων. * @param string $since pdel_stamp() πριν χτιστούν τα έγγραφα (βλ. forget_pending_for_docs()). * @return array{accepted:int,failed:bool,limited:bool} `failed` = σφάλμα που * ΑΦΗΝΕΙ υπάρχοντα έγγραφα ασφράγιστα (δίκτυο/5xx/παλιός 402) → όχι σκούπα. */ private function send_batch( array $docs, $since = '' ) { $out = array( 'accepted' => 0, 'failed' => false, 'limited' => false, ); foreach ( array_chunk( $docs, self::BATCH_SIZE ) as $chunk ) { $result = Addsynapse_Search_Api_Client::ingest( $chunk ); if ( $this->handle_ingest_result( $result, 'batch ingest απέτυχε (' . count( $chunk ) . ' έγγραφα)' ) ) { $rejected = self::rejected_ids( $result ); $out['accepted'] += self::accepted_count( $result, $chunk ); $out['limited'] = self::is_partial_ingest( $result ) || $out['limited']; self::forget_pending_for_docs( $chunk, $rejected, $since ); $this->fire_document_indexed_actions( self::accepted_docs( $chunk, $rejected ) ); } elseif ( self::is_new_only_limit( $result ) ) { $out['limited'] = true; } else { $out['failed'] = true; } } return $out; } /** * 🔴 ΣΗΜΕΙΟ ΕΠΕΚΤΑΣΗΣ (docs/DEVELOPERS.md): action `addsynapse_search_document_indexed`, * ΜΙΑ φορά ανά έγγραφο (όχι ανά batch) -- ΜΕΤΑ από επιβεβαιωμένα επιτυχή * αποστολή στο API (ΠΟΤΕ αισιόδοξα πριν το αποτέλεσμα). Χρήσιμο π.χ. για * δικό σου logging/webhook ανά προϊόν. * * @param array[] $docs Λίστα εγγράφων που μόλις στάλθηκαν με επιτυχία. * @return void */ private function fire_document_indexed_actions( array $docs ) { foreach ( $docs as $doc ) { /** * Ενεργοποιείται μετά την επιτυχή δεικτοδότηση ενός εγγράφου. * * @param array $doc Το έγγραφο που μόλις δεικτοδοτήθηκε (id/title/.../custom/...). */ do_action( 'addsynapse_search_document_indexed', $doc ); } } /** * Κοινός χειρισμός αποτελέσματος ingest() (μονού προϊόντος ή batch): * καταγράφει το σφάλμα (WP_DEBUG only, βλ. log()) ΚΑΙ, ειδικά για 402 * `plan_limit`, σημαδεύει το transient που διαβάζει η καρτέλα «Κατάσταση» * -- έτσι ο πελάτης βλέπει καθαρό μήνυμα αντί να απορεί γιατί κάποια * προϊόντα λείπουν από την αναζήτηση. Καθαρίζει το σημάδι σε κάθε * επιτυχία (μπορεί να μην ισχύει πια, π.χ. μετά από αναβάθμιση πακέτου). * * @param array|WP_Error $result Η απάντηση του Addsynapse_Search_Api_Client::ingest(). * @param string $context Σύντομη περιγραφή για το log. * @return bool true αν πέτυχε. */ private function handle_ingest_result( $result, $context ) { if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); if ( is_array( $data ) && isset( $data['status'] ) && 402 === (int) $data['status'] ) { $this->flag_plan_limit( $data ); } $this->log( $context . ': ' . $result->get_error_message() ); return false; } // 2xx `*_partial`: ο server δέχτηκε τα υπάρχοντα/όσα χωρούσαν και // απέρριψε τα υπόλοιπα ΝΕΑ — ο πελάτης ΠΡΕΠΕΙ να δει το μήνυμα ορίου. // // 🔴 Μια πλήρης αποδοχή ΔΕΝ καθαρίζει πια το σημάδι: ο νέος server // δέχεται ΠΑΝΤΑ τις ενημερώσεις υπαρχόντων, άρα ένα 200 (π.χ. αλλαγή // αποθέματος) δεν λέει τίποτα για το αν υπάρχει χώρος. Καθαρίζει μόνο // ένα πλήρες πέρασμα χωρίς καμία απόρριψη (complete_full_run()). if ( self::is_partial_ingest( $result ) ) { $this->flag_plan_limit( $result ); $this->log( 'ingest: μερική αποδοχή (' . $result['code'] . '), απορρίφθηκαν ' . count( self::rejected_ids( $result ) ) . ' νέα έγγραφα' ); } return true; } /** * Σημαδεύει ότι χτυπήσαμε το όριο πακέτου, με ό,τι limit/current έστειλε * ο server (backend DocumentsController), για να τα δείξει η καρτέλα * «Κατάσταση» χωρίς δεύτερο round-trip. * * @param array $data Error data από WP_Error::get_error_data() (status/code/limit/current). * @return void */ private function flag_plan_limit( array $data ) { set_transient( self::TRANSIENT_PLAN_LIMIT, array( // Μόνο δομημένα πεδία — το `message` του server (γλώσσα site) ΔΕΝ // αποθηκεύεται· το κείμενο χτίζεται στο admin (Addsynapse_Search_Admin::limit_message()). 'code' => ( isset( $data['code'] ) && 0 === strpos( (string) $data['code'], 'record_limit' ) ) ? 'record_limit' : 'plan_limit', 'limit' => isset( $data['limit'] ) && is_numeric( $data['limit'] ) ? (int) $data['limit'] : null, 'current' => isset( $data['current'] ) && is_numeric( $data['current'] ) ? (int) $data['current'] : null, ), DAY_IN_SECONDS ); } /** * Καθαρίζει το σημάδι «χτυπήσαμε το όριο» (μετά από επιτυχές ingest). * * @return void */ private function clear_plan_limit_flag() { delete_transient( self::TRANSIENT_PLAN_LIMIT ); } /** * Αν έχουμε πρόσφατα χτυπήσει το όριο πακέτου κατά τη δεικτοδότηση — για * την καρτέλα «Κατάσταση» (views/status.php). * * @return array{code:string,limit:int|null,current:int|null}|null */ public static function get_plan_limit_notice() { $data = get_transient( self::TRANSIENT_PLAN_LIMIT ); return is_array( $data ) ? $data : null; } /* ----------------------------------------------------------------- * Νυχτερινό δίχτυ ασφαλείας. * --------------------------------------------------------------- */ /** * Προγραμματίζει το νυχτερινό cron (κλήση από activation). * * @return void */ public static function schedule_cron() { // Guideline 7: τίποτα αυτόματο πριν ο διαχειριστής συνδέσει το κατάστημα. if ( ! self::is_configured() ) { return; } if ( ! function_exists( 'as_schedule_recurring_action' ) || ! function_exists( 'as_next_scheduled_action' ) ) { return; } if ( ! as_next_scheduled_action( self::HOOK_NIGHTLY, array(), self::GROUP ) ) { $first_run = strtotime( 'tomorrow 03:00:00' ); as_schedule_recurring_action( $first_run, DAY_IN_SECONDS, self::HOOK_NIGHTLY, array(), self::GROUP ); } } /** * Ακυρώνει το νυχτερινό cron (κλήση από deactivation). * * @return void */ public static function unschedule_cron() { if ( ! function_exists( 'as_unschedule_all_actions' ) ) { return; } // 🔴 audit F15: ΟΛΕΣ οι εκκρεμείς ενέργειες της ομάδας μας (sync/ // delete/reindex/sweep/flush/nightly), όχι μόνο το νυχτερινό. Η // λίστα εκκρεμών διαγραφών ΜΕΝΕΙ στη βάση: στην επανενεργοποίηση // το ensure_cron()/νυχτερινό την ξαναπιάνει. as_unschedule_all_actions( '', array(), self::GROUP ); } /** * Αυτο-επιδιόρθωση (audit F15): το νυχτερινό προγραμματιζόταν ΜΟΝΟ στην * ενεργοποίηση — αν χανόταν (καθαρισμός πίνακα AS, migration, update * χωρίς activation hook) δεν ξαναπρογραμματιζόταν ποτέ. Φθηνός έλεγχος * το πολύ μία φορά ανά 12 ώρες (transient), μόνο σε admin. * * @return void */ public static function ensure_cron() { // Πριν τη σύνδεση: κανένας προγραμματισμός, ούτε transient — μόλις // συνδεθεί, το επόμενο admin_init προγραμματίζει αμέσως. if ( ! self::is_configured() ) { return; } if ( false !== get_transient( 'addsynapse_search_cron_checked' ) ) { return; } set_transient( 'addsynapse_search_cron_checked', 1, 12 * HOUR_IN_SECONDS ); self::schedule_cron(); self::load_pending_meta(); // Μετάπτωση παλιάς λίστας, αν υπάρχει. if ( self::pdel_count( 'p' ) > 0 ) { self::instance()->schedule_flush_deletes( 0 ); } } /** * Processor του νυχτερινού hook: ξεκινά πλήρη επαναδεικτοδότηση ως δίχτυ ασφαλείας. * * @return void */ public function process_nightly_sync() { $this->schedule_flush_deletes( 0 ); $this->schedule_reindex_page( 1, self::BATCH_SIZE ); } /* ----------------------------------------------------------------- * Βοηθητικά. * --------------------------------------------------------------- */ /** * Ελέγχει ότι η άδεια είναι ενεργή· αν όχι, καταγράφει και σταματά (χωρίς αποστολή). * * @param string $context Σύντομη περιγραφή για το log. * @return bool */ private function license_ready( $context ) { if ( ! class_exists( 'Addsynapse_Search_License' ) || ! Addsynapse_Search_License::is_active() ) { $this->log( 'η άδεια δεν είναι ενεργή, παραλείπεται: ' . $context ); return false; } if ( ! class_exists( 'Addsynapse_Search_Api_Client' ) ) { $this->log( 'λείπει το Addsynapse_Search_Api_Client, παραλείπεται: ' . $context ); return false; } return true; } /** * Καταγραφή μέσω addsynapse_search_log() (logger WooCommerce, όχι debug.log). * * @param string $message Το μήνυμα (ΠΟΤΕ ευαίσθητα δεδομένα). * @return void */ private function log( $message ) { addsynapse_search_log( 'Indexer', (string) $message ); } }