30])): * { * "range": {"from":"","to":"","days":30}, * "totals": {"searches":0,"unique_terms":0,"zero_results":0,"clicks":0,"ctr":0.0}, * "indexed_count": 0, * "top_terms": [{"term":"","searches":0,"results_avg":0,"clicks":0,"ctr":0.0}], * "zero_terms": [{"term":"","searches":0,"last_seen":""}], * "low_ctr_terms": [{"term":"","searches":0,"results_avg":0,"clicks":0,"ctr":0.0}], * "term_clicks": [{"term":"","product_id":0,"product_title":"","clicks":0}], * "timeseries": [{"day":"","searches":0,"zero_results":0,"clicks":0}] * } * * 🔴 Απόρρητο: το backend υπόσχεται μόνο ανώνυμα αθροίσματα ανά όρο. Αν ποτέ * εμφανιστεί εδώ πεδίο που θα μπορούσε να ταυτοποιήσει επισκέπτη (π.χ. IP, * session/visitor id, user agent), ΔΕΝ το περνάμε στο view — βλ. normalize(). * * @package Addsynapse_Search_Filter */ if ( ! defined( 'ABSPATH' ) ) { exit; } class Addsynapse_Search_Analytics { /** @var int Διάρκεια cache σε δευτερόλεπτα. */ const CACHE_TTL = 900; // 15 λεπτά. /** @var string Πρόθεμα για τα κλειδιά transient. */ const CACHE_PREFIX = 'addsynapse_search_stats_'; /** @var int[] Επιτρεπτά εύρη ημερών. */ const VALID_RANGES = array( 7, 30, 90 ); /** * Πόσο "παλιά" πρέπει να είναι η παλαιότερη pending ενέργεια της ουράς * (χωρίς καμία να τρέχει) για να τη θεωρήσουμε "κολλημένη" -- βλ. * get_queue_health(). 10 λεπτά, όπως ζητήθηκε. */ const QUEUE_STALLED_THRESHOLD = 600; /** * Φέρνει στατιστικά από το API για ένα εύρος ημερών, με cache και * κανονικοποίηση στο σχήμα του συμβολαίου. ΠΟΤΕ δεν πετάει exception προς * τα έξω και ΠΟΤΕ δεν επιστρέφει μερικό/ανακόλουθο array — είτε πλήρες * (με defaults) είτε WP_Error. * * @param int $range Ημέρες: 7, 30 ή 90 (ό,τι άλλο -> 30). * @param bool $force Αν true, αγνοεί την cache. * @return array|WP_Error */ public static function get_stats( $range = 30, $force = false ) { $range = in_array( (int) $range, self::VALID_RANGES, true ) ? (int) $range : 30; $cache_key = self::CACHE_PREFIX . $range; if ( ! $force ) { $cached = get_transient( $cache_key ); if ( false !== $cached ) { return $cached; } } if ( ! class_exists( 'Addsynapse_Search_Api_Client' ) || ! method_exists( 'Addsynapse_Search_Api_Client', 'stats' ) ) { return new WP_Error( 'addsynapse_search_analytics_unavailable', __( 'The stats service is not available right now.', 'addsynapse-search-filter' ) ); } try { $result = Addsynapse_Search_Api_Client::stats( array( 'range' => $range ) ); } catch ( \Throwable $e ) { return new WP_Error( 'addsynapse_search_analytics_exception', $e->getMessage() ); } if ( is_wp_error( $result ) ) { return $result; } if ( ! is_array( $result ) && ! is_object( $result ) ) { return new WP_Error( 'addsynapse_search_analytics_bad_response', __( 'Invalid response from the stats service.', 'addsynapse-search-filter' ) ); } $normalized = self::normalize( $result, $range ); self::sync_variation_indexing_flag( $normalized ); set_transient( $cache_key, $normalized, self::CACHE_TTL ); return $normalized; } /** * Συγχρονίζει το τοπικό flag `variation_indexing` με ό,τι μόλις * επέστρεψε ΦΡΕΣΚΟ (όχι από cache) το /v1/stats -- ΙΔΙΟ helper με το * Addsynapse_Search_License::activate(), βλ. εκεί για το γιατί μια αλλαγή τιμής * σημαίνει ΥΠΟΧΡΕΩΤΙΚΗ πλήρη επαναδεικτοδότηση (task-brief §4 🔴). * Καλείται ΜΟΝΟ όταν πήραμε πραγματικά νέα δεδομένα από το δίκτυο * (δηλ. εδώ, ΟΧΙ σε κάθε cache-hit του get_stats()) -- αρκεί, μια * αλλαγή server-side εντοπίζεται το αργότερο στην επόμενη λήξη cache * (15 λεπτά) ή στο επόμενο activate(). * * @param array $normalized Το κανονικοποιημένο /v1/stats (βλ. normalize()). * @return void */ private static function sync_variation_indexing_flag( array $normalized ) { if ( ! array_key_exists( 'variation_indexing', $normalized ) || null === $normalized['variation_indexing'] ) { return; } if ( ! class_exists( 'Addsynapse_Search_License' ) || ! method_exists( 'Addsynapse_Search_License', 'sync_variation_indexing_flag' ) ) { return; } Addsynapse_Search_License::sync_variation_indexing_flag( (bool) $normalized['variation_indexing'] ); } /** * Κανονικοποιεί την ακατέργαστη απάντηση του API στο ΑΚΡΙΒΕΣ σχήμα που * περιμένουν τα views — κάθε πεδίο μπορεί να λείπει ή να έχει λάθος τύπο, * γι' αυτό γεμίζουμε ασφαλή defaults παντού. * * @param array|object $data Η ακατέργαστη απάντηση. * @param int $range Το ζητηθέν εύρος ημερών (fallback για range.days). * @return array */ private static function normalize( $data, $range ) { if ( is_object( $data ) ) { $data = (array) $data; } if ( ! is_array( $data ) ) { $data = array(); } $range_raw = isset( $data['range'] ) ? (array) $data['range'] : array(); $range_out = array( 'from' => isset( $range_raw['from'] ) && is_scalar( $range_raw['from'] ) ? (string) $range_raw['from'] : '', 'to' => isset( $range_raw['to'] ) && is_scalar( $range_raw['to'] ) ? (string) $range_raw['to'] : '', 'days' => isset( $range_raw['days'] ) && is_numeric( $range_raw['days'] ) ? (int) $range_raw['days'] : (int) $range, ); $totals_raw = isset( $data['totals'] ) ? (array) $data['totals'] : array(); $totals_out = array( 'searches' => self::to_int( self::pluck( $totals_raw, array( 'searches' ), 0 ) ), 'unique_terms' => self::to_int( self::pluck( $totals_raw, array( 'unique_terms' ), 0 ) ), 'zero_results' => self::to_int( self::pluck( $totals_raw, array( 'zero_results' ), 0 ) ), 'clicks' => self::to_int( self::pluck( $totals_raw, array( 'clicks' ), 0 ) ), 'ctr' => self::to_float( self::pluck( $totals_raw, array( 'ctr' ), 0.0 ) ), ); $indexed_count = self::pluck( $data, array( 'indexed_count' ), null ); // 🔴 ΚΑΘΕ νέο πεδίο του /v1/stats πρέπει να δηλωθεί ΚΑΙ ΕΔΩ ΚΑΙ στο // return παρακάτω. Το normalize() είναι whitelist: ό,τι δεν αναφέρεται // ρητά ΠΕΤΙΕΤΑΙ ΣΙΩΠΗΛΑ. Το «Last sync» ήταν κενό για κάθε πελάτη // ακριβώς επειδή ξεχάστηκε αυτό το βήμα. $last_sync_at = self::pluck( $data, array( 'last_sync_at' ), null ); $plan_renews_at = self::pluck( $data, array( 'plan_renews_at' ), null ); $plan = self::pluck( $data, array( 'plan' ), '' ); $limit = self::pluck( $data, array( 'limit' ), null ); $current = self::pluck( $data, array( 'current' ), null ); // 🔴 «Ακριβής αναζήτηση παραλλαγών» (task-brief §1/§5): products_count // = ό,τι είναι ήδη το indexed_count/current παραπάνω (προϊόντα, // ΠΑΝΤΑ), documents_count = ΩΜΟ πλήθος εγγράφων Meilisearch -- τα δύο // διαφέρουν ΜΟΝΟ όταν variation_indexing=true (1 προϊόν = Ν έγγραφα). // Χρησιμοποιεί array_key_exists (ΟΧΙ isset) γιατί `null` είναι έγκυρη // τιμή "άγνωστο" (π.χ. Meilisearch εκτός), ξεχωριστή από "backend // παλιό, δεν το στέλνει ακόμα" -- και τα δύο πρέπει να μείνουν null, // απλώς το δεύτερο ΔΕΝ πρέπει να σπάσει τίποτα (βλ. views: το πεδίο // είναι πάντα προαιρετικό στην εμφάνιση). $products_count = self::pluck( $data, array( 'products_count' ), null ); $documents_count = self::pluck( $data, array( 'documents_count' ), null ); $variation_indexing = array_key_exists( 'variation_indexing', $data ) ? $data['variation_indexing'] : null; // 🔴 «Διαφάνεια» (task-brief §3): δεύτερο, ανεξάρτητο όριο πάνω στις // ΕΓΓΡΑΦΕΣ (records) -- βλ. backend config/plans.php § record_limit, // StatsController. ΙΔΙΟ μοτίβο array_key_exists με 'limit'/'current' // παραπάνω -- null είναι τιμή («άγνωστο» ή «απεριόριστο»), ΟΧΙ // «λείπει». Χωρίς αυτό το ρητό pluck, το normalize() θα τα έκοβε // σιωπηλά (ΙΔΙΟ bug-class με CLAUDE.md § Τι έμαθε η πρώτη δοκιμή -- // αυτή η συνάρτηση είναι ΑΚΡΙΒΩΣ ένα τέτοιο whitelist). $products_limit = self::pluck( $data, array( 'products_limit' ), null ); $records_count = self::pluck( $data, array( 'records_count' ), null ); $records_limit = self::pluck( $data, array( 'records_limit' ), null ); // 29/09: διάστημα χρέωσης της ενεργής συνδρομής (month|year|null) και // δίκαιη χρήση αναζητήσεων. Ό,τι δεν είναι γνωστή τιμή → null. $billing_interval = self::pluck( $data, array( 'billing_interval' ), null ); $billing_interval = in_array( $billing_interval, array( 'month', 'year' ), true ) ? $billing_interval : null; return array( 'range' => $range_out, 'totals' => $totals_out, 'indexed_count' => is_numeric( $indexed_count ) ? (int) $indexed_count : null, 'last_sync_at' => is_string( $last_sync_at ) && '' !== $last_sync_at ? $last_sync_at : null, 'plan_renews_at' => is_string( $plan_renews_at ) && '' !== $plan_renews_at ? $plan_renews_at : null, // Πακέτο/όριο/χρήση — ο server τα προσθέτει στο ΙΔΙΟ /v1/stats (βλ. // backend StatsController), ώστε η καρτέλα «Κατάσταση» να τα δείχνει // χωρίς δεύτερο round-trip. `limit === null` σημαίνει απεριόριστο // πακέτο (βλ. backend PlanLimits::limitFor()) -- ξεχωριστό από «λείπει». 'plan' => is_scalar( $plan ) ? (string) $plan : '', 'limit' => is_numeric( $limit ) ? (int) $limit : null, 'current' => is_numeric( $current ) ? (int) $current : null, 'products_count' => is_numeric( $products_count ) ? (int) $products_count : null, 'documents_count' => is_numeric( $documents_count ) ? (int) $documents_count : null, 'variation_indexing' => null === $variation_indexing ? null : (bool) $variation_indexing, 'products_limit' => is_numeric( $products_limit ) ? (int) $products_limit : null, 'records_count' => is_numeric( $records_count ) ? (int) $records_count : null, 'records_limit' => is_numeric( $records_limit ) ? (int) $records_limit : null, 'billing_interval' => $billing_interval, 'fair_use' => self::normalize_fair_use( isset( $data['fair_use'] ) ? $data['fair_use'] : null ), 'top_terms' => self::normalize_rows( isset( $data['top_terms'] ) ? $data['top_terms'] : array(), array( 'term' => '', 'searches' => 0, 'results_avg' => 0.0, 'clicks' => 0, 'ctr' => 0.0, ) ), 'zero_terms' => self::normalize_rows( isset( $data['zero_terms'] ) ? $data['zero_terms'] : array(), array( 'term' => '', 'searches' => 0, 'last_seen' => '', ) ), 'low_ctr_terms' => self::normalize_rows( isset( $data['low_ctr_terms'] ) ? $data['low_ctr_terms'] : array(), array( 'term' => '', 'searches' => 0, 'results_avg' => 0.0, 'clicks' => 0, 'ctr' => 0.0, ) ), 'term_clicks' => self::normalize_rows( isset( $data['term_clicks'] ) ? $data['term_clicks'] : array(), array( 'term' => '', 'product_id' => 0, 'product_title' => '', 'clicks' => 0, ) ), 'timeseries' => self::normalize_rows( isset( $data['timeseries'] ) ? $data['timeseries'] : array(), array( 'day' => '', 'searches' => 0, 'zero_results' => 0, 'clicks' => 0, ) ), ); } /** * Το μπλοκ `fair_use` του /v1/stats → καθαρός πίνακας, ή null όταν δεν * υπάρχει μέτρηση που να αξίζει εμφάνιση. * * 🔴 null σε ΚΑΘΕ αμφίβολη περίπτωση: μπλοκ απόν (παλιός server), * `counted_until` null (η μέτρηση δεν έχει τρέξει ποτέ — τότε το * `searches: 0` ΔΕΝ είναι γεγονός), ή όριο που δεν είναι θετικός αριθμός. * * @param mixed $raw Η τιμή του `fair_use` από την απάντηση. * @return array|null */ private static function normalize_fair_use( $raw ) { if ( is_object( $raw ) ) { $raw = (array) $raw; } if ( ! is_array( $raw ) ) { return null; } $counted_until = isset( $raw['counted_until'] ) && is_string( $raw['counted_until'] ) ? $raw['counted_until'] : ''; $limit = isset( $raw['limit'] ) && is_numeric( $raw['limit'] ) ? (int) $raw['limit'] : 0; $searches = isset( $raw['searches'] ) && is_numeric( $raw['searches'] ) ? (int) $raw['searches'] : null; if ( '' === $counted_until || $limit <= 0 || null === $searches || $searches < 0 ) { return null; } $percent = isset( $raw['percent'] ) && is_numeric( $raw['percent'] ) ? (float) $raw['percent'] : round( $searches / $limit * 100, 1 ); $level = isset( $raw['level'] ) && in_array( $raw['level'], array( 'ok', 'warning', 'exceeded' ), true ) ? $raw['level'] : 'ok'; return array( 'month' => isset( $raw['month'] ) && is_string( $raw['month'] ) ? $raw['month'] : '', 'searches' => $searches, 'limit' => $limit, 'percent' => $percent, 'level' => $level, 'counted_until' => $counted_until, // Αν ο server πει ποτέ κάτι άλλο από ρητό false, το UI ΔΕΝ // υπόσχεται «η αναζήτηση δεν διακόπτεται». 'enforced' => ! ( array_key_exists( 'enforced', $raw ) && false === $raw['enforced'] ), ); } /** * Κανονικοποιεί μια λίστα γραμμών (π.χ. top_terms) πάνω σε ένα «σχήμα»: * κάθε γραμμή παίρνει ΑΚΡΙΒΩΣ τα κλειδιά του $shape, με τον τύπο που * υπαγορεύει η default τιμή τους (int/float/string). Ό,τι επιπλέον * κλειδί υπάρχει στην ακατέργαστη γραμμή αγνοείται σιωπηλά — αυτό είναι * και το φίλτρο απορρήτου: ποτέ δεν περνάμε στο view κάτι που δεν * ζητήσαμε ρητά (π.χ. IP/visitor id, αν κάποτε προστεθεί κατά λάθος * στο backend). * * @param mixed $rows Ό,τι έστειλε το API για αυτό το πεδίο. * @param array $shape key => τύπος-δείγμα (0, 0.0, '' ή array()). * @return array[] */ private static function normalize_rows( $rows, array $shape ) { if ( is_object( $rows ) ) { $rows = (array) $rows; } if ( ! is_array( $rows ) ) { return array(); } $out = array(); foreach ( $rows as $row ) { if ( is_object( $row ) ) { $row = (array) $row; } if ( ! is_array( $row ) ) { continue; } $clean = array(); foreach ( $shape as $key => $default ) { $value = array_key_exists( $key, $row ) ? $row[ $key ] : null; if ( null === $value ) { $clean[ $key ] = $default; continue; } if ( is_int( $default ) ) { $clean[ $key ] = self::to_int( $value ); } elseif ( is_float( $default ) ) { $clean[ $key ] = self::to_float( $value ); } else { $clean[ $key ] = is_scalar( $value ) ? (string) $value : $default; } } $out[] = $clean; } return $out; } /** * Κατάσταση δεικτοδότησης: πόσα δεικτοδοτημένα (από το ίδιο /v1/stats, πεδίο * indexed_count) και πόσα εκκρεμούν στην ουρά (τοπικά, μέσω Action Scheduler — * δεν χρειάζεται δίκτυο και δεν εξαρτάται από το σχήμα της απάντησης του API). * * @param bool $force Παρακάμπτει την cache του απομακρυσμένου μέρους. * @return array{indexed_count: int|null, last_sync_at: string|null, queue_pending: int|null, remote_error: WP_Error|null} */ public static function get_index_status( $force = false ) { $queue_pending = self::count_pending_queue(); $data = self::get_stats( 30, $force ); if ( is_wp_error( $data ) ) { return array( 'indexed_count' => null, 'last_sync_at' => null, 'plan_renews_at' => null, 'queue_pending' => $queue_pending, 'remote_error' => $data, 'plan' => '', 'limit' => null, 'current' => null, 'products_count' => null, 'documents_count' => null, 'variation_indexing' => null, // 🔴 «Διαφάνεια» (task-brief §3): δεύτερο, ανεξάρτητο όριο πάνω // στις ΕΓΓΡΑΦΕΣ (records) — βλ. backend config/plans.php § // record_limit. null εδώ = άγνωστο (χωρίς ενεργή άδεια/σφάλμα // δικτύου), ΙΔΙΑ σύμβαση με 'limit'/'current' παραπάνω. 'records_count' => null, 'records_limit' => null, 'billing_interval' => null, 'fair_use' => null, ); } return array( 'indexed_count' => isset( $data['indexed_count'] ) ? $data['indexed_count'] : null, // Από τις 23/09 υπάρχει στο συμβόλαιο του /v1/stats. Πριν ήταν // hardcoded null, δηλαδή η γραμμή «Last sync» ήταν ΜΟΝΙΜΑ κενή // σε κάθε εγκατάσταση — ορατό πεδίο που δεν έδειχνε ποτέ τίποτα. 'last_sync_at' => isset( $data['last_sync_at'] ) && is_string( $data['last_sync_at'] ) ? $data['last_sync_at'] : null, // 🔴 Ανανέωση ΣΥΝΔΡΟΜΗΣ, ΟΧΙ λήξη token — βλ. σχόλιο στο // admin/views/status.php. null όσο δεν υπάρχει πληρωμένη συνδρομή, // και τότε η γραμμή απλώς δεν εμφανίζεται. 'plan_renews_at' => isset( $data['plan_renews_at'] ) && is_string( $data['plan_renews_at'] ) ? $data['plan_renews_at'] : null, 'queue_pending' => $queue_pending, 'remote_error' => null, 'plan' => isset( $data['plan'] ) ? $data['plan'] : '', 'limit' => isset( $data['limit'] ) ? $data['limit'] : null, 'current' => isset( $data['current'] ) ? $data['current'] : null, 'products_count' => isset( $data['products_count'] ) ? $data['products_count'] : null, 'documents_count' => isset( $data['documents_count'] ) ? $data['documents_count'] : null, 'variation_indexing' => array_key_exists( 'variation_indexing', $data ) ? $data['variation_indexing'] : null, // 🔴 GET /v1/stats § records_count/records_limit (backend // StatsController) — ίδιο πλήθος με documents_count, διαφορετικό // όνομα ΓΙΑ ΤΟ ΟΡΙΟ (records_limit) που δεν υπάρχει για το // documents_count. Η καρτέλα «Κατάσταση» τα δείχνει ΚΑΙ ΤΑ ΔΥΟ // (products + records) με ξεχωριστές μπάρες. 'records_count' => isset( $data['records_count'] ) ? $data['records_count'] : null, 'records_limit' => array_key_exists( 'records_limit', $data ) ? $data['records_limit'] : null, 'billing_interval' => isset( $data['billing_interval'] ) ? $data['billing_interval'] : null, 'fair_use' => isset( $data['fair_use'] ) && is_array( $data['fair_use'] ) ? $data['fair_use'] : null, ); } /** * Μετρά τοπικά, μέσω Action Scheduler, πόσες ενέργειες δεικτοδότησης εκκρεμούν * (ομάδα `addsynapse-search`). Επιστρέφει null αν δεν υπάρχει διαθέσιμο Action Scheduler. * * @return int|null */ private static function count_pending_queue() { if ( ! function_exists( 'as_get_scheduled_actions' ) ) { return null; } $total = 0; foreach ( array( 'addsynapse_search_sync_product', 'addsynapse_search_delete_product', 'addsynapse_search_reindex_all' ) as $hook ) { $ids = as_get_scheduled_actions( array( 'hook' => $hook, 'group' => 'addsynapse-search', 'status' => 'pending', 'per_page' => -1, ), 'ids' ); if ( is_array( $ids ) ) { $total += count( $ids ); } } return $total; } /** * Ελέγχει αν η ουρά Action Scheduler (ομάδα `addsynapse-search`) «κόλλησε»: υπάρχει * εκκρεμής (pending) ενέργεια, ΚΑΜΙΑ δεν τρέχει αυτή τη στιγμή, και η * παλαιότερη είναι προγραμματισμένη πάνω από STALLED_THRESHOLD πριν. * * 🔴 ΓΙΑΤΙ: το Action Scheduler στηρίζεται στο WP-Cron -- αν είναι * απενεργοποιημένο (DISABLE_WP_CRON) ή το site δεν έχει επισκέψεις, οι * ενέργειες μένουν pending επ' αόριστον χωρίς κανένα μήνυμα σφάλματος * («σιωπηλή αποτυχία»). Η καρτέλα «Κατάσταση» δείχνει προειδοποίηση με * βάση αυτό, προτείνοντας το «Run now» (Addsynapse_Search_Admin::ajax_run_batch()). * * Χρησιμοποιεί as_next_scheduled_action( $hook, null, 'addsynapse-search' ) ανά hook: * με $args=null δεν φιλτράρει βάσει ορισμάτων (άρα ταιριάζει ΟΠΟΙΑΔΗΠΟΤΕ * εκκρεμή ενέργεια του hook, π.χ. οποιοδήποτε product_id), επιστρέφει * true αν κάτι ΤΡΕΧΕΙ αυτή τη στιγμή, αλλιώς το timestamp της * παλαιότερης pending, αλλιώς false αν δεν υπάρχει καμία. * * @return array{stalled:bool,pending_since:int|null,age_seconds:int|null} */ public static function get_queue_health() { $default = array( 'stalled' => false, 'pending_since' => null, 'age_seconds' => null, ); if ( ! function_exists( 'as_next_scheduled_action' ) ) { return $default; } $earliest = null; $any_running = false; foreach ( array( 'addsynapse_search_sync_product', 'addsynapse_search_delete_product', 'addsynapse_search_reindex_all' ) as $hook ) { $result = as_next_scheduled_action( $hook, null, 'addsynapse-search' ); if ( true === $result ) { $any_running = true; continue; } if ( is_numeric( $result ) ) { $timestamp = (int) $result; if ( null === $earliest || $timestamp < $earliest ) { $earliest = $timestamp; } } } if ( $any_running || null === $earliest ) { return $default; } $age = time() - $earliest; return array( 'stalled' => $age >= self::QUEUE_STALLED_THRESHOLD, 'pending_since' => $earliest, 'age_seconds' => max( 0, $age ), ); } /** * Μορφοποιεί CTR για εμφάνιση. 🔴 Ο server στέλνει ΛΟΓΟ 0–1 (StatsController::ratio(), * 4 δεκαδικά — π.χ. 0.5333), ΟΧΙ ποσοστό: ως 0.8.2 το view τύπωνε «0.5%» αντί για «53%». * Κάτω από 10% ένα δεκαδικό (το όριο «χαμηλού CTR» είναι 10%), αλλιώς ακέραιο. * * @param mixed $ratio Λόγος κλικ/αναζητήσεις. * @return string π.χ. «53%», «4.5%», «0%». */ public static function format_ctr( $ratio ) { $pct = is_numeric( $ratio ) ? max( 0.0, (float) $ratio * 100 ) : 0.0; $dec = ( $pct < 10 && abs( round( $pct, 1 ) - round( $pct ) ) > 0.0001 ) ? 1 : 0; return ( function_exists( 'number_format_i18n' ) ? number_format_i18n( $pct, $dec ) : number_format( $pct, $dec ) ) . '%'; } /** * Διαβάζει την πρώτη τιμή που υπάρχει από μια λίστα πιθανών κλειδιών. * Δέχεται array ή object (π.χ. αν το API επιστρέψει stdClass). * * @param mixed $data * @param array $keys * @param mixed $default * @return mixed */ public static function pluck( $data, $keys, $default = null ) { if ( is_object( $data ) ) { $data = (array) $data; } if ( ! is_array( $data ) ) { return $default; } foreach ( (array) $keys as $key ) { if ( array_key_exists( $key, $data ) && null !== $data[ $key ] && '' !== $data[ $key ] ) { return $data[ $key ]; } } return $default; } /** * @param mixed $value * @return int */ private static function to_int( $value ) { return is_numeric( $value ) ? (int) $value : 0; } /** * @param mixed $value * @return float */ private static function to_float( $value ) { return is_numeric( $value ) ? (float) $value : 0.0; } /** * Καθαρίζει όλη την cache στατιστικών (π.χ. μετά από πλήρη επαναδεικτοδότηση). * * 🔴 ΜΟΝΟ delete_transient() με ΑΚΡΙΒΗ κλειδιά (audit F14): με persistent * object cache (Redis/Memcached) τα transients ΔΕΝ ζουν στο wp_options — * ένα DELETE ... LIKE εκεί δεν άγγιζε τίποτα και η καρτέλα έδειχνε παλιά * νούμερα. Τα κλειδιά είναι γνωστά: CACHE_PREFIX + κάθε VALID_RANGES. * * @return void */ public static function clear_cache() { foreach ( self::cache_keys() as $key ) { delete_transient( $key ); } } /** * Όλα τα κλειδιά transient που γράφει αυτή η κλάση (το χρησιμοποιεί και το uninstall). * * @return string[] */ public static function cache_keys() { $keys = array(); foreach ( self::VALID_RANGES as $range ) { $keys[] = self::CACHE_PREFIX . $range; } return $keys; } }