PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.3
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.3
4.6.3 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / EmbedPress / Includes / Classes / GoogleReviewsStore.php
embedpress / EmbedPress / Includes / Classes Last commit date
Analytics 1 month ago Database 3 months ago DynamicFieldResolver.php 2 months ago Elementor_Enhancer.php 7 months ago EmbedPress_Core_Installer.php 6 years ago EmbedPress_Notice.php 4 months ago EmbedPress_Plugin_Usage_Tracker.php 2 months ago Extend_CustomPlayer_Controls.php 3 months ago Extend_Elementor_Controls.php 1 year ago FeatureNoticeManager.php 1 month ago FeatureNotices.php 2 weeks ago FeaturePreviewModal.php 1 month ago Feature_Enhancer.php 2 months ago GoogleReviewsAdminPage.php 1 month ago GoogleReviewsApify.php 1 month 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 5 days ago PermalinkHelper.php 11 months ago View_Count_Display.php 1 month ago
GoogleReviewsStore.php
555 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 * Per-job push-webhook secret store.
308 *
309 * When a managed scrape is enqueued we generate a random secret, send it to
310 * the API, and stash it here keyed by job_id. When the API POSTs the result
311 * back to our inbound webhook, the route recomputes the HMAC with this secret
312 * and constant-time compares — so a leaked callback URL alone can't forge
313 * reviews into the store.
314 *
315 * Held in a transient (not a table column) because it's short-lived: it only
316 * needs to survive from enqueue until the webhook fires. 6h TTL comfortably
317 * covers the worst-case scrape (30-min job wall) plus retries, then expires.
318 */
319 const WEBHOOK_SECRET_TTL = 6 * HOUR_IN_SECONDS;
320
321 private static function webhook_secret_key(string $job_id): string
322 {
323 return 'ep_gr_cb_' . md5($job_id);
324 }
325
326 /**
327 * Persist an already-generated secret under a job_id. Managed generates the
328 * secret BEFORE the job_id exists (it must go in the enqueue request), then
329 * calls this once the API returns the job_id.
330 */
331 public static function store_webhook_secret(string $job_id, string $secret): void
332 {
333 $job_id = trim($job_id);
334 $secret = trim($secret);
335 if ($job_id === '' || $secret === '') {
336 return;
337 }
338 set_transient(self::webhook_secret_key($job_id), $secret, self::WEBHOOK_SECRET_TTL);
339 }
340
341 /** Look up the secret for a job_id (empty string if unknown/expired). */
342 public static function get_webhook_secret(string $job_id): string
343 {
344 $job_id = trim($job_id);
345 if ($job_id === '') {
346 return '';
347 }
348 $v = get_transient(self::webhook_secret_key($job_id));
349 return is_string($v) ? $v : '';
350 }
351
352 /** Drop a job's secret once the webhook has been consumed. */
353 public static function clear_webhook_secret(string $job_id): void
354 {
355 $job_id = trim($job_id);
356 if ($job_id !== '') {
357 delete_transient(self::webhook_secret_key($job_id));
358 }
359 }
360
361 /**
362 * Mark a place's background fetch job state (status + optional run id /
363 * message / progress count). Used by the Pro batch-fetch service + cron.
364 */
365 public static function set_job(string $place_id, string $status, array $extra = []): void
366 {
367 $place_id = trim($place_id);
368 if ($place_id === '') {
369 return;
370 }
371 self::ensure_table();
372 if (!self::exists($place_id)) {
373 self::add($place_id, (string) ($extra['place_name'] ?? ''));
374 }
375 $data = ['fetch_status' => sanitize_key($status)];
376 if (array_key_exists('run_id', $extra)) $data['fetch_run_id'] = $extra['run_id'] !== null ? sanitize_text_field((string) $extra['run_id']) : null;
377 if (array_key_exists('message', $extra)) $data['fetch_message'] = $extra['message'] !== null ? sanitize_text_field((string) $extra['message']) : null;
378 if (array_key_exists('so_far', $extra)) $data['fetched_so_far'] = max(0, (int) $extra['so_far']);
379 global $wpdb;
380 $wpdb->update(self::table(), $data, ['place_id' => $place_id]);
381 }
382
383 /**
384 * Append a batch of reviews to a place's stored set (deduped by the caller),
385 * bumping review_count + fetched_so_far. For incremental background fetch.
386 * Pass $final=true with meta/source to finalize the job.
387 */
388 public static function append_reviews(string $place_id, array $batch, array $meta = [], string $source = 'apify', bool $final = false): void
389 {
390 $place_id = trim($place_id);
391 if ($place_id === '' || !self::exists($place_id)) {
392 return;
393 }
394 $row = self::get($place_id);
395 $current = is_array($row['reviews']) ? $row['reviews'] : [];
396 $merged = self::dedupe(array_merge($current, array_values($batch)));
397
398 // TIER-AWARE HARD CAP on the stored set. The worker overshoots its soft
399 // `max` target (it scrolls in ~10-card batches), and the merge unions
400 // across fetches — so without a clamp HERE a FREE place ends up storing
401 // more than the free cap, and any place could exceed 1000. Clamp to the
402 // effective tier cap: 10 for free, 1000 for Pro.
403 // Keep the newest (tail = most recently appended).
404 $is_pro = class_exists('\\EmbedPress\\Includes\\Classes\\Helper')
405 && \EmbedPress\Includes\Classes\Helper::is_pro_active();
406 $free_cap = (int) apply_filters('embedpress/google_reviews/free_max_reviews', 10);
407 $pro_cap = (int) apply_filters('embedpress/google_reviews/max_stored_reviews', 1000);
408 $cap = $is_pro ? $pro_cap : $free_cap;
409 if (count($merged) > $cap) {
410 $merged = array_slice($merged, -$cap);
411 }
412
413 global $wpdb;
414 $data = [
415 'reviews' => wp_json_encode($merged),
416 'review_count' => count($merged),
417 'fetched_so_far' => count($merged),
418 ];
419 if (!empty($meta)) {
420 // Protect search-seeded display fields (address/total/rating) — the
421 // batched fetch supplies reviews, not the place's own info.
422 $existing_meta = (is_array($row) && is_array($row['meta'] ?? null)) ? $row['meta'] : [];
423 $meta = self::merge_meta($meta, $existing_meta);
424 $data['meta'] = wp_json_encode($meta);
425 if (!empty($meta['name'])) {
426 $data['place_name'] = sanitize_text_field((string) $meta['name']);
427 }
428 }
429 if ($final) {
430 $data['source'] = sanitize_key($source);
431 $data['last_fetched_at'] = current_time('mysql');
432 $data['fetch_status'] = self::STATUS_DONE;
433 $data['fetch_run_id'] = null;
434 }
435 $wpdb->update(self::table(), $data, ['place_id' => $place_id]);
436 }
437
438 /**
439 * Clear a place's stored reviews (used when a fresh background fetch starts,
440 * so a refresh REPLACES the set instead of stacking duplicates on top).
441 */
442 public static function reset_reviews(string $place_id): void
443 {
444 $place_id = trim($place_id);
445 if ($place_id === '' || !self::exists($place_id)) {
446 return;
447 }
448 global $wpdb;
449 $wpdb->update(self::table(), [
450 'reviews' => wp_json_encode([]),
451 'review_count' => 0,
452 'fetched_so_far' => 0,
453 ], ['place_id' => $place_id]);
454 }
455
456 /**
457 * Compute a stable per-review identity hash. Used to detect duplicates and
458 * to early-stop incremental refetches (the first already-seen fingerprint
459 * = we've caught up with Google).
460 *
461 * Inputs are deliberately stable across edits to the surrounding metadata
462 * but DO change if the user edits their review text — that's the right
463 * behaviour for "new review detection" but means an edited review looks
464 * new (a periodic full refresh reconciles edits/deletes).
465 */
466 public static function fingerprint_of(array $review): string
467 {
468 return md5(
469 ($review['author_name'] ?? '')
470 . '|' . ($review['time'] ?? '')
471 . '|' . substr((string) ($review['text'] ?? ''), 0, 80)
472 );
473 }
474
475 /** Dedupe a review list by author + time + text fingerprint. */
476 private static function dedupe(array $reviews): array
477 {
478 $seen = [];
479 $out = [];
480 foreach ($reviews as $r) {
481 if (!is_array($r)) {
482 continue;
483 }
484 $key = self::fingerprint_of($r);
485 if (isset($seen[$key])) {
486 continue;
487 }
488 $seen[$key] = true;
489 $out[] = $r;
490 }
491 return $out;
492 }
493
494 /**
495 * Return the set of fingerprints currently stored for a place. Used by the
496 * incremental refetch to detect "we've caught up" — the moment a batch
497 * contains a fingerprint already in this set, everything after it is
498 * already in our DB (Apify returns newest-first).
499 *
500 * @return array<string, true> map of fingerprint → true (cheap set lookup)
501 */
502 public static function fingerprints_for(string $place_id): array
503 {
504 $row = self::get($place_id);
505 $stored = ($row && is_array($row['reviews'] ?? null)) ? $row['reviews'] : [];
506 $set = [];
507 foreach ($stored as $r) {
508 if (is_array($r)) {
509 $set[self::fingerprint_of($r)] = true;
510 }
511 }
512 return $set;
513 }
514
515 public static function remove(string $place_id): bool
516 {
517 $place_id = trim($place_id);
518 if ($place_id === '') {
519 return false;
520 }
521 self::ensure_table();
522 global $wpdb;
523 return (bool) $wpdb->delete(self::table(), ['place_id' => $place_id]);
524 }
525
526 /**
527 * All saved places, newest first. Each row hydrated (reviews/meta decoded).
528 * For the settings manager list.
529 */
530 public static function all(): array
531 {
532 self::ensure_table();
533 global $wpdb;
534 $rows = $wpdb->get_results("SELECT * FROM " . self::table() . " ORDER BY created_at DESC", ARRAY_A);
535 return is_array($rows) ? array_map([self::class, 'hydrate'], $rows) : [];
536 }
537
538 /** Decode JSON columns into arrays. */
539 private static function hydrate(array $row): array
540 {
541 $row['meta'] = isset($row['meta']) ? (json_decode($row['meta'], true) ?: []) : [];
542 $row['reviews'] = isset($row['reviews']) ? (json_decode($row['reviews'], true) ?: []) : [];
543 $row['review_count'] = (int) ($row['review_count'] ?? 0);
544 return $row;
545 }
546
547 /** Drop the table (uninstall). */
548 public static function drop_table(): void
549 {
550 global $wpdb;
551 $wpdb->query("DROP TABLE IF EXISTS " . self::table());
552 delete_option(self::DB_VERSION_OPT);
553 }
554 }
555