PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.0
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.0
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 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