Analytics
2 weeks ago
Database
2 months ago
DynamicFieldResolver.php
1 month ago
Elementor_Enhancer.php
6 months ago
EmbedPress_Core_Installer.php
6 years ago
EmbedPress_Notice.php
3 months ago
EmbedPress_Plugin_Usage_Tracker.php
1 month ago
Extend_CustomPlayer_Controls.php
2 months ago
Extend_Elementor_Controls.php
1 year ago
FeatureNoticeManager.php
5 days ago
FeatureNotices.php
5 days ago
FeaturePreviewModal.php
5 days ago
Feature_Enhancer.php
1 month ago
GoogleReviewsAdminPage.php
5 days ago
GoogleReviewsApify.php
5 days ago
GoogleReviewsManaged.php
5 days ago
GoogleReviewsRenderer.php
5 days ago
GoogleReviewsRestController.php
5 days ago
GoogleReviewsStore.php
5 days ago
Helper.php
5 days ago
Pdf_Thumbnail_Handler.php
2 months ago
PermalinkHelper.php
10 months ago
View_Count_Display.php
2 weeks ago
GoogleReviewsStore.php
500 lines
| 1 | <?php |
| 2 | |
| 3 | namespace EmbedPress\Includes\Classes; |
| 4 | |
| 5 | (defined('ABSPATH') && defined('EMBEDPRESS_IS_LOADED')) or die("No direct script access allowed."); |
| 6 | |
| 7 | /** |
| 8 | * DB-backed store for Google Reviews "places". |
| 9 | * |
| 10 | * One row per place (per entry). Adding a place anywhere — settings page, block, |
| 11 | * or Elementor widget — saves it here globally, so a search that's already saved |
| 12 | * just selects the existing row instead of re-adding/re-fetching. |
| 13 | * |
| 14 | * Reviews are FETCHED into this table explicitly (settings "Refresh", or a |
| 15 | * one-time auto-fetch on first render) and READ from here on every render — |
| 16 | * rendering does NO network calls. This decouples expensive fetching (rare, |
| 17 | * deliberate) from cheap displaying (every page view). |
| 18 | * |
| 19 | * Schema (wp_embedpress_gr_places): |
| 20 | * id PK |
| 21 | * place_id Google Place ID (unique) |
| 22 | * place_name display name |
| 23 | * meta JSON {name, rating, total} |
| 24 | * reviews JSON [ {author_name, rating, text, time, relative_time, profile_photo_url}, … ] |
| 25 | * review_count cached count(reviews) |
| 26 | * source 'api' (≤5) | 'apify' (all) | '' (never fetched) |
| 27 | * last_fetched_at datetime, NULL = never fetched |
| 28 | * created_at datetime |
| 29 | * updated_at datetime |
| 30 | */ |
| 31 | class GoogleReviewsStore |
| 32 | { |
| 33 | const DB_VERSION = '1.1.0'; |
| 34 | const DB_VERSION_OPT = 'embedpress_gr_store_db_version'; |
| 35 | |
| 36 | // fetch_status values |
| 37 | const STATUS_IDLE = 'idle'; |
| 38 | const STATUS_QUEUED = 'queued'; |
| 39 | const STATUS_RUNNING = 'running'; |
| 40 | const STATUS_DONE = 'done'; |
| 41 | const STATUS_FAILED = 'failed'; |
| 42 | |
| 43 | public static function table(): string |
| 44 | { |
| 45 | global $wpdb; |
| 46 | return $wpdb->prefix . 'embedpress_gr_places'; |
| 47 | } |
| 48 | |
| 49 | /** |
| 50 | * Create/upgrade the table. Idempotent — safe to call on every request; it |
| 51 | * short-circuits once the stored DB version matches. |
| 52 | */ |
| 53 | public static function ensure_table(): void |
| 54 | { |
| 55 | if (get_option(self::DB_VERSION_OPT) === self::DB_VERSION) { |
| 56 | return; |
| 57 | } |
| 58 | |
| 59 | global $wpdb; |
| 60 | $table = self::table(); |
| 61 | $charset_collate = $wpdb->get_charset_collate(); |
| 62 | |
| 63 | $sql = "CREATE TABLE IF NOT EXISTS $table ( |
| 64 | id bigint(20) unsigned NOT NULL AUTO_INCREMENT, |
| 65 | place_id varchar(191) NOT NULL, |
| 66 | place_name varchar(255) DEFAULT '', |
| 67 | meta longtext DEFAULT NULL, |
| 68 | reviews longtext DEFAULT NULL, |
| 69 | review_count int(10) unsigned DEFAULT 0, |
| 70 | source varchar(20) DEFAULT '', |
| 71 | last_fetched_at datetime DEFAULT NULL, |
| 72 | fetch_status varchar(20) DEFAULT 'idle', |
| 73 | fetch_run_id varchar(191) DEFAULT NULL, |
| 74 | fetch_message varchar(255) DEFAULT NULL, |
| 75 | fetched_so_far int(10) unsigned DEFAULT 0, |
| 76 | created_at datetime DEFAULT CURRENT_TIMESTAMP, |
| 77 | updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
| 78 | PRIMARY KEY (id), |
| 79 | UNIQUE KEY unique_place_id (place_id), |
| 80 | KEY idx_last_fetched_at (last_fetched_at), |
| 81 | KEY idx_fetch_status (fetch_status) |
| 82 | ) $charset_collate;"; |
| 83 | |
| 84 | require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); |
| 85 | dbDelta($sql); |
| 86 | |
| 87 | // dbDelta can skip column adds on existing tables in some configs — add |
| 88 | // the job columns explicitly for upgrades from the 1.0.0 schema. |
| 89 | $cols = $wpdb->get_col("SHOW COLUMNS FROM `$table`"); |
| 90 | if (is_array($cols)) { |
| 91 | $add = []; |
| 92 | if (!in_array('fetch_status', $cols, true)) $add[] = "ADD COLUMN fetch_status varchar(20) DEFAULT 'idle' AFTER last_fetched_at"; |
| 93 | if (!in_array('fetch_run_id', $cols, true)) $add[] = "ADD COLUMN fetch_run_id varchar(191) DEFAULT NULL AFTER fetch_status"; |
| 94 | if (!in_array('fetch_message', $cols, true)) $add[] = "ADD COLUMN fetch_message varchar(255) DEFAULT NULL AFTER fetch_run_id"; |
| 95 | if (!in_array('fetched_so_far', $cols, true)) $add[] = "ADD COLUMN fetched_so_far int(10) unsigned DEFAULT 0 AFTER fetch_message"; |
| 96 | if (!empty($add)) { |
| 97 | $wpdb->query("ALTER TABLE `$table` " . implode(', ', $add)); |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | update_option(self::DB_VERSION_OPT, self::DB_VERSION); |
| 102 | } |
| 103 | |
| 104 | /** |
| 105 | * Get one place row (assoc array) by place_id, or null. |
| 106 | */ |
| 107 | public static function get(string $place_id): ?array |
| 108 | { |
| 109 | $place_id = trim($place_id); |
| 110 | if ($place_id === '') { |
| 111 | return null; |
| 112 | } |
| 113 | self::ensure_table(); |
| 114 | global $wpdb; |
| 115 | $row = $wpdb->get_row( |
| 116 | $wpdb->prepare("SELECT * FROM " . self::table() . " WHERE place_id = %s", $place_id), |
| 117 | ARRAY_A |
| 118 | ); |
| 119 | return $row ? self::hydrate($row) : null; |
| 120 | } |
| 121 | |
| 122 | public static function exists(string $place_id): bool |
| 123 | { |
| 124 | return self::get($place_id) !== null; |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Add a place if it doesn't already exist (lookup-first). Returns the row. |
| 129 | * Does NOT fetch reviews — that's a separate, explicit step. |
| 130 | */ |
| 131 | public static function add(string $place_id, string $place_name = '', string $address = ''): ?array |
| 132 | { |
| 133 | $place_id = trim($place_id); |
| 134 | if ($place_id === '') { |
| 135 | return null; |
| 136 | } |
| 137 | self::ensure_table(); |
| 138 | $address = sanitize_text_field($address); |
| 139 | |
| 140 | $existing = self::get($place_id); |
| 141 | if ($existing) { |
| 142 | global $wpdb; |
| 143 | $patch = []; |
| 144 | // Keep a friendlier name if we now have one and the row lacked it. |
| 145 | if ($place_name !== '' && ($existing['place_name'] ?? '') === '') { |
| 146 | $patch['place_name'] = sanitize_text_field($place_name); |
| 147 | $existing['place_name'] = $patch['place_name']; |
| 148 | } |
| 149 | // Backfill the address into meta if we now have one and it's missing. |
| 150 | if ($address !== '') { |
| 151 | $meta = is_array($existing['meta'] ?? null) ? $existing['meta'] : []; |
| 152 | if (empty($meta['address'])) { |
| 153 | $meta['address'] = $address; |
| 154 | $patch['meta'] = wp_json_encode($meta); |
| 155 | $existing['meta'] = $meta; |
| 156 | } |
| 157 | } |
| 158 | if ($patch) { |
| 159 | $wpdb->update(self::table(), $patch, ['place_id' => $place_id]); |
| 160 | } |
| 161 | return $existing; |
| 162 | } |
| 163 | |
| 164 | global $wpdb; |
| 165 | $wpdb->insert(self::table(), [ |
| 166 | 'place_id' => $place_id, |
| 167 | 'place_name' => sanitize_text_field($place_name), |
| 168 | // Seed the address (location description) into meta on add so the |
| 169 | // saved-place lists can show it without a fetch. |
| 170 | 'meta' => wp_json_encode($address !== '' ? ['address' => $address] : []), |
| 171 | 'reviews' => wp_json_encode([]), |
| 172 | 'review_count' => 0, |
| 173 | 'source' => '', |
| 174 | 'created_at' => current_time('mysql'), |
| 175 | ]); |
| 176 | |
| 177 | return self::get($place_id); |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Display fields captured from the place SEARCH result (name, address, total |
| 182 | * review count, rating). These describe the place itself and are the source |
| 183 | * of truth for the saved-place row — a later reviews FETCH (which only |
| 184 | * supplies the review list) must never overwrite them. Used by merge_meta(). |
| 185 | */ |
| 186 | const SEEDED_META_KEYS = ['address', 'total', 'rating']; |
| 187 | |
| 188 | /** |
| 189 | * Seed search-result display fields (total/rating/…) into a place's meta |
| 190 | * without clobbering values already present. Called on add() so the row |
| 191 | * shows the real numbers immediately, before any fetch runs. |
| 192 | * |
| 193 | * @param array $seed e.g. ['total' => 109, 'rating' => 4.0] |
| 194 | */ |
| 195 | public static function seed_meta(string $place_id, array $seed): void |
| 196 | { |
| 197 | $place_id = trim($place_id); |
| 198 | if ($place_id === '' || !self::exists($place_id)) { |
| 199 | return; |
| 200 | } |
| 201 | $row = self::get($place_id); |
| 202 | $meta = (is_array($row) && is_array($row['meta'] ?? null)) ? $row['meta'] : []; |
| 203 | $changed = false; |
| 204 | foreach ($seed as $k => $v) { |
| 205 | // Only fill blanks — never overwrite an existing non-empty value. |
| 206 | if (($v !== null && $v !== '' && $v !== 0 && $v !== 0.0) && empty($meta[$k])) { |
| 207 | $meta[$k] = $v; |
| 208 | $changed = true; |
| 209 | } |
| 210 | } |
| 211 | if ($changed) { |
| 212 | global $wpdb; |
| 213 | $wpdb->update(self::table(), ['meta' => wp_json_encode($meta)], ['place_id' => $place_id]); |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Merge a FETCH's meta onto the stored meta, protecting the search-seeded |
| 219 | * display fields (SEEDED_META_KEYS). The fetch supplies reviews; the place's |
| 220 | * own info (address, total review count, rating) comes from the search |
| 221 | * result and stays authoritative. A seeded field is only filled from the |
| 222 | * fetch when it isn't already stored. |
| 223 | * |
| 224 | * @param array $incoming meta from the fetch payload |
| 225 | * @param array $existing meta already stored for the place |
| 226 | * @return array the meta to persist |
| 227 | */ |
| 228 | private static function merge_meta(array $incoming, array $existing): array |
| 229 | { |
| 230 | foreach (self::SEEDED_META_KEYS as $k) { |
| 231 | if (!empty($existing[$k])) { |
| 232 | // We already have it (from search/seed) — keep ours. |
| 233 | $incoming[$k] = $existing[$k]; |
| 234 | } |
| 235 | // else: leave whatever the fetch provided (may be empty — fine). |
| 236 | } |
| 237 | return $incoming; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Persist a fetched review set + meta against a place. Creates the row if |
| 242 | * needed. Called by the fetch service (settings refresh / first-render). |
| 243 | */ |
| 244 | public static function save_reviews(string $place_id, array $reviews, array $meta, string $source): ?array |
| 245 | { |
| 246 | $place_id = trim($place_id); |
| 247 | if ($place_id === '') { |
| 248 | return null; |
| 249 | } |
| 250 | self::ensure_table(); |
| 251 | |
| 252 | $name = (string) ($meta['name'] ?? ''); |
| 253 | // Defense in depth: scrapers occasionally pick up a page-region |
| 254 | // header instead of the place name (e.g. "Results" when the |
| 255 | // reviews tab takes a moment to hydrate). Don't overwrite a good |
| 256 | // stored name with one of these obvious junk values. |
| 257 | $junk_names = [ |
| 258 | 'results', 'search', 'google maps', 'maps', 'google', |
| 259 | 'résultats', 'ergebnisse', 'risultati', 'resultados', |
| 260 | ]; |
| 261 | $is_junk = in_array(strtolower(trim($name)), $junk_names, true); |
| 262 | if ($is_junk) { |
| 263 | $name = ''; |
| 264 | } |
| 265 | |
| 266 | if (!self::exists($place_id)) { |
| 267 | self::add($place_id, $name); |
| 268 | } |
| 269 | |
| 270 | // Look up the existing row so we can preserve the name + address through |
| 271 | // a payload that doesn't carry them (above). |
| 272 | $existing_name = ''; |
| 273 | $existing_meta = []; |
| 274 | if (function_exists('current_time')) { |
| 275 | $row = self::get($place_id); |
| 276 | $existing_name = is_array($row) ? (string) ($row['place_name'] ?? '') : ''; |
| 277 | $existing_meta = (is_array($row) && is_array($row['meta'] ?? null)) ? $row['meta'] : []; |
| 278 | } |
| 279 | |
| 280 | // Protect the search-seeded display fields (address, total, rating): the |
| 281 | // fetch only supplies the review list — the place's own info comes from |
| 282 | // the search result and stays authoritative. Without this, a fetch whose |
| 283 | // meta omits the address/total/rating would WIPE what we captured on add |
| 284 | // (the picker's secondary_text + review_count + rating). |
| 285 | $meta = self::merge_meta($meta, $existing_meta); |
| 286 | |
| 287 | global $wpdb; |
| 288 | $wpdb->update(self::table(), [ |
| 289 | 'place_name' => $name !== '' |
| 290 | ? sanitize_text_field($name) |
| 291 | : ($existing_name !== '' ? $existing_name : null), |
| 292 | 'meta' => wp_json_encode($meta), |
| 293 | 'reviews' => wp_json_encode(array_values($reviews)), |
| 294 | 'review_count' => count($reviews), |
| 295 | 'source' => sanitize_key($source), |
| 296 | 'last_fetched_at' => current_time('mysql'), |
| 297 | 'fetch_status' => self::STATUS_DONE, |
| 298 | 'fetch_run_id' => null, |
| 299 | 'fetch_message' => null, |
| 300 | 'fetched_so_far' => count($reviews), |
| 301 | ], ['place_id' => $place_id]); |
| 302 | |
| 303 | return self::get($place_id); |
| 304 | } |
| 305 | |
| 306 | /** |
| 307 | * Mark a place's background fetch job state (status + optional run id / |
| 308 | * message / progress count). Used by the Pro batch-fetch service + cron. |
| 309 | */ |
| 310 | public static function set_job(string $place_id, string $status, array $extra = []): void |
| 311 | { |
| 312 | $place_id = trim($place_id); |
| 313 | if ($place_id === '') { |
| 314 | return; |
| 315 | } |
| 316 | self::ensure_table(); |
| 317 | if (!self::exists($place_id)) { |
| 318 | self::add($place_id, (string) ($extra['place_name'] ?? '')); |
| 319 | } |
| 320 | $data = ['fetch_status' => sanitize_key($status)]; |
| 321 | if (array_key_exists('run_id', $extra)) $data['fetch_run_id'] = $extra['run_id'] !== null ? sanitize_text_field((string) $extra['run_id']) : null; |
| 322 | if (array_key_exists('message', $extra)) $data['fetch_message'] = $extra['message'] !== null ? sanitize_text_field((string) $extra['message']) : null; |
| 323 | if (array_key_exists('so_far', $extra)) $data['fetched_so_far'] = max(0, (int) $extra['so_far']); |
| 324 | global $wpdb; |
| 325 | $wpdb->update(self::table(), $data, ['place_id' => $place_id]); |
| 326 | } |
| 327 | |
| 328 | /** |
| 329 | * Append a batch of reviews to a place's stored set (deduped by the caller), |
| 330 | * bumping review_count + fetched_so_far. For incremental background fetch. |
| 331 | * Pass $final=true with meta/source to finalize the job. |
| 332 | */ |
| 333 | public static function append_reviews(string $place_id, array $batch, array $meta = [], string $source = 'apify', bool $final = false): void |
| 334 | { |
| 335 | $place_id = trim($place_id); |
| 336 | if ($place_id === '' || !self::exists($place_id)) { |
| 337 | return; |
| 338 | } |
| 339 | $row = self::get($place_id); |
| 340 | $current = is_array($row['reviews']) ? $row['reviews'] : []; |
| 341 | $merged = self::dedupe(array_merge($current, array_values($batch))); |
| 342 | |
| 343 | // TIER-AWARE HARD CAP on the stored set. The worker overshoots its soft |
| 344 | // `max` target (it scrolls in ~10-card batches), and the merge unions |
| 345 | // across fetches — so without a clamp HERE a FREE place ends up storing |
| 346 | // more than the free cap, and any place could exceed 1000. Clamp to the |
| 347 | // effective tier cap: 10 for free, 1000 for Pro. |
| 348 | // Keep the newest (tail = most recently appended). |
| 349 | $is_pro = class_exists('\\EmbedPress\\Includes\\Classes\\Helper') |
| 350 | && \EmbedPress\Includes\Classes\Helper::is_pro_active(); |
| 351 | $free_cap = (int) apply_filters('embedpress/google_reviews/free_max_reviews', 10); |
| 352 | $pro_cap = (int) apply_filters('embedpress/google_reviews/max_stored_reviews', 1000); |
| 353 | $cap = $is_pro ? $pro_cap : $free_cap; |
| 354 | if (count($merged) > $cap) { |
| 355 | $merged = array_slice($merged, -$cap); |
| 356 | } |
| 357 | |
| 358 | global $wpdb; |
| 359 | $data = [ |
| 360 | 'reviews' => wp_json_encode($merged), |
| 361 | 'review_count' => count($merged), |
| 362 | 'fetched_so_far' => count($merged), |
| 363 | ]; |
| 364 | if (!empty($meta)) { |
| 365 | // Protect search-seeded display fields (address/total/rating) — the |
| 366 | // batched fetch supplies reviews, not the place's own info. |
| 367 | $existing_meta = (is_array($row) && is_array($row['meta'] ?? null)) ? $row['meta'] : []; |
| 368 | $meta = self::merge_meta($meta, $existing_meta); |
| 369 | $data['meta'] = wp_json_encode($meta); |
| 370 | if (!empty($meta['name'])) { |
| 371 | $data['place_name'] = sanitize_text_field((string) $meta['name']); |
| 372 | } |
| 373 | } |
| 374 | if ($final) { |
| 375 | $data['source'] = sanitize_key($source); |
| 376 | $data['last_fetched_at'] = current_time('mysql'); |
| 377 | $data['fetch_status'] = self::STATUS_DONE; |
| 378 | $data['fetch_run_id'] = null; |
| 379 | } |
| 380 | $wpdb->update(self::table(), $data, ['place_id' => $place_id]); |
| 381 | } |
| 382 | |
| 383 | /** |
| 384 | * Clear a place's stored reviews (used when a fresh background fetch starts, |
| 385 | * so a refresh REPLACES the set instead of stacking duplicates on top). |
| 386 | */ |
| 387 | public static function reset_reviews(string $place_id): void |
| 388 | { |
| 389 | $place_id = trim($place_id); |
| 390 | if ($place_id === '' || !self::exists($place_id)) { |
| 391 | return; |
| 392 | } |
| 393 | global $wpdb; |
| 394 | $wpdb->update(self::table(), [ |
| 395 | 'reviews' => wp_json_encode([]), |
| 396 | 'review_count' => 0, |
| 397 | 'fetched_so_far' => 0, |
| 398 | ], ['place_id' => $place_id]); |
| 399 | } |
| 400 | |
| 401 | /** |
| 402 | * Compute a stable per-review identity hash. Used to detect duplicates and |
| 403 | * to early-stop incremental refetches (the first already-seen fingerprint |
| 404 | * = we've caught up with Google). |
| 405 | * |
| 406 | * Inputs are deliberately stable across edits to the surrounding metadata |
| 407 | * but DO change if the user edits their review text — that's the right |
| 408 | * behaviour for "new review detection" but means an edited review looks |
| 409 | * new (a periodic full refresh reconciles edits/deletes). |
| 410 | */ |
| 411 | public static function fingerprint_of(array $review): string |
| 412 | { |
| 413 | return md5( |
| 414 | ($review['author_name'] ?? '') |
| 415 | . '|' . ($review['time'] ?? '') |
| 416 | . '|' . substr((string) ($review['text'] ?? ''), 0, 80) |
| 417 | ); |
| 418 | } |
| 419 | |
| 420 | /** Dedupe a review list by author + time + text fingerprint. */ |
| 421 | private static function dedupe(array $reviews): array |
| 422 | { |
| 423 | $seen = []; |
| 424 | $out = []; |
| 425 | foreach ($reviews as $r) { |
| 426 | if (!is_array($r)) { |
| 427 | continue; |
| 428 | } |
| 429 | $key = self::fingerprint_of($r); |
| 430 | if (isset($seen[$key])) { |
| 431 | continue; |
| 432 | } |
| 433 | $seen[$key] = true; |
| 434 | $out[] = $r; |
| 435 | } |
| 436 | return $out; |
| 437 | } |
| 438 | |
| 439 | /** |
| 440 | * Return the set of fingerprints currently stored for a place. Used by the |
| 441 | * incremental refetch to detect "we've caught up" — the moment a batch |
| 442 | * contains a fingerprint already in this set, everything after it is |
| 443 | * already in our DB (Apify returns newest-first). |
| 444 | * |
| 445 | * @return array<string, true> map of fingerprint → true (cheap set lookup) |
| 446 | */ |
| 447 | public static function fingerprints_for(string $place_id): array |
| 448 | { |
| 449 | $row = self::get($place_id); |
| 450 | $stored = ($row && is_array($row['reviews'] ?? null)) ? $row['reviews'] : []; |
| 451 | $set = []; |
| 452 | foreach ($stored as $r) { |
| 453 | if (is_array($r)) { |
| 454 | $set[self::fingerprint_of($r)] = true; |
| 455 | } |
| 456 | } |
| 457 | return $set; |
| 458 | } |
| 459 | |
| 460 | public static function remove(string $place_id): bool |
| 461 | { |
| 462 | $place_id = trim($place_id); |
| 463 | if ($place_id === '') { |
| 464 | return false; |
| 465 | } |
| 466 | self::ensure_table(); |
| 467 | global $wpdb; |
| 468 | return (bool) $wpdb->delete(self::table(), ['place_id' => $place_id]); |
| 469 | } |
| 470 | |
| 471 | /** |
| 472 | * All saved places, newest first. Each row hydrated (reviews/meta decoded). |
| 473 | * For the settings manager list. |
| 474 | */ |
| 475 | public static function all(): array |
| 476 | { |
| 477 | self::ensure_table(); |
| 478 | global $wpdb; |
| 479 | $rows = $wpdb->get_results("SELECT * FROM " . self::table() . " ORDER BY created_at DESC", ARRAY_A); |
| 480 | return is_array($rows) ? array_map([self::class, 'hydrate'], $rows) : []; |
| 481 | } |
| 482 | |
| 483 | /** Decode JSON columns into arrays. */ |
| 484 | private static function hydrate(array $row): array |
| 485 | { |
| 486 | $row['meta'] = isset($row['meta']) ? (json_decode($row['meta'], true) ?: []) : []; |
| 487 | $row['reviews'] = isset($row['reviews']) ? (json_decode($row['reviews'], true) ?: []) : []; |
| 488 | $row['review_count'] = (int) ($row['review_count'] ?? 0); |
| 489 | return $row; |
| 490 | } |
| 491 | |
| 492 | /** Drop the table (uninstall). */ |
| 493 | public static function drop_table(): void |
| 494 | { |
| 495 | global $wpdb; |
| 496 | $wpdb->query("DROP TABLE IF EXISTS " . self::table()); |
| 497 | delete_option(self::DB_VERSION_OPT); |
| 498 | } |
| 499 | } |
| 500 |