PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.6.7
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.6.7
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 / Feed.php
reviews-feed / class / Common Last commit date
Admin 2 months ago Builder 2 months ago Customizer 2 months ago Exceptions 2 months ago Helpers 2 months ago Integrations 2 months ago Migrations 2 months ago ReviewAlerts 2 months ago Services 2 months ago Settings 2 months ago Support 2 months ago Traits 2 months ago Utils 2 months ago AuthorizationStatusCheck.php 2 months ago BusinessDataCache.php 2 months ago Clear_Cache.php 2 months ago Container.php 2 months ago DisplayElements.php 2 months ago Email_Notification.php 2 months ago Error_Reporter.php 2 months ago Feed.php 2 months ago FeedCache.php 2 months ago FeedCacheUpdater.php 2 months ago FeedDisplay.php 2 months ago Feed_Locator.php 2 months ago Parser.php 2 months ago PostAggregator.php 2 months ago RemoteRequest.php 2 months ago SBR_Education.php 2 months ago SBR_Settings.php 2 months ago ServiceContainer.php 2 months ago SinglePostCache.php 2 months ago TemplateRenderer.php 2 months ago Tooltip_Wizard.php 2 months ago Util.php 2 months ago
Feed.php
1370 lines
1 <?php
2
3 // phpcs:disable Generic.Metrics.CyclomaticComplexity.MaxExceeded,Generic.Metrics.CyclomaticComplexity.TooHigh
4 // Note: Legacy file with complex feed rendering logic. Refactoring planned.
5
6 /**
7 * Class Feed
8 *
9 * @since 1.0
10 */
11
12 namespace SmashBalloon\Reviews\Common;
13
14 use SmashBalloon\Reviews\Common\Builder\SBR_Feed_Saver_Manager;
15 use SmashBalloon\Reviews\Common\Builder\SBR_Sources;
16 use SmashBalloon\Reviews\Common\Helpers\Data_Encryption;
17
18 class Feed
19 {
20 protected $posts = array();
21
22 protected $header_data = array();
23
24 /**
25 * @var FeedCache
26 */
27 protected $feed_cache;
28
29 protected $statuses = array();
30
31 protected $settings;
32
33 protected $feed_id;
34 private $feed_style;
35
36 private $flag_media_check;
37 private $providers_languages;
38
39 /**
40 * Data_Encryption
41 */
42 private $encryption;
43
44 /**
45 * @var array|string[]
46 */
47 protected $providers_no_media = [];
48
49
50 public function __construct($settings, $feed_id, FeedCache $feed_cache)
51 {
52 $this->feed_cache = $feed_cache;
53 $this->feed_id = $feed_id;
54 // Ensure settings is an array to prevent PHP 8.1+ deprecation warning
55 $this->settings = is_array($settings) ? $settings : [];
56 $this->settings['apiCallLanguage'] = Util::get_api_call_language($this->settings);
57 $this->feed_style = is_array($settings) && isset($settings['feed_style']) ? $settings['feed_style'] : '';
58 $this->statuses = array(
59 'from_cache' => false,
60 'post_found_before_filter' => false,
61 'errors' => array()
62 );
63
64 $this->flag_media_check = false;
65
66 $this->providers_languages = [
67 'facebook',
68 'google'
69 ];
70
71 $this->providers_no_media = sbr_get_no_media_providers();
72 $this->encryption = new Data_Encryption();
73 }
74
75 public function init()
76 {
77 if (empty($this->settings)) {
78 $this->add_error(sprintf(__('No feed with the ID %d found.', 'reviews-feed'), $this->feed_id), sprintf(__('Please go to the %sReviews Feed%s settings page to create a feed.', 'reviews-feed'), '<a href="' . esc_url(admin_url('admin.php?page=sbr')) . '" target="_blank" rel="noopener noreferrer">', '</a>'));
79 return;
80 }
81 if (empty($this->settings['sources']) && ! $this->is_single_manual_review()) {
82 $this->add_error(sprintf(__('No sources available for this feed.', 'reviews-feed'), $this->feed_id), sprintf(__('Please go to the %sReviews Feed%s settings page add sources for this feed to use.', 'reviews-feed'), '<a href="' . esc_url(admin_url('admin.php?page=sbr')) . '" target="_blank" rel="noopener noreferrer">', '</a>'));
83 return;
84 }
85 if (! $this->is_single_manual_review()) {
86 $this->hydrate_sources();
87 }
88 }
89
90 public function get_settings()
91 {
92 return $this->settings;
93 }
94
95 public function get_errors()
96 {
97 return $this->statuses['errors'];
98 }
99
100 public function set_errors($errors_array)
101 {
102 $this->statuses['errors'] = $errors_array;
103 }
104
105 public function add_error($message, $instructions)
106 {
107 $this->statuses['errors'][] = array(
108 'message' => $message,
109 'directions' => $instructions
110 );
111 }
112
113 public function get_feed_id()
114 {
115 return $this->feed_id;
116 }
117
118 public function get_feed_style()
119 {
120 return $this->feed_style;
121 }
122
123 public function set_posts($posts)
124 {
125 $this->posts = $posts;
126 }
127
128 public function get_posts()
129 {
130 return $this->posts;
131 }
132
133 public function should_check_media()
134 {
135 return $this->flag_media_check;
136 }
137
138 public function set_header_data($header_data)
139 {
140 $this->header_data = $header_data;
141 }
142
143 public function get_header_data()
144 {
145 return $this->header_data;
146 }
147
148 public function is_single_manual_review()
149 {
150 return isset($this->settings['singleManualReview']) && $this->settings['singleManualReview'] === true;
151 }
152
153 public function get_set_cache()
154 {
155 if (! $this->is_single_manual_review()) {
156 $this->feed_cache->retrieve_and_set();
157
158 if ($this->feed_cache->is_expired()) {
159 $posts = $this->update_posts_cache();
160 $header_data = $this->update_header_cache();
161 } else {
162 $this->statuses['from_cache'] = true;
163 $posts = json_decode($this->feed_cache->get('posts'), true);
164 $header_data = $this->feed_cache->get('header') !== null ? json_decode($this->feed_cache->get('header'), true) : $this->update_header_cache_from_source();
165 $error_cache = $this->feed_cache->get('errors');
166 if (is_string($error_cache)) {
167 $error_cache = json_decode($error_cache, true);
168 }
169 $this->set_errors($error_cache);
170 }
171
172 $posts = PostAggregator::remove_duplicated_posts_list($posts, 'json');
173
174 if (empty($header_data)) {
175 $header_data = $this->update_header_cache_from_source();
176 }
177
178
179 $this->set_posts($posts);
180 $this->set_header_data($header_data);
181 }
182 }
183
184 /**
185 * Acquire a per-feed single-flight refresh lock using `add_option` for
186 * MySQL-level atomicity (UNIQUE constraint on `option_name` makes the
187 * underlying INSERT a CAS — only one concurrent worker wins, the rest
188 * see false). Stores `time()` as the lock value so a crashed worker's
189 * orphaned lock can be detected and re-taken after the TTL elapses.
190 *
191 * @param string $lock_key Unique key per feed_id + cache_type.
192 * @param int $ttl Seconds after which a held lock is considered stale.
193 * @return bool True if the caller now owns the lock.
194 *
195 * @since 2.5.6
196 */
197 private function acquire_refresh_lock(string $lock_key, int $ttl): bool
198 {
199 if (add_option($lock_key, time(), '', 'no')) {
200 return true;
201 }
202 // Option already exists. Check whether the lock is stale.
203 $held_since = (int) get_option($lock_key, 0);
204 if ($held_since > 0 && (time() - $held_since) < $ttl) {
205 return false;
206 }
207 // Stale lock — likely a crashed prior worker. Take it over.
208 update_option($lock_key, time(), false);
209 return true;
210 }
211
212 /**
213 * Release the single-flight refresh lock acquired by acquire_refresh_lock().
214 *
215 * @param string $lock_key
216 *
217 * @since 2.5.6
218 */
219 private function release_refresh_lock(string $lock_key): void
220 {
221 delete_option($lock_key);
222 }
223
224 public function update_posts_cache()
225 {
226 $settings = $this->get_settings();
227
228 if (empty($settings['sources'])) {
229 return array();
230 }
231
232 // Single-flight: if another worker (cron or another visitor render)
233 // is already fetching upstream for this feed, skip the duplicate HTTP
234 // round-trip and return whatever's locally available. The lock holder
235 // will populate the cache and the next render will see warm data.
236 //
237 // TTL is 75s — sits just above the relay's reviews fetch timeout (now 60s
238 // on the RapidAPI Google/Yelp review calls) plus a little relay overhead,
239 // so a slow-but-alive lock holder isn't mistaken for stale and double-
240 // fetched. On lock-held, returns posts_from_db() which reads
241 // `wp_sbr_reviews_posts` (review rows preserved across clear_plugin_cache,
242 // only the images_done flag is reset, so the lock-loser still serves real
243 // review text/ratings).
244 $lock_key = 'sbr_refresh_lock_posts_' . $this->feed_id;
245 if (! $this->acquire_refresh_lock($lock_key, 75)) {
246 return $this->posts_from_db();
247 }
248
249 try {
250 $remote_posts = $this->get_remote_posts($settings);
251
252 foreach ($remote_posts as $provider_remote_posts) {
253 // Only dispatch when the reviews payload is actually a list. An
254 // error-shaped relay response can leave 'reviews' as a scalar,
255 // which would otherwise foreach-warn / fatal downstream (SMASH-1578).
256 if (isset($provider_remote_posts['data']['reviews']) && is_array($provider_remote_posts['data']['reviews'])) {
257 $this->cache_single_posts_from_set($provider_remote_posts['data']['reviews'], $provider_remote_posts['provider_id']);
258 }
259 }
260
261 $posts = $this->posts_from_db();
262 if (empty($posts)) {
263 $no_posts_found = __('No Posts Found.', 'reviews-feed');
264 if ($this->statuses['post_found_before_filter']) {
265 $this->add_error($no_posts_found, sprintf(__('There were no posts that fit your filters. Try modifying the filters set or add more sources with reviews that fit the filter by %sediting your feed%s', 'reviews-feed'), '<a href="' . esc_url(admin_url('admin.php?page=sbr')) . '" target="_blank" rel="noopener noreferrer">', '</a>'));
266 } else {
267 $this->add_error($no_posts_found, sprintf(__('There were no posts found for the sources selected. Make sure reviews are available for this source or change the source by %sediting your feed%s', 'reviews-feed'), '<a href="' . esc_url(admin_url('admin.php?page=sbr')) . '" target="_blank" rel="noopener noreferrer">', '</a>'));
268 }
269 }
270
271 $posts = $this->maybe_encrypt_cached_posts($posts);
272 $this->update_cache($posts);
273
274 return $posts;
275 } finally {
276 $this->release_refresh_lock($lock_key);
277 }
278 }
279
280
281 /**
282 * Used to filter Posts and check Facebook that should be encrypted
283 *
284 * @param $posts posts list
285 *
286 * @return array
287 *
288 */
289 public function maybe_encrypt_cached_posts($posts)
290 {
291 foreach ($posts as $key => $s_post) {
292 if (isset($s_post['provider']['name']) && $s_post['provider']['name'] === 'facebook') {
293 $posts[$key] = $this->encryption->maybe_encrypt(wp_json_encode($s_post));
294 }
295 }
296 return $posts;
297 }
298
299 public function posts_from_db()
300 {
301 $settings = $this->get_settings();
302 $aggregator = new PostAggregator();
303 // Pass limit from settings (default 150 for backward compatibility)
304 $limit = isset($settings['numPostDesktop']) ? max(150, (int) $settings['numPostDesktop']) : 150;
305 $posts = $aggregator->db_post_set($settings['sources'], $this->settings['apiCallLanguage'], $limit);
306 $posts = $aggregator->normalize_db_post_set($posts);
307 if ($aggregator->missing_media_found()) {
308 $this->flag_media_check = true;
309 }
310
311 $aggregator->update_last_requested($settings['sources']);
312
313 if (! empty($posts)) {
314 $this->statuses['post_found_before_filter'] = true;
315 }
316
317 return $this->filter_posts($posts, $settings, true);
318 }
319
320 public function update_cache($posts)
321 {
322 $this->feed_cache->update_or_insert('posts', json_encode($posts));
323 $this->feed_cache->clear('errors');
324 $this->feed_cache->update_or_insert('errors', json_encode($this->get_errors()));
325 }
326
327 public function update_header_cache()
328 {
329 $settings = $this->get_settings();
330 if (empty($settings['sources'])) {
331 return array();
332 }
333
334 // Single-flight: see update_posts_cache() for rationale (75s TTL).
335 $lock_key = 'sbr_refresh_lock_header_' . $this->feed_id;
336 if (! $this->acquire_refresh_lock($lock_key, 75)) {
337 return $this->update_header_cache_from_source();
338 }
339
340 try {
341 $remote_header_data = $this->get_remote_header_data($settings);
342
343 // SMASH-1412: same dedup'd feed aggregate as update_header_cache_from_source().
344 // Both paths persist the header cache, so both must stamp the aggregate.
345 $feed_aggregate = $this->compute_feed_level_aggregate($settings['sources']);
346 if ($feed_aggregate !== null && !empty($remote_header_data)) {
347 foreach ($remote_header_data as $key => $entry) {
348 $remote_header_data[$key]['info']['feed_total_review_count'] = $feed_aggregate['count'];
349 $remote_header_data[$key]['info']['feed_average_rating'] = $feed_aggregate['rating'];
350 $remote_header_data[$key]['info']['feed_aggregated'] = true;
351 }
352 }
353
354 if (!empty($remote_header_data) && isset($remote_header_data[0]) && isset($remote_header_data[0]['info']) && isset($remote_header_data[0]['info']['id'])) {
355 // Use get_provider_for_source to match the correct provider for the first header entry
356 $first_header_id = $remote_header_data[0]['info']['id'];
357 $first_header_provider = $this->get_provider_for_source($first_header_id, $settings['sources']);
358 $persistent_business_data_cache = new BusinessDataCache();
359 $persistent_business_data_cache->update_data($first_header_provider ?: ($settings['sources'][0]['provider'] ?? ''), $first_header_id, $remote_header_data);
360 $this->feed_cache->update_or_insert('header', json_encode($remote_header_data));
361
362 // Update ALL sources in DB, not just the first one
363 foreach ($remote_header_data as $index => $source_data) {
364 if (empty($source_data['info']['id'])) {
365 continue;
366 }
367 $provider = $this->get_provider_for_source($source_data['info']['id'], $settings['sources']);
368 // Fall back to index-based provider when ID lookup fails (e.g., type mismatch)
369 if (empty($provider) && isset($settings['sources'][$index]['provider'])) {
370 $provider = $settings['sources'][$index]['provider'];
371 }
372 if (empty($provider)) {
373 continue;
374 }
375 $source_to_update = [
376 'id' => $source_data['info']['id'],
377 'provider' => $provider,
378 'last_updated' => date('Y-m-d H:i:s'),
379 'info' => json_encode($source_data['info'])
380 ];
381 SBR_Sources::update($source_to_update);
382 }
383 }
384 return $remote_header_data;
385 } finally {
386 $this->release_refresh_lock($lock_key);
387 }
388 }
389
390 /**
391 * Get the provider name for a source ID from the sources settings array
392 *
393 * @param string $source_id
394 * @param array $sources
395 *
396 * @return string
397 */
398 private function get_provider_for_source($source_id, $sources)
399 {
400 foreach ($sources as $source) {
401 $info = $source['info'] ?? [];
402 if (is_string($info)) {
403 $info = json_decode($info, true) ?: [];
404 }
405 $info_id = $info['id'] ?? $source['account_id'] ?? '';
406 if ($info_id === $source_id || ($source['account_id'] ?? '') === $source_id) {
407 return $source['provider'] ?? '';
408 }
409 }
410 return '';
411 }
412
413 public function update_header_cache_from_source()
414 {
415 $settings = $this->get_settings();
416
417 if (empty($settings['sources'])) {
418 return array();
419 }
420
421 // Decode info field if it's a JSON string
422 foreach ($settings['sources'] as $key => $source) {
423 if (isset($source['info']) && is_string($source['info'])) {
424 $decoded = json_decode($source['info'], true);
425 // Handle malformed JSON by setting empty array to prevent null access errors
426 $settings['sources'][$key]['info'] = is_array($decoded) ? $decoded : [];
427 }
428 }
429
430 // Build per-source header data so Parser can iterate each source correctly
431 $remote_header_data = [];
432 foreach ($settings['sources'] as $s_source) {
433 $source_info = $s_source['info'] ?? [];
434 if (empty($source_info)) {
435 continue;
436 }
437 $remote_header_data[] = [
438 'info' => [
439 'id' => $source_info['id'] ?? $s_source['account_id'] ?? '',
440 'name' => $source_info['name'] ?? $source_info['source_name'] ?? $s_source['name'] ?? 'Unknown',
441 'rating' => $source_info['rating'] ?? $source_info['average_rating'] ?? 0,
442 'total_rating' => $source_info['total_rating'] ?? $source_info['review_count'] ?? 0,
443 'url' => $source_info['url'] ?? ''
444 ]
445 ];
446 }
447
448 // SMASH-1412: per-source counts double-count when two EDD (or Woo) sources
449 // overlap on the same underlying download/product. Compute a dedup'd feed
450 // aggregate here so the customizer reads one correct number instead of
451 // summing per-source. For providers without overlap semantics (Yelp,
452 // Google, Trustpilot, TripAdvisor, WP.org) the helper returns null and
453 // the customizer falls back to summing — which is correct because each
454 // source represents an independent business.
455 $feed_aggregate = $this->compute_feed_level_aggregate($settings['sources']);
456 if ($feed_aggregate !== null && !empty($remote_header_data)) {
457 foreach ($remote_header_data as $key => $entry) {
458 $remote_header_data[$key]['info']['feed_total_review_count'] = $feed_aggregate['count'];
459 $remote_header_data[$key]['info']['feed_average_rating'] = $feed_aggregate['rating'];
460 $remote_header_data[$key]['info']['feed_aggregated'] = true;
461 }
462 }
463
464 if (!empty($remote_header_data)) {
465 $first_source = $remote_header_data[0]['info'] ?? [];
466 $first_source_id = $first_source['id'] ?? '';
467 $first_provider = !empty($first_source_id)
468 ? $this->get_provider_for_source($first_source_id, $settings['sources'])
469 : '';
470 if (empty($first_provider)) {
471 $first_provider = $settings['sources'][0]['provider'] ?? '';
472 }
473 $persistent_business_data_cache = new BusinessDataCache();
474 $persistent_business_data_cache->update_data($first_provider, $first_source_id, $remote_header_data);
475 $this->feed_cache->update_or_insert('header', json_encode($remote_header_data));
476 }
477
478 return $remote_header_data;
479 }
480
481 /**
482 * Compute a deduplicated feed-level review-count + weighted average rating
483 * across all sources in the feed. Groups sources by provider so each
484 * provider's class can dedup its own entity space (EDD = downloads,
485 * Woo = products). Sums totals across provider groups at the end since
486 * different providers represent independent business surfaces.
487 *
488 * Returns null when there's nothing to aggregate (no sources / no
489 * recognised providers / no review data) — the caller treats null as
490 * "let the customizer fall back to its existing per-source sum".
491 *
492 * @since SMASH-1412
493 * @param array $sources Sources array from feed settings
494 * @return array{count:int,rating:float}|null
495 */
496 private function compute_feed_level_aggregate(array $sources)
497 {
498 if (empty($sources)) {
499 return null;
500 }
501
502 $by_provider = [];
503 foreach ($sources as $src) {
504 $provider = $src['provider'] ?? '';
505 if (empty($provider)) {
506 continue;
507 }
508 $by_provider[$provider][] = $src;
509 }
510 if (empty($by_provider)) {
511 return null;
512 }
513
514 $total_count = 0;
515 $weighted_sum = 0.0;
516 $any_dedup = false;
517
518 foreach ($by_provider as $provider => $provider_sources) {
519 $agg = $this->compute_provider_aggregate($provider, $provider_sources);
520 if ($agg === null) {
521 // No dedup available — sum per-source (correct for independent
522 // businesses: Yelp, Google, Trustpilot, TripAdvisor, WP.org).
523 foreach ($provider_sources as $src) {
524 $info = $src['info'] ?? [];
525 if (is_string($info)) {
526 $info = json_decode($info, true) ?: [];
527 }
528 $count = (int) ($info['review_count'] ?? $info['total_rating'] ?? 0);
529 $rating = (float) ($info['rating'] ?? $info['average_rating'] ?? 0);
530 $total_count += $count;
531 $weighted_sum += $rating * $count;
532 }
533 } else {
534 $any_dedup = true;
535 $total_count += $agg['count'];
536 $weighted_sum += $agg['rating'] * $agg['count'];
537 }
538 }
539
540 // Only return an aggregate when at least one provider actually deduped
541 // something. Otherwise let the customizer use its existing per-source
542 // sum so we don't pay the BC cost on non-EDD/Woo feeds.
543 if (! $any_dedup) {
544 return null;
545 }
546
547 return [
548 'count' => $total_count,
549 'rating' => $total_count > 0 ? round($weighted_sum / $total_count, 1) : 0.0,
550 ];
551 }
552
553 /**
554 * Provider-specific deduplicated aggregate. Returns null when the provider
555 * doesn't expose a dedup hook (treated as "use per-source sum" by the
556 * caller). EDD + WooCommerce dedup over the union of download / product
557 * IDs respectively. Other providers (Google / Yelp / Trustpilot /
558 * TripAdvisor / WP.org) return null on purpose because each source is its
559 * own business — summing is mathematically correct there.
560 *
561 * @since SMASH-1412
562 * @param string $provider Provider name (edd, woocommerce, …)
563 * @param array $sources Subset of feed sources matching this provider
564 * @return array{count:int,rating:float}|null
565 */
566 private function compute_provider_aggregate(string $provider, array $sources)
567 {
568 if ($provider === 'edd') {
569 $download_ids = [];
570 foreach ($sources as $src) {
571 $info = $src['info'] ?? [];
572 if (is_string($info)) {
573 $info = json_decode($info, true) ?: [];
574 }
575 $downloads = $info['downloads'] ?? $info['direct_downloads'] ?? [];
576 foreach ($downloads as $d) {
577 if (! empty($d['id'])) {
578 $download_ids[(int) $d['id']] = true;
579 }
580 }
581 }
582 $download_ids = array_keys($download_ids);
583 if (empty($download_ids)) {
584 return null;
585 }
586 $class = '\\SmashBalloon\\Reviews\\Pro\\Integrations\\Providers\\EDD';
587 if (! class_exists($class)) {
588 return null;
589 }
590 $edd = new $class();
591 if (! method_exists($edd, 'get_multi_source_info')) {
592 return null;
593 }
594 $info = $edd->get_multi_source_info($download_ids, 'feed_aggregate', []);
595 return [
596 'count' => (int) ($info['review_count'] ?? 0),
597 'rating' => (float) ($info['average_rating'] ?? $info['rating'] ?? 0),
598 ];
599 }
600
601 if ($provider === 'woocommerce') {
602 $product_ids = [];
603 foreach ($sources as $src) {
604 $info = $src['info'] ?? [];
605 if (is_string($info)) {
606 $info = json_decode($info, true) ?: [];
607 }
608 $products = $info['products'] ?? $info['direct_products'] ?? $info['downloads'] ?? [];
609 foreach ($products as $p) {
610 if (! empty($p['id'])) {
611 $product_ids[(int) $p['id']] = true;
612 }
613 }
614 }
615 $product_ids = array_keys($product_ids);
616 if (empty($product_ids)) {
617 return null;
618 }
619 $class = '\\SmashBalloon\\Reviews\\Pro\\Integrations\\Providers\\WooCommerce';
620 if (! class_exists($class)) {
621 return null;
622 }
623 $woo = new $class();
624 if (! method_exists($woo, 'get_multi_source_info')) {
625 return null;
626 }
627 $info = $woo->get_multi_source_info($product_ids, 'feed_aggregate', []);
628 return [
629 'count' => (int) ($info['review_count'] ?? 0),
630 'rating' => (float) ($info['average_rating'] ?? $info['rating'] ?? 0),
631 ];
632 }
633
634 return null;
635 }
636
637
638 public function get_remote_posts($settings)
639 {
640 if (empty($settings['sources'])) {
641 return array();
642 }
643 return $this->api_request($settings['sources']);
644 }
645
646 public function get_remote_header_data_old($settings)
647 {
648 if (empty($settings['sources'])) {
649 return array();
650 }
651 $needed = array($settings['sources'][0]);
652 return $this->api_request($needed, 'sources');
653 }
654
655 public function get_remote_header_data($settings)
656 {
657 if (empty($settings['sources'])) {
658 return array();
659 }
660 $needed = $settings['sources'];
661 return $this->api_request($needed, 'sources');
662 }
663
664 public function cache_single_posts_from_set($posts, $provider_id)
665 {
666 foreach ($posts as $single_review) {
667 // Skip scalar entries from a malformed upstream payload (SMASH-1578);
668 // downstream caching assumes an associative review array.
669 if (! is_array($single_review)) {
670 continue;
671 }
672 $single_post_cache = new SinglePostCache($single_review);
673 $single_post_cache->set_provider_id($provider_id);
674
675 $single_post_cache->set_lang($this->get_db_lang($provider_id));
676
677 if (Util::sbr_is_pro() && method_exists($single_post_cache, 'check_api_media')) {
678 $single_post_cache->check_api_media();
679 }
680
681 if (! $single_post_cache->db_record_exists()) {
682 $single_post_cache->resize_avatar(150);
683 if (in_array($this->provider_for_provider_id($provider_id), $this->providers_no_media, true)) {
684 $single_post_cache->set_storage_data('images_done', 1);
685 }
686 $single_post_cache->store();
687 } else {
688 $single_post_cache->update_single();
689 }
690 }
691 }
692
693
694
695 /**
696 * Push a source's stored `info` into the header results so the source is
697 * still counted when its fresh remote fetch is skipped (API key limit or
698 * free-tier per-provider call cap). Without this, a rate-limited source
699 * vanishes from the multi-source header total — the front-end then
700 * under-reports the combined review count versus the Feed Builder preview,
701 * which always aggregates every source's stored info (SMASH-1583 parity).
702 *
703 * No-op for review requests and for sources with no stored info.
704 *
705 * @param array $data Results accumulator (by reference).
706 * @param mixed $request The hydrated source request.
707 * @param string $type 'sources' or 'reviews'.
708 * @return void
709 */
710 private function push_stored_source_info(&$data, $request, $type)
711 {
712 if ($type !== 'sources' || empty($request['info'])) {
713 return;
714 }
715 $info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
716 if (!empty($info) && is_array($info)) {
717 $data[] = ['info' => $info];
718 }
719 }
720
721 public function api_request($requests_needed, $type = 'reviews')
722 {
723 $data = array();
724
725 foreach ($requests_needed as $request) {
726 // Handle collections separately
727 if ($request['provider'] === 'collection') {
728 if ($type === 'sources') {
729 $collection = SBR_Sources::update_collection_ratings($request['account_id']);
730 $info = isset($collection['info']) ? json_decode($collection['info'], true) : [];
731 $data[] = [
732 'info' => $info
733 ];
734 }
735 continue;
736 }
737
738 // Apply Trustpilot-specific settings
739 if ($type === 'reviews' && $request['provider'] === 'trustpilot') {
740 $request['language'] = !empty($this->settings['trustpilotLanguage'])
741 ? $this->settings['trustpilotLanguage']
742 : 'default';
743 $request['starsFilter'] = !empty($this->settings['includedStarFilters'])
744 ? implode(',', $this->settings['includedStarFilters'])
745 : '';
746 }
747
748 // Skip if API limit reached for this provider. For header (sources)
749 // requests still count the source via its stored info so a
750 // rate-limited source isn't dropped from the multi-source header
751 // count — the admin preview always aggregates every source, so the
752 // front-end must too (SMASH-1583 front-end parity).
753 if (SBR_Feed_Saver_Manager::check_api_limit($request['provider'])) {
754 $this->push_stored_source_info($data, $request, $type);
755 continue;
756 }
757
758 // Skip if provider call limit reached — same stored-info fallback,
759 // otherwise a free-tier per-provider call cap silently removes the
760 // source from the header total (SMASH-1583).
761 if (SBR_Feed_Saver_Manager::limit_provider_api_calls($request['provider'], $request['account_id'])) {
762 $this->push_stored_source_info($data, $request, $type);
763 continue;
764 }
765
766 // Apply language settings if applicable
767 if (in_array($request['provider'], $this->providers_languages)) {
768 $request['language'] = Util::get_api_call_language($this->settings);
769 }
770
771 // Fetch data from the appropriate provider
772 $new_data = null;
773 if ($request['provider'] === 'facebook') {
774 // Check if Pro version is active (Facebook provider is Pro-only)
775 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\Facebook')) {
776 $new_data = [
777 'data' => [
778 'error' => __('Facebook sources require Reviews Feed Pro.', 'reviews-feed')
779 ],
780 'message' => __('Please upgrade to Reviews Feed Pro to display Facebook reviews.', 'reviews-feed')
781 ];
782 } else {
783 $new_data = \SmashBalloon\Reviews\Pro\Integrations\Providers\Facebook::get_facebook_info($type, $request);
784 }
785 } elseif ($request['provider'] === 'woocommerce') {
786 // Check if Pro version is active (WooCommerce provider is Pro-only)
787 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\WooCommerce')) {
788 $new_data = [
789 'data' => [
790 'error' => __('WooCommerce sources require Reviews Feed Pro.', 'reviews-feed')
791 ],
792 'message' => __('Please upgrade to Reviews Feed Pro to display WooCommerce reviews.', 'reviews-feed')
793 ];
794 } elseif (!function_exists('wc_get_product')) {
795 // Check if WooCommerce plugin is active
796 $new_data = [
797 'data' => [
798 'error' => __('WooCommerce plugin is not active.', 'reviews-feed')
799 ],
800 'message' => __('Please activate WooCommerce to display reviews from this source.', 'reviews-feed')
801 ];
802 } else {
803 // WooCommerce is a local provider, fetch reviews directly from database
804 $woocommerce = new \SmashBalloon\Reviews\Pro\Integrations\Providers\WooCommerce();
805 $is_multi_product = strpos($request['account_id'], 'wc_multi_') === 0;
806
807 if ($type === 'reviews') {
808 if ($is_multi_product) {
809 // Multi-product source: extract product IDs from info
810 $product_ids = [];
811 if (!empty($request['info']['products']) && is_array($request['info']['products'])) {
812 foreach ($request['info']['products'] as $product_info) {
813 if (!empty($product_info['id'])) {
814 $product_ids[] = absint($product_info['id']);
815 }
816 }
817 }
818
819 if (!empty($product_ids)) {
820 $reviews = $woocommerce->fetch_reviews_multi($product_ids);
821 $normalized_reviews = $woocommerce->normalize_reviews_multi($reviews);
822 $new_data = [
823 'data' => [
824 'reviews' => $normalized_reviews
825 ]
826 ];
827 } else {
828 $new_data = [
829 'data' => [
830 'error' => __('No valid products found in WooCommerce multi-product source.', 'reviews-feed')
831 ],
832 'message' => __('The WooCommerce source has no valid products configured.', 'reviews-feed')
833 ];
834 }
835 } else {
836 // Single product source
837 $product = wc_get_product($request['account_id']);
838 if ($product) {
839 $reviews = $woocommerce->fetch_reviews($request['account_id']);
840 $normalized_reviews = $woocommerce->normalize_reviews($reviews, $product);
841 $new_data = [
842 'data' => [
843 'reviews' => $normalized_reviews
844 ]
845 ];
846 } else {
847 // Product not found or deleted
848 $new_data = [
849 'data' => [
850 'error' => __('WooCommerce product not found or has been deleted.', 'reviews-feed')
851 ],
852 'message' => sprintf(
853 /* translators: %s: product ID */
854 __('The WooCommerce product (ID: %s) no longer exists. Please update or remove this source.', 'reviews-feed'),
855 esc_html($request['account_id'])
856 )
857 ];
858 }
859 }
860 } elseif ($type === 'sources') {
861 if ($is_multi_product) {
862 // Multi-product source: aggregate info from all products
863 $product_ids = [];
864 if (!empty($request['info']['products']) && is_array($request['info']['products'])) {
865 foreach ($request['info']['products'] as $product_info) {
866 if (!empty($product_info['id'])) {
867 $product_ids[] = absint($product_info['id']);
868 }
869 }
870 }
871
872 if (!empty($product_ids)) {
873 $new_data = [
874 'data' => [
875 'info' => $woocommerce->get_multi_source_info($product_ids, $request['account_id'], $request['info'])
876 ]
877 ];
878 }
879 } else {
880 // Single product source
881 $product = wc_get_product($request['account_id']);
882 if ($product) {
883 $new_data = [
884 'data' => [
885 'info' => $woocommerce->get_source_info($product)
886 ]
887 ];
888 }
889 }
890 }
891 }
892 } elseif ($request['provider'] === 'edd') {
893 // Check if Pro version is active (EDD provider is Pro-only)
894 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\EDD')) {
895 $new_data = [
896 'data' => [
897 'error' => __('EDD sources require Reviews Feed Pro.', 'reviews-feed')
898 ],
899 'message' => __('Please upgrade to Reviews Feed Pro to display EDD reviews.', 'reviews-feed')
900 ];
901 } else {
902 // EDD is a local provider, fetch reviews directly from database
903 $edd_provider = new \SmashBalloon\Reviews\Pro\Integrations\Providers\EDD();
904
905 // Check if EDD with reviews capability is active
906 if (!$edd_provider->is_edd_active()) {
907 // Provide specific error based on what's missing
908 if ($edd_provider->is_edd_core_only_active()) {
909 // EDD core is active but Reviews extension is missing
910 $new_data = [
911 'data' => [
912 'error' => __('EDD Reviews extension is not active.', 'reviews-feed')
913 ],
914 'message' => __('Please install and activate the EDD Reviews extension to display download reviews.', 'reviews-feed')
915 ];
916 } else {
917 // EDD core is not active
918 $new_data = [
919 'data' => [
920 'error' => __('Easy Digital Downloads plugin is not active.', 'reviews-feed')
921 ],
922 'message' => __('Please activate Easy Digital Downloads to display reviews from this source.', 'reviews-feed')
923 ];
924 }
925 } else {
926 // EDD is fully active - fetch reviews
927 $is_multi_download = strpos($request['account_id'], 'edd_multi_') === 0;
928
929 if ($type === 'reviews') {
930 if ($is_multi_download) {
931 // Multi-download source: extract download IDs from info
932 $download_ids = [];
933 if (!empty($request['info']['downloads']) && is_array($request['info']['downloads'])) {
934 foreach ($request['info']['downloads'] as $download_info) {
935 if (!empty($download_info['id'])) {
936 $download_ids[] = absint($download_info['id']);
937 }
938 }
939 }
940
941 if (!empty($download_ids)) {
942 $reviews = $edd_provider->fetch_reviews_multi($download_ids);
943 $normalized_reviews = $edd_provider->normalize_reviews_multi($reviews);
944 $new_data = [
945 'data' => [
946 'reviews' => $normalized_reviews
947 ]
948 ];
949 } else {
950 $new_data = [
951 'data' => [
952 'error' => __('No valid downloads found in EDD multi-download source.', 'reviews-feed')
953 ],
954 'message' => __('The EDD source has no valid downloads configured.', 'reviews-feed')
955 ];
956 }
957 } else {
958 // Single download source
959 $download = get_post($request['account_id']);
960 if ($download && $download->post_type === 'download') {
961 $reviews = $edd_provider->fetch_reviews($request['account_id']);
962 $normalized_reviews = $edd_provider->normalize_reviews($reviews, $download);
963 $new_data = [
964 'data' => [
965 'reviews' => $normalized_reviews
966 ]
967 ];
968 } else {
969 // Download not found or deleted
970 $new_data = [
971 'data' => [
972 'error' => __('EDD download not found or has been deleted.', 'reviews-feed')
973 ],
974 'message' => sprintf(
975 /* translators: %s: download ID */
976 __('The EDD download (ID: %s) no longer exists. Please update or remove this source.', 'reviews-feed'),
977 esc_html($request['account_id'])
978 )
979 ];
980 }
981 }
982 } elseif ($type === 'sources') {
983 if ($is_multi_download) {
984 // Multi-download source: aggregate info from all downloads
985 $download_ids = [];
986 if (!empty($request['info']['downloads']) && is_array($request['info']['downloads'])) {
987 foreach ($request['info']['downloads'] as $download_info) {
988 if (!empty($download_info['id'])) {
989 $download_ids[] = absint($download_info['id']);
990 }
991 }
992 }
993
994 if (!empty($download_ids)) {
995 $new_data = [
996 'data' => [
997 'info' => $edd_provider->get_multi_source_info($download_ids, $request['account_id'], $request['info'])
998 ]
999 ];
1000 }
1001 } else {
1002 // Single download source
1003 $download = get_post($request['account_id']);
1004 if ($download && $download->post_type === 'download') {
1005 $new_data = [
1006 'data' => [
1007 'info' => $edd_provider->get_source_info($download)
1008 ]
1009 ];
1010 }
1011 }
1012 }
1013 }
1014 }
1015 } else {
1016 $remote_request = new RemoteRequest($request['provider'], $request, $type);
1017 $new_data = $remote_request->fetch();
1018 }
1019
1020 // If no data was returned, fall back to stored source info for header requests
1021 if (!isset($new_data['data'])) {
1022 if ($type === 'sources' && !empty($request['info'])) {
1023 $fallback_info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
1024 if (!empty($fallback_info) && is_array($fallback_info)) {
1025 array_push($data, ['info' => $fallback_info]);
1026 }
1027 }
1028 continue;
1029 }
1030
1031 // Handle errors — for source requests, fall back to stored info
1032 if (! empty($new_data['data']['error'])) {
1033 $used_fallback = false;
1034 if ($type === 'sources' && !empty($request['info'])) {
1035 $fallback_info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
1036 if (!empty($fallback_info) && is_array($fallback_info)) {
1037 array_push($data, ['info' => $fallback_info]);
1038 $used_fallback = true;
1039 }
1040 }
1041 $message = ! empty(( $new_data['message'] )) ? wp_strip_all_tags($new_data['message']) : 'An error has occurred when fetching new reviews';
1042 if (is_array($new_data['data']['error'])) {
1043 $message .= '<br>';
1044 foreach ($new_data['data']['error'] as $key => $value) {
1045 $message .= '<br>' . $key . ': ' . wp_strip_all_tags($value);
1046 }
1047 }
1048 $message .= '<br><br>';
1049 $message .= sprintf(__('This is affecting the source %s for %s. New reviews will not be fetched until this is resolved.', 'reviews-feed'), wp_strip_all_tags($request['name']), wp_strip_all_tags($request['provider']));
1050 $message .= '<br><br>';
1051 $this->add_error($message, sprintf(__('Troubleshoot by visiting %serror message reference page%s.', 'reviews-feed'), '<a href="https://smashballoon.com/doc/reviews-feed-error-message-reference/?reviews&utm_campaign=reviews-pro&utm_source=feed&utm_medium=apierror&utm_content=Error%20Message%20Reference" target="_blank" rel="noopener noreferrer">', '</a>'));
1052 // For source requests: always skip the normal data push after error handling
1053 // (error structures lack 'info' key and would break update_header_cache)
1054 // For review requests: preserve original fall-through behavior
1055 if ($type === 'sources') {
1056 continue;
1057 }
1058 }
1059
1060 $new_data = $this->add_source_to_post_set($request, $new_data);
1061 $to_push = $type === 'reviews' ? [
1062 'provider_id' => $request['account_id'],
1063 'data' => $new_data['data']
1064 ] : $new_data['data'];
1065 array_push($data, $to_push);
1066 }
1067
1068 return $data;
1069 }
1070
1071 public function add_source_to_post_set($source, $post_set)
1072 {
1073 // `reviews` must be a real list before we iterate. On an error-shaped
1074 // payload the container can itself be a scalar (e.g. 'reviews' => 'error
1075 // message'); `isset($reviews[0])` alone is fooled by string-offset
1076 // semantics (isset($str[0]) is true), which would make the foreach below
1077 // emit a "foreach() argument must be of type array|object" warning. Guard
1078 // that it's an array first (SMASH-1578 / PR #478 review).
1079 if (! is_array($post_set['data']['reviews'] ?? null) || ! isset($post_set['data']['reviews'][0])) {
1080 return $post_set;
1081 }
1082 foreach ($post_set['data']['reviews'] as $index => $review) {
1083 // Skip non-array entries from a malformed/error-shaped payload
1084 // (SMASH-1578): the write below assigns a 'source' offset, which on a
1085 // scalar (string) entry is a fatal TypeError on PHP 8.0+. This runs
1086 // upstream of cache_single_posts_from_set, so it must guard too.
1087 if (! is_array($review)) {
1088 continue;
1089 }
1090 $post_set['data']['reviews'][ $index ]['source'] = array(
1091 'id' => $source['info']['id'] ?? $source['account_id'] ?? '',
1092 'url' => $source['info']['url'] ?? '',
1093 );
1094 }
1095
1096 return $post_set;
1097 }
1098
1099 public function get_post_set_page($page = 1)
1100 {
1101 if ($this->is_single_manual_review()) {
1102 return [
1103 $this->hydrate_single_manual_review($this->settings['singleManualReviewContent'])
1104 ];
1105 }
1106
1107 $posts = $this->get_posts();
1108 $max = $this->settings['numPostDesktop'];
1109 if ($this->settings['numPostTablet'] > $this->settings['numPostDesktop']) {
1110 $max = $this->settings['numPostTablet'];
1111 }
1112 if ($this->settings['numPostMobile'] > $this->settings['numPostTablet']) {
1113 $max = $this->settings['numPostMobile'];
1114 }
1115
1116 $offset = ($page - 1) * $max;
1117 return is_array($posts) ? array_slice($posts, $offset, $max) : [];
1118 }
1119
1120 public function is_last_page($page)
1121 {
1122 $posts = $this->get_posts();
1123 $posts_counts = count($posts);
1124 $posts_per_page = $this->settings['numPostDesktop'];
1125 if ($this->settings['numPostTablet'] > $this->settings['numPostDesktop']) {
1126 $posts_per_page = $this->settings['numPostTablet'];
1127 }
1128 if ($this->settings['numPostMobile'] > $this->settings['numPostTablet']) {
1129 $posts_per_page = $this->settings['numPostMobile'];
1130 }
1131 $posts_per_page = (int) $posts_per_page;
1132 return $posts_counts <= ($page * $posts_per_page);
1133 }
1134
1135 public function hydrate_sources()
1136 {
1137
1138 if (!is_array($this->settings['sources'])) {
1139 $this->settings['sources'] = explode(',', $this->settings['sources']);
1140 }
1141
1142 $db_sources = SBR_Sources::get_sources_list([
1143 'id' => $this->settings['sources']
1144 ]);
1145
1146 $hydrated_sources = array();
1147 foreach ($this->settings['sources'] as $single_source) {
1148 foreach ($db_sources as $db_source) {
1149 if (
1150 !is_array($single_source)
1151 && !empty($db_source['account_id'])
1152 && (string) $db_source['account_id'] === $single_source
1153 ) {
1154 $final_source = $db_source;
1155 $final_source['business'] = $db_source['account_id'];
1156 if (!empty($final_source['info'])) {
1157 $decoded = json_decode($final_source['info'], true);
1158 // Handle malformed JSON by setting empty array to prevent null access errors
1159 $final_source['info'] = is_array($decoded) ? $decoded : [];
1160 } else {
1161 // Ensure info is always an array even when empty/falsy
1162 $final_source['info'] = [];
1163 }
1164 if ($final_source['provider'] === 'google') {
1165 $final_source['lang'] = $this->settings['apiCallLanguage'];
1166 }
1167 $hydrated_sources[] = $final_source;
1168 }
1169 }
1170 }
1171
1172 $this->settings['sources'] = $hydrated_sources;
1173 }
1174
1175 protected function get_db_lang($provider_id)
1176 {
1177 $settings = $this->get_settings();
1178 if ('google' === $this->provider_for_provider_id($provider_id)) {
1179 return $settings['apiCallLanguage'];
1180 }
1181
1182 return '';
1183 }
1184
1185
1186 protected function provider_for_provider_id($provider_id)
1187 {
1188 foreach ($this->settings['sources'] as $single_source) {
1189 if ($provider_id === $single_source['account_id']) {
1190 return $single_source['provider'];
1191 }
1192 }
1193
1194 return '';
1195 }
1196
1197 public function filter_posts($posts, $settings, $moderatePosts = false)
1198 {
1199
1200 $filtered_posts = [];
1201
1202 $is_star_filters = isset($settings['includedStarFilters']) && sizeof($settings['includedStarFilters']) > 0 ? true : false;
1203 $is_includeword = isset($settings['includeWords']) && !empty($settings['includeWords']) ? true : false;
1204 $is_excludeword = isset($settings['excludeWords']) && !empty($settings['excludeWords']) ? true : false;
1205
1206 $is_sortbydate = isset($settings['sortByDateEnabled']) && !empty($settings['sortByDateEnabled']) && $settings['sortByDateEnabled'] == true ? true : false;
1207 $is_sortbyrating = isset($settings['sortByRatingEnabled']) && !empty($settings['sortByRatingEnabled']) && $settings['sortByRatingEnabled'] == true ? true : false;
1208 $is_randomize = isset($settings['sortRandomEnabled']) && !empty($settings['sortRandomEnabled']) && $settings['sortRandomEnabled'] == true ? true : false;
1209
1210 $is_minchar = isset($settings['filterCharCountMin']) && !empty($settings['filterCharCountMin']) ? true : false;
1211 $is_maxchar = isset($settings['filterCharCountMax']) && !empty($settings['filterCharCountMax']) ? true : false;
1212
1213 $sort_by_date = $settings['sortByDate'];
1214 $sort_by_rating = $settings['sortByRating'];
1215
1216 $includewords = $is_includeword ? explode(',', $settings['includeWords']) : [];
1217 $excludewords = $is_excludeword ? explode(',', $settings['excludeWords']) : [];
1218
1219
1220 foreach ($posts as $post) {
1221 if (!is_null($post)) {
1222 $keep_post = false;
1223 //Work Around for facebook Positive / Negative Reviews
1224 if (!empty($post['provider']['name']) && $post['provider']['name'] === 'facebook') {
1225 if (in_array($post['rating'], [ 'positive', 'negative' ])) {
1226 $post['rating'] = $post['rating'] === 'positive' ? 5 : 1;
1227 }
1228 }
1229
1230 $passes_star_filter = !$is_star_filters || ($is_star_filters && (isset($post['rating']) && in_array($post['rating'], $settings['includedStarFilters']))) ? true : false;
1231 $has_includeword = false;
1232 $has_excludeword = false;
1233
1234 $passes_word_filter = false;
1235 $passes_moderation = true;
1236
1237
1238 if ($is_includeword && !empty($includewords)) {
1239 foreach ($includewords as $includeword) {
1240 if (strpos(strtolower($post['text']), strtolower($includeword)) !== false) {
1241 $has_includeword = true;
1242 }
1243 }
1244 }
1245
1246 if ($is_excludeword && !empty($excludewords)) {
1247 foreach ($excludewords as $excludeword) {
1248 if (strpos(strtolower($post['text']), strtolower($excludeword)) !== false) {
1249 $has_excludeword = true;
1250 }
1251 }
1252 }
1253
1254 if (!empty($excludewords) && !empty($includewords)) {
1255 $passes_word_filter = $has_includeword && !$has_excludeword;
1256 } elseif (!empty($includewords)) {
1257 $passes_word_filter = $has_includeword;
1258 } else {
1259 $passes_word_filter = !$has_excludeword;
1260 }
1261
1262
1263 if ($moderatePosts === true && isset($settings['moderationEnabled']) && $settings['moderationEnabled'] === true) {
1264 $moderation_ids = isset($settings['moderationType']) && $settings['moderationType'] === 'allow' ? $settings['moderationAllowList'] : $settings['moderationBlockList'];
1265 if ($settings['moderationType'] === 'allow') {
1266 $passes_moderation = in_array($post['review_id'], $moderation_ids);
1267 }
1268 if ($settings['moderationType'] === 'block') {
1269 $passes_moderation = !in_array($post['review_id'], $moderation_ids);
1270 }
1271 }
1272
1273 //Max Length and Min Length checking
1274 $text_length = strlen($post['text']);
1275 $passes_minchar_filter = ( !$is_minchar || ( $is_minchar && $text_length >= intval($settings['filterCharCountMin']) ) ) ? true : false;
1276 $passes_maxchar_filter = ( !$is_maxchar || ( $is_maxchar && $text_length <= intval($settings['filterCharCountMax']) ) ) ? true : false;
1277
1278
1279 if ($passes_star_filter === true && $passes_word_filter && $passes_moderation && $passes_minchar_filter && $passes_maxchar_filter) {
1280 $keep_post = true;
1281 }
1282
1283 // $keep_post = apply_filters( 'sbr_passes_filter', $keep_post, $post, $settings );
1284 if ($keep_post) {
1285 $filtered_posts[] = $post;
1286 }
1287 }
1288 }
1289
1290 if (!$is_randomize) {
1291 if ($is_sortbydate && !$is_sortbyrating) {
1292 $filtered_posts = $this->sort_array_bydate($filtered_posts, $sort_by_date);
1293 }
1294
1295 if ($is_sortbyrating && !$is_sortbydate) {
1296 $filtered_posts = $this->sort_array_byrating($filtered_posts, $sort_by_rating);
1297 }
1298
1299 if ($is_sortbyrating && $is_sortbydate) {
1300 $filtered_posts = $this->sort_array_byrating_and_date($filtered_posts, $sort_by_rating, $sort_by_date);
1301 }
1302 }
1303
1304 return $filtered_posts;
1305 }
1306
1307
1308 public function sort_array_bydate($posts, $type = 'latest')
1309 {
1310 usort($posts, function ($a, $b) use ($type) {
1311 return $type == 'latest' ? $b['time'] <=> $a['time'] : $a['time'] <=> $b['time'];
1312 });
1313 return $posts;
1314 }
1315
1316 public function sort_array_byrating($posts, $type = 'lowest')
1317 {
1318 usort($posts, function ($a, $b) use ($type) {
1319 return $type == 'highest' ? $b['rating'] <=> $a['rating'] : $a['rating'] <=> $b['rating'];
1320 });
1321 return $posts;
1322 }
1323
1324 public function sort_array_byrating_and_date($posts, $rating_type = 'lowest', $date_type = 'latest')
1325 {
1326 usort($posts, function ($a, $b) use ($rating_type, $date_type) {
1327 if ($a['rating'] === $b['rating']) {
1328 return $date_type == 'latest' ? $b['time'] <=> $a['time'] : $a['time'] <=> $b['time'];
1329 }
1330 return $rating_type == 'highest' ? $b['rating'] <=> $a['rating'] : $a['rating'] <=> $b['rating'];
1331 });
1332 return $posts;
1333 }
1334
1335 public function get_posts_for_moderation()
1336 {
1337 $settings = $this->get_settings();
1338 $aggregator = new PostAggregator();
1339 // Pass limit from settings (default 150 for backward compatibility)
1340 $limit = isset($settings['numPostDesktop']) ? max(150, (int) $settings['numPostDesktop']) : 150;
1341 $posts = $aggregator->db_post_set($settings['sources'], null, $limit);
1342 $posts = $aggregator->normalize_db_post_set($posts);
1343 $post_set = $this->filter_posts($posts, $settings);
1344 return $post_set;
1345 }
1346
1347 public function hydrate_single_manual_review($review)
1348 {
1349 return [
1350 'review_id' => uniqid(),
1351 'text' => $review['content'],
1352 'rating' => $review['rating'],
1353 'time' => $review['time'],
1354 'reviewer' => [
1355 'name' => $review['name'],
1356 'avatar' => $review['avatar']
1357 ],
1358 'provider' => [
1359 'name' => $review['provider']
1360 ]
1361 ];
1362 }
1363
1364 public function is_init_wpml()
1365 {
1366 return false;
1367 }
1368
1369 }
1370