PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.11.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / Integrations / Analytics / SB_Analytics.php
reviews-feed / class / Common / Integrations / Analytics Last commit date
SB_Analytics.php 1 week ago
SB_Analytics.php
438 lines
1 <?php
2
3 /**
4 * SB_Analytics plugin integration for Reviews.
5 *
6 * Implements the three filter callbacks the sb-analytics dashboard
7 * dispatches when it needs Reviews-side data: feed list, profile
8 * details for a given feed, and top-post hydration by review id.
9 */
10
11 namespace SmashBalloon\Reviews\Common\Integrations\Analytics;
12
13 use SmashBalloon\Reviews\Common\Builder\SBR_Sources;
14 use SmashBalloon\Reviews\Common\Customizer\DB;
15 use SmashBalloon\Reviews\Common\PostAggregator;
16
17 class SB_Analytics
18 {
19 /**
20 * Plugin slug used by sb-analytics to identify this integration.
21 *
22 * @var string
23 */
24 private static $current_plugin = 'reviews';
25
26 /**
27 * Friendly label rendered for the plugin in the analytics UI.
28 *
29 * @var string
30 */
31 private static $current_plugin_label = 'Reviews';
32
33 /**
34 * Register the filter hooks consumed by sb-analytics.
35 */
36 public function register()
37 {
38 add_filter('sb_analytics_filter_feed_list', [$this, 'filter_feed_list'], 10, 2);
39 add_filter('sb_analytics_filter_profile_details', [$this, 'filter_profile_details'], 10, 3);
40 add_filter('sb_analytics_filter_top_posts', [$this, 'filter_top_posts'], 10, 3);
41 }
42
43 /**
44 * Return the list of Reviews feeds shaped for the analytics feed picker.
45 *
46 * @param array $feeds Existing feeds list passed in by sb-analytics.
47 * @param string $plugin_slug The plugin slug being requested.
48 *
49 * @return array
50 */
51 public function filter_feed_list($feeds, $plugin_slug)
52 {
53 if ($plugin_slug !== self::$current_plugin) {
54 return $feeds;
55 }
56
57 return $this->get_all_feeds();
58 }
59
60 /**
61 * Return collapsed feed-level profile details for a single Reviews feed.
62 *
63 * Reviews feeds may carry multiple sources; we surface a single profile
64 * that represents the feed itself, using the first source's avatar/image
65 * when available.
66 *
67 * @param array $profile Existing profile passed in by sb-analytics.
68 * @param int $feed_id Feed id to resolve.
69 * @param string $plugin_slug The plugin slug being requested.
70 *
71 * @return array
72 */
73 public function filter_profile_details($profile, $feed_id, $plugin_slug)
74 {
75 if ($plugin_slug !== self::$current_plugin) {
76 return $profile;
77 }
78
79 return $this->get_feed_profile($feed_id);
80 }
81
82 /**
83 * Hydrate a list of review post ids into the shape the analytics
84 * dashboard renders in its top-posts widget.
85 *
86 * @param array $posts Existing posts passed in by sb-analytics.
87 * @param array $post_ids Review post_id values to hydrate.
88 * @param string $plugin_slug The plugin slug being requested.
89 *
90 * @return array
91 */
92 public function filter_top_posts($posts, $post_ids, $plugin_slug)
93 {
94 if ($plugin_slug !== self::$current_plugin) {
95 return $posts;
96 }
97
98 if (empty($post_ids)) {
99 return [];
100 }
101
102 return $this->get_posts_by_ids($post_ids);
103 }
104
105 /**
106 * Build the analytics-shaped feed list from Reviews' DB.
107 *
108 * @return array
109 */
110 private function get_all_feeds()
111 {
112 $records = [];
113
114 $feeds = $this->get_all_feed_rows();
115
116 if (empty($feeds)) {
117 return $records;
118 }
119
120 foreach ($feeds as $feed) {
121 if (empty($feed['id'])) {
122 continue;
123 }
124
125 $records[] = [
126 'value' => [
127 'feed_id' => (int) $feed['id'],
128 'pluginSlug' => self::$current_plugin,
129 ],
130 'label' => !empty($feed['feed_name']) ? $feed['feed_name'] : '',
131 'plugin' => [
132 'slug' => self::$current_plugin,
133 'label' => self::$current_plugin_label,
134 ],
135 ];
136 }
137
138 return $records;
139 }
140
141 /**
142 * Resolve a single feed's profile, using the first source's image as the
143 * profile avatar when available.
144 *
145 * @param int $feed_id Feed id to resolve.
146 *
147 * @return array
148 */
149 private function get_feed_profile($feed_id)
150 {
151 if (empty($feed_id)) {
152 return [];
153 }
154
155 $db_instance = new DB();
156 $feed_rows = $db_instance->feeds_query(['id' => $feed_id]);
157
158 if (empty($feed_rows) || !is_array($feed_rows)) {
159 return [];
160 }
161
162 $feed = $feed_rows[0];
163
164 $feed_name = !empty($feed['feed_name']) ? $feed['feed_name'] : '';
165 $settings = !empty($feed['settings']) ? json_decode($feed['settings'], true) : [];
166
167 $source_ids_raw = '';
168 if (is_array($settings) && !empty($settings['sources'])) {
169 $source_ids_raw = $settings['sources'];
170 }
171
172 $image_src = '';
173
174 if (!empty($source_ids_raw)) {
175 $sources = SBR_Sources::get_sources_list(['id' => $source_ids_raw]);
176
177 if (!empty($sources) && is_array($sources)) {
178 $first_source = $sources[0];
179 $info = !empty($first_source['info']) ? json_decode($first_source['info'], true) : [];
180
181 if (is_array($info)) {
182 if (!empty($info['avatar'])) {
183 $image_src = $info['avatar'];
184 } elseif (!empty($info['image'])) {
185 $image_src = $info['image'];
186 }
187 }
188 }
189 }
190
191 return [
192 'id' => 'feed_' . $feed_id,
193 'pluginSlug' => self::$current_plugin,
194 'profile' => [
195 'label' => $feed_name,
196 'imageSrc' => $image_src,
197 ],
198 ];
199 }
200
201 /**
202 * Hydrate review posts by id into the analytics top-posts shape.
203 *
204 * @param array $post_ids Review post_id values.
205 *
206 * @return array
207 */
208 private function get_posts_by_ids($post_ids)
209 {
210 $records = [];
211
212 $rows = PostAggregator::get_list_reviews_by_ids($post_ids);
213
214 if (empty($rows) || !is_array($rows)) {
215 return $records;
216 }
217
218 $aggregator = new PostAggregator();
219 $normalized = $aggregator->normalize_db_post_set($rows);
220 $row_index = $this->index_rows_by_post_id($rows);
221 $feed_id_lookup = $this->build_provider_to_feed_lookup($rows);
222
223 foreach ($normalized as $review) {
224 if (empty($review) || !is_array($review)) {
225 continue;
226 }
227
228 $source_id = $review['source']['id'] ?? '';
229 $review_id = $review['review_id'] ?? '';
230 $matched_row = !empty($review_id) && isset($row_index[$review_id]) ? $row_index[$review_id] : null;
231
232 $post_id = '';
233 if (!empty($matched_row['post_id'])) {
234 $post_id = $matched_row['post_id'];
235 } elseif (!empty($review_id)) {
236 $post_id = $review_id;
237 }
238
239 if ($post_id === '') {
240 continue;
241 }
242
243 $image_src = $this->pick_review_image($review);
244 $updated_time_ago = $this->format_review_timestamp($matched_row, $review);
245
246 $feed_id = '';
247 if (!empty($source_id) && isset($feed_id_lookup[$source_id])) {
248 $feed_id = $feed_id_lookup[$source_id];
249 }
250
251 $records[$post_id] = [
252 'plugin' => [
253 'slug' => self::$current_plugin,
254 ],
255 'feed_id' => $feed_id,
256 'feed_post_id' => $post_id,
257 'text' => $review['text'] ?? '',
258 'imageSrc' => $image_src,
259 'updated_time_ago' => $updated_time_ago,
260 'profile' => [
261 'label' => $review['reviewer']['name'] ?? '',
262 'url' => $review['source']['url'] ?? '',
263 'id' => $source_id,
264 ],
265 ];
266 }
267
268 return $records;
269 }
270
271 /**
272 * Resolve the best available image for a normalized review.
273 *
274 * Prefers the first media item (object `url`/`original`, or a bare string URL)
275 * and falls back to the reviewer avatar.
276 *
277 * @param array $review Normalized review.
278 *
279 * @return string Image URL, or '' when none is available.
280 */
281 private function pick_review_image($review)
282 {
283 $image_src = '';
284
285 if (!empty($review['media'][0])) {
286 $first_media = $review['media'][0];
287 if (is_array($first_media)) {
288 $image_src = $first_media['url'] ?? ($first_media['original'] ?? '');
289 } elseif (is_string($first_media)) {
290 $image_src = $first_media;
291 }
292 }
293
294 if (empty($image_src) && !empty($review['reviewer']['avatar'])) {
295 $image_src = $review['reviewer']['avatar'];
296 }
297
298 return $image_src;
299 }
300
301 /**
302 * Build the localized "X ago" string for a review.
303 *
304 * Resolves the timestamp from the raw row first, then the normalized review's
305 * `time_stamp`/`time` fields, then formats it relative to now.
306 *
307 * @param array|null $matched_row Raw db row paired with the review, if any.
308 * @param array $review Normalized review.
309 *
310 * @return string Localized relative time, or '' when no timestamp is available.
311 */
312 private function format_review_timestamp($matched_row, $review)
313 {
314 $time_stamp = '';
315 if (!empty($matched_row['time_stamp'])) {
316 $time_stamp = $matched_row['time_stamp'];
317 } elseif (!empty($review['time_stamp'])) {
318 $time_stamp = $review['time_stamp'];
319 } elseif (!empty($review['time'])) {
320 $time_stamp = is_numeric($review['time']) ? gmdate('Y-m-d H:i:s', (int) $review['time']) : $review['time'];
321 }
322
323 if (empty($time_stamp)) {
324 return '';
325 }
326
327 $ts = strtotime($time_stamp);
328 if (!$ts) {
329 return '';
330 }
331
332 return sprintf(
333 /* translators: %s: human-readable time difference, e.g. "5 minutes" */
334 __('%s ago', 'reviews-feed'),
335 human_time_diff($ts, time())
336 );
337 }
338
339 /**
340 * Build a lookup of normalized review review_id => raw db row for
341 * pulling fields (time_stamp, post_id) that the JSON payload omits.
342 *
343 * @param array $rows Raw rows from PostAggregator::get_list_reviews_by_ids().
344 *
345 * @return array
346 */
347 private function index_rows_by_post_id($rows)
348 {
349 $index = [];
350
351 foreach ($rows as $row) {
352 if (empty($row['json_data'])) {
353 continue;
354 }
355 $decoded = json_decode($row['json_data'], true);
356 if (empty($decoded) || !is_array($decoded)) {
357 continue;
358 }
359 $key = $decoded['review_id'] ?? ($row['post_id'] ?? '');
360 if ($key === '') {
361 continue;
362 }
363 $index[$key] = $row;
364 }
365
366 return $index;
367 }
368
369 /**
370 * Best-effort map of provider_id (source account id) => owning feed id.
371 *
372 * Reviews does not store a feed_id column on posts, so we look at which
373 * feed's settings list each provider_id as a source. Feeds are fetched
374 * once and matched in PHP; falls back to empty when nothing matches.
375 *
376 * @param array $rows Raw posts rows.
377 *
378 * @return array
379 */
380 private function build_provider_to_feed_lookup($rows)
381 {
382 $provider_ids = [];
383 foreach ($rows as $row) {
384 if (!empty($row['provider_id']) && !in_array($row['provider_id'], $provider_ids, true)) {
385 $provider_ids[] = $row['provider_id'];
386 }
387 }
388
389 if (empty($provider_ids)) {
390 return [];
391 }
392
393 // Bulk-fetch all feeds once and map provider_id => feed_id in PHP, instead of one
394 // LIKE scan per provider_id (N+1) which also risked substring false positives
395 // (e.g. provider "5" matching a feed sourcing "15" or "50").
396 $all_feeds = $this->get_all_feed_rows();
397
398 $lookup = [];
399 foreach ($all_feeds as $feed) {
400 $settings = !empty($feed['settings']) ? json_decode($feed['settings'], true) : [];
401 $sources = (is_array($settings) && !empty($settings['sources'])) ? $settings['sources'] : '';
402 $feed_source_ids = is_array($sources) ? $sources : explode(',', $sources);
403 $feed_source_ids = array_map('strval', $feed_source_ids);
404 foreach ($provider_ids as $pid) {
405 if (!isset($lookup[$pid]) && in_array((string) $pid, $feed_source_ids, true)) {
406 $lookup[$pid] = (int) $feed['id'];
407 }
408 }
409 }
410
411 return $lookup;
412 }
413
414 /**
415 * Fetch every Reviews feed row directly from the DB.
416 *
417 * Bypasses DB::get_feeds_list(), which early-returns when $_GET['feed_id'] is
418 * present and only returns the first page (20 rows) of feeds. The analytics
419 * filters run in request contexts where neither assumption is safe, so we read
420 * all feeds with a single unguarded query — mirroring CTF_Db::all_feeds_query()
421 * in the other plugins' analytics integrations. Rows are ordered by id ASC so
422 * the provider lookup keeps the original "lowest feed id wins" behaviour.
423 *
424 * @return array Raw feed rows (id, feed_name, settings).
425 */
426 private function get_all_feed_rows()
427 {
428 global $wpdb;
429
430 $feeds_table = $wpdb->prefix . 'sbr_feeds';
431
432 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- internal table name only, no user input.
433 $rows = $wpdb->get_results("SELECT id, feed_name, settings FROM {$feeds_table} ORDER BY id ASC", ARRAY_A);
434
435 return is_array($rows) ? $rows : [];
436 }
437 }
438