PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.7.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.7.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 / Feed.php
reviews-feed / class / Common Last commit date
Admin 1 month ago Builder 1 month ago Customizer 1 month ago Exceptions 1 month ago Helpers 1 month ago Integrations 1 month ago Migrations 1 month ago ReviewAlerts 1 month ago Services 1 month ago Settings 1 month ago Support 1 month ago Traits 1 month ago Utils 1 month ago AuthorizationStatusCheck.php 1 month ago BusinessDataCache.php 1 month ago Clear_Cache.php 1 month ago Container.php 1 month ago DisplayElements.php 1 month ago Email_Notification.php 1 month ago Error_Reporter.php 1 month ago Feed.php 1 month ago FeedCache.php 1 month ago FeedCacheUpdater.php 1 month ago FeedDisplay.php 1 month ago Feed_Locator.php 1 month ago Parser.php 1 month ago PostAggregator.php 1 month ago RemoteRequest.php 1 month ago SBR_Education.php 1 month ago SBR_Settings.php 1 month ago ServiceContainer.php 1 month ago SinglePostCache.php 1 month ago TemplateRenderer.php 1 month ago Tooltip_Wizard.php 1 month ago Util.php 1 month ago
Feed.php
1383 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 // SMASH-1634: re-open this source's paginated backfill when its upstream
383 // review count grows, so new review batches load without a manual reset.
384 // Pro-only: the bulk backfill lives in the Pro plugin. Guard on
385 // sbr_is_pro() too — the Pro classes share this directory and stay
386 // autoloadable when only the Free plugin is active, so class_exists()
387 // alone would run this in Free (matches the convention below).
388 if (Util::sbr_is_pro() && class_exists('\\SmashBalloon\\Reviews\\Pro\\Services\\BulkUpdate\\Bulk_Reviews_Update')) {
389 \SmashBalloon\Reviews\Pro\Services\BulkUpdate\Bulk_Reviews_Update::maybe_rearm_source(
390 $provider,
391 $source_data['info']['id'],
392 isset($source_data['info']['total_rating']) ? $source_data['info']['total_rating'] : 0
393 );
394 }
395 }
396 }
397 return $remote_header_data;
398 } finally {
399 $this->release_refresh_lock($lock_key);
400 }
401 }
402
403 /**
404 * Get the provider name for a source ID from the sources settings array
405 *
406 * @param string $source_id
407 * @param array $sources
408 *
409 * @return string
410 */
411 private function get_provider_for_source($source_id, $sources)
412 {
413 foreach ($sources as $source) {
414 $info = $source['info'] ?? [];
415 if (is_string($info)) {
416 $info = json_decode($info, true) ?: [];
417 }
418 $info_id = $info['id'] ?? $source['account_id'] ?? '';
419 if ($info_id === $source_id || ($source['account_id'] ?? '') === $source_id) {
420 return $source['provider'] ?? '';
421 }
422 }
423 return '';
424 }
425
426 public function update_header_cache_from_source()
427 {
428 $settings = $this->get_settings();
429
430 if (empty($settings['sources'])) {
431 return array();
432 }
433
434 // Decode info field if it's a JSON string
435 foreach ($settings['sources'] as $key => $source) {
436 if (isset($source['info']) && is_string($source['info'])) {
437 $decoded = json_decode($source['info'], true);
438 // Handle malformed JSON by setting empty array to prevent null access errors
439 $settings['sources'][$key]['info'] = is_array($decoded) ? $decoded : [];
440 }
441 }
442
443 // Build per-source header data so Parser can iterate each source correctly
444 $remote_header_data = [];
445 foreach ($settings['sources'] as $s_source) {
446 $source_info = $s_source['info'] ?? [];
447 if (empty($source_info)) {
448 continue;
449 }
450 $remote_header_data[] = [
451 'info' => [
452 'id' => $source_info['id'] ?? $s_source['account_id'] ?? '',
453 'name' => $source_info['name'] ?? $source_info['source_name'] ?? $s_source['name'] ?? 'Unknown',
454 'rating' => $source_info['rating'] ?? $source_info['average_rating'] ?? 0,
455 'total_rating' => $source_info['total_rating'] ?? $source_info['review_count'] ?? 0,
456 'url' => $source_info['url'] ?? ''
457 ]
458 ];
459 }
460
461 // SMASH-1412: per-source counts double-count when two EDD (or Woo) sources
462 // overlap on the same underlying download/product. Compute a dedup'd feed
463 // aggregate here so the customizer reads one correct number instead of
464 // summing per-source. For providers without overlap semantics (Yelp,
465 // Google, Trustpilot, TripAdvisor, WP.org) the helper returns null and
466 // the customizer falls back to summing — which is correct because each
467 // source represents an independent business.
468 $feed_aggregate = $this->compute_feed_level_aggregate($settings['sources']);
469 if ($feed_aggregate !== null && !empty($remote_header_data)) {
470 foreach ($remote_header_data as $key => $entry) {
471 $remote_header_data[$key]['info']['feed_total_review_count'] = $feed_aggregate['count'];
472 $remote_header_data[$key]['info']['feed_average_rating'] = $feed_aggregate['rating'];
473 $remote_header_data[$key]['info']['feed_aggregated'] = true;
474 }
475 }
476
477 if (!empty($remote_header_data)) {
478 $first_source = $remote_header_data[0]['info'] ?? [];
479 $first_source_id = $first_source['id'] ?? '';
480 $first_provider = !empty($first_source_id)
481 ? $this->get_provider_for_source($first_source_id, $settings['sources'])
482 : '';
483 if (empty($first_provider)) {
484 $first_provider = $settings['sources'][0]['provider'] ?? '';
485 }
486 $persistent_business_data_cache = new BusinessDataCache();
487 $persistent_business_data_cache->update_data($first_provider, $first_source_id, $remote_header_data);
488 $this->feed_cache->update_or_insert('header', json_encode($remote_header_data));
489 }
490
491 return $remote_header_data;
492 }
493
494 /**
495 * Compute a deduplicated feed-level review-count + weighted average rating
496 * across all sources in the feed. Groups sources by provider so each
497 * provider's class can dedup its own entity space (EDD = downloads,
498 * Woo = products). Sums totals across provider groups at the end since
499 * different providers represent independent business surfaces.
500 *
501 * Returns null when there's nothing to aggregate (no sources / no
502 * recognised providers / no review data) — the caller treats null as
503 * "let the customizer fall back to its existing per-source sum".
504 *
505 * @since SMASH-1412
506 * @param array $sources Sources array from feed settings
507 * @return array{count:int,rating:float}|null
508 */
509 private function compute_feed_level_aggregate(array $sources)
510 {
511 if (empty($sources)) {
512 return null;
513 }
514
515 $by_provider = [];
516 foreach ($sources as $src) {
517 $provider = $src['provider'] ?? '';
518 if (empty($provider)) {
519 continue;
520 }
521 $by_provider[$provider][] = $src;
522 }
523 if (empty($by_provider)) {
524 return null;
525 }
526
527 $total_count = 0;
528 $weighted_sum = 0.0;
529 $any_dedup = false;
530
531 foreach ($by_provider as $provider => $provider_sources) {
532 $agg = $this->compute_provider_aggregate($provider, $provider_sources);
533 if ($agg === null) {
534 // No dedup available — sum per-source (correct for independent
535 // businesses: Yelp, Google, Trustpilot, TripAdvisor, WP.org).
536 foreach ($provider_sources as $src) {
537 $info = $src['info'] ?? [];
538 if (is_string($info)) {
539 $info = json_decode($info, true) ?: [];
540 }
541 $count = (int) ($info['review_count'] ?? $info['total_rating'] ?? 0);
542 $rating = (float) ($info['rating'] ?? $info['average_rating'] ?? 0);
543 $total_count += $count;
544 $weighted_sum += $rating * $count;
545 }
546 } else {
547 $any_dedup = true;
548 $total_count += $agg['count'];
549 $weighted_sum += $agg['rating'] * $agg['count'];
550 }
551 }
552
553 // Only return an aggregate when at least one provider actually deduped
554 // something. Otherwise let the customizer use its existing per-source
555 // sum so we don't pay the BC cost on non-EDD/Woo feeds.
556 if (! $any_dedup) {
557 return null;
558 }
559
560 return [
561 'count' => $total_count,
562 'rating' => $total_count > 0 ? round($weighted_sum / $total_count, 1) : 0.0,
563 ];
564 }
565
566 /**
567 * Provider-specific deduplicated aggregate. Returns null when the provider
568 * doesn't expose a dedup hook (treated as "use per-source sum" by the
569 * caller). EDD + WooCommerce dedup over the union of download / product
570 * IDs respectively. Other providers (Google / Yelp / Trustpilot /
571 * TripAdvisor / WP.org) return null on purpose because each source is its
572 * own business — summing is mathematically correct there.
573 *
574 * @since SMASH-1412
575 * @param string $provider Provider name (edd, woocommerce, …)
576 * @param array $sources Subset of feed sources matching this provider
577 * @return array{count:int,rating:float}|null
578 */
579 private function compute_provider_aggregate(string $provider, array $sources)
580 {
581 if ($provider === 'edd') {
582 $download_ids = [];
583 foreach ($sources as $src) {
584 $info = $src['info'] ?? [];
585 if (is_string($info)) {
586 $info = json_decode($info, true) ?: [];
587 }
588 $downloads = $info['downloads'] ?? $info['direct_downloads'] ?? [];
589 foreach ($downloads as $d) {
590 if (! empty($d['id'])) {
591 $download_ids[(int) $d['id']] = true;
592 }
593 }
594 }
595 $download_ids = array_keys($download_ids);
596 if (empty($download_ids)) {
597 return null;
598 }
599 $class = '\\SmashBalloon\\Reviews\\Pro\\Integrations\\Providers\\EDD';
600 if (! class_exists($class)) {
601 return null;
602 }
603 $edd = new $class();
604 if (! method_exists($edd, 'get_multi_source_info')) {
605 return null;
606 }
607 $info = $edd->get_multi_source_info($download_ids, 'feed_aggregate', []);
608 return [
609 'count' => (int) ($info['review_count'] ?? 0),
610 'rating' => (float) ($info['average_rating'] ?? $info['rating'] ?? 0),
611 ];
612 }
613
614 if ($provider === 'woocommerce') {
615 $product_ids = [];
616 foreach ($sources as $src) {
617 $info = $src['info'] ?? [];
618 if (is_string($info)) {
619 $info = json_decode($info, true) ?: [];
620 }
621 $products = $info['products'] ?? $info['direct_products'] ?? $info['downloads'] ?? [];
622 foreach ($products as $p) {
623 if (! empty($p['id'])) {
624 $product_ids[(int) $p['id']] = true;
625 }
626 }
627 }
628 $product_ids = array_keys($product_ids);
629 if (empty($product_ids)) {
630 return null;
631 }
632 $class = '\\SmashBalloon\\Reviews\\Pro\\Integrations\\Providers\\WooCommerce';
633 if (! class_exists($class)) {
634 return null;
635 }
636 $woo = new $class();
637 if (! method_exists($woo, 'get_multi_source_info')) {
638 return null;
639 }
640 $info = $woo->get_multi_source_info($product_ids, 'feed_aggregate', []);
641 return [
642 'count' => (int) ($info['review_count'] ?? 0),
643 'rating' => (float) ($info['average_rating'] ?? $info['rating'] ?? 0),
644 ];
645 }
646
647 return null;
648 }
649
650
651 public function get_remote_posts($settings)
652 {
653 if (empty($settings['sources'])) {
654 return array();
655 }
656 return $this->api_request($settings['sources']);
657 }
658
659 public function get_remote_header_data_old($settings)
660 {
661 if (empty($settings['sources'])) {
662 return array();
663 }
664 $needed = array($settings['sources'][0]);
665 return $this->api_request($needed, 'sources');
666 }
667
668 public function get_remote_header_data($settings)
669 {
670 if (empty($settings['sources'])) {
671 return array();
672 }
673 $needed = $settings['sources'];
674 return $this->api_request($needed, 'sources');
675 }
676
677 public function cache_single_posts_from_set($posts, $provider_id)
678 {
679 foreach ($posts as $single_review) {
680 // Skip scalar entries from a malformed upstream payload (SMASH-1578);
681 // downstream caching assumes an associative review array.
682 if (! is_array($single_review)) {
683 continue;
684 }
685 $single_post_cache = new SinglePostCache($single_review);
686 $single_post_cache->set_provider_id($provider_id);
687
688 $single_post_cache->set_lang($this->get_db_lang($provider_id));
689
690 if (Util::sbr_is_pro() && method_exists($single_post_cache, 'check_api_media')) {
691 $single_post_cache->check_api_media();
692 }
693
694 if (! $single_post_cache->db_record_exists()) {
695 $single_post_cache->resize_avatar(150);
696 if (in_array($this->provider_for_provider_id($provider_id), $this->providers_no_media, true)) {
697 $single_post_cache->set_storage_data('images_done', 1);
698 }
699 $single_post_cache->store();
700 } else {
701 $single_post_cache->update_single();
702 }
703 }
704 }
705
706
707
708 /**
709 * Push a source's stored `info` into the header results so the source is
710 * still counted when its fresh remote fetch is skipped (API key limit or
711 * free-tier per-provider call cap). Without this, a rate-limited source
712 * vanishes from the multi-source header total — the front-end then
713 * under-reports the combined review count versus the Feed Builder preview,
714 * which always aggregates every source's stored info (SMASH-1583 parity).
715 *
716 * No-op for review requests and for sources with no stored info.
717 *
718 * @param array $data Results accumulator (by reference).
719 * @param mixed $request The hydrated source request.
720 * @param string $type 'sources' or 'reviews'.
721 * @return void
722 */
723 private function push_stored_source_info(&$data, $request, $type)
724 {
725 if ($type !== 'sources' || empty($request['info'])) {
726 return;
727 }
728 $info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
729 if (!empty($info) && is_array($info)) {
730 $data[] = ['info' => $info];
731 }
732 }
733
734 public function api_request($requests_needed, $type = 'reviews')
735 {
736 $data = array();
737
738 foreach ($requests_needed as $request) {
739 // Handle collections separately
740 if ($request['provider'] === 'collection') {
741 if ($type === 'sources') {
742 $collection = SBR_Sources::update_collection_ratings($request['account_id']);
743 $info = isset($collection['info']) ? json_decode($collection['info'], true) : [];
744 $data[] = [
745 'info' => $info
746 ];
747 }
748 continue;
749 }
750
751 // Apply Trustpilot-specific settings
752 if ($type === 'reviews' && $request['provider'] === 'trustpilot') {
753 $request['language'] = !empty($this->settings['trustpilotLanguage'])
754 ? $this->settings['trustpilotLanguage']
755 : 'default';
756 $request['starsFilter'] = !empty($this->settings['includedStarFilters'])
757 ? implode(',', $this->settings['includedStarFilters'])
758 : '';
759 }
760
761 // Skip if API limit reached for this provider. For header (sources)
762 // requests still count the source via its stored info so a
763 // rate-limited source isn't dropped from the multi-source header
764 // count — the admin preview always aggregates every source, so the
765 // front-end must too (SMASH-1583 front-end parity).
766 if (SBR_Feed_Saver_Manager::check_api_limit($request['provider'])) {
767 $this->push_stored_source_info($data, $request, $type);
768 continue;
769 }
770
771 // Skip if provider call limit reached — same stored-info fallback,
772 // otherwise a free-tier per-provider call cap silently removes the
773 // source from the header total (SMASH-1583).
774 if (SBR_Feed_Saver_Manager::limit_provider_api_calls($request['provider'], $request['account_id'])) {
775 $this->push_stored_source_info($data, $request, $type);
776 continue;
777 }
778
779 // Apply language settings if applicable
780 if (in_array($request['provider'], $this->providers_languages)) {
781 $request['language'] = Util::get_api_call_language($this->settings);
782 }
783
784 // Fetch data from the appropriate provider
785 $new_data = null;
786 if ($request['provider'] === 'facebook') {
787 // Check if Pro version is active (Facebook provider is Pro-only)
788 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\Facebook')) {
789 $new_data = [
790 'data' => [
791 'error' => __('Facebook sources require Reviews Feed Pro.', 'reviews-feed')
792 ],
793 'message' => __('Please upgrade to Reviews Feed Pro to display Facebook reviews.', 'reviews-feed')
794 ];
795 } else {
796 $new_data = \SmashBalloon\Reviews\Pro\Integrations\Providers\Facebook::get_facebook_info($type, $request);
797 }
798 } elseif ($request['provider'] === 'woocommerce') {
799 // Check if Pro version is active (WooCommerce provider is Pro-only)
800 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\WooCommerce')) {
801 $new_data = [
802 'data' => [
803 'error' => __('WooCommerce sources require Reviews Feed Pro.', 'reviews-feed')
804 ],
805 'message' => __('Please upgrade to Reviews Feed Pro to display WooCommerce reviews.', 'reviews-feed')
806 ];
807 } elseif (!function_exists('wc_get_product')) {
808 // Check if WooCommerce plugin is active
809 $new_data = [
810 'data' => [
811 'error' => __('WooCommerce plugin is not active.', 'reviews-feed')
812 ],
813 'message' => __('Please activate WooCommerce to display reviews from this source.', 'reviews-feed')
814 ];
815 } else {
816 // WooCommerce is a local provider, fetch reviews directly from database
817 $woocommerce = new \SmashBalloon\Reviews\Pro\Integrations\Providers\WooCommerce();
818 $is_multi_product = strpos($request['account_id'], 'wc_multi_') === 0;
819
820 if ($type === 'reviews') {
821 if ($is_multi_product) {
822 // Multi-product source: extract product IDs from info
823 $product_ids = [];
824 if (!empty($request['info']['products']) && is_array($request['info']['products'])) {
825 foreach ($request['info']['products'] as $product_info) {
826 if (!empty($product_info['id'])) {
827 $product_ids[] = absint($product_info['id']);
828 }
829 }
830 }
831
832 if (!empty($product_ids)) {
833 $reviews = $woocommerce->fetch_reviews_multi($product_ids);
834 $normalized_reviews = $woocommerce->normalize_reviews_multi($reviews);
835 $new_data = [
836 'data' => [
837 'reviews' => $normalized_reviews
838 ]
839 ];
840 } else {
841 $new_data = [
842 'data' => [
843 'error' => __('No valid products found in WooCommerce multi-product source.', 'reviews-feed')
844 ],
845 'message' => __('The WooCommerce source has no valid products configured.', 'reviews-feed')
846 ];
847 }
848 } else {
849 // Single product source
850 $product = wc_get_product($request['account_id']);
851 if ($product) {
852 $reviews = $woocommerce->fetch_reviews($request['account_id']);
853 $normalized_reviews = $woocommerce->normalize_reviews($reviews, $product);
854 $new_data = [
855 'data' => [
856 'reviews' => $normalized_reviews
857 ]
858 ];
859 } else {
860 // Product not found or deleted
861 $new_data = [
862 'data' => [
863 'error' => __('WooCommerce product not found or has been deleted.', 'reviews-feed')
864 ],
865 'message' => sprintf(
866 /* translators: %s: product ID */
867 __('The WooCommerce product (ID: %s) no longer exists. Please update or remove this source.', 'reviews-feed'),
868 esc_html($request['account_id'])
869 )
870 ];
871 }
872 }
873 } elseif ($type === 'sources') {
874 if ($is_multi_product) {
875 // Multi-product source: aggregate info from all products
876 $product_ids = [];
877 if (!empty($request['info']['products']) && is_array($request['info']['products'])) {
878 foreach ($request['info']['products'] as $product_info) {
879 if (!empty($product_info['id'])) {
880 $product_ids[] = absint($product_info['id']);
881 }
882 }
883 }
884
885 if (!empty($product_ids)) {
886 $new_data = [
887 'data' => [
888 'info' => $woocommerce->get_multi_source_info($product_ids, $request['account_id'], $request['info'])
889 ]
890 ];
891 }
892 } else {
893 // Single product source
894 $product = wc_get_product($request['account_id']);
895 if ($product) {
896 $new_data = [
897 'data' => [
898 'info' => $woocommerce->get_source_info($product)
899 ]
900 ];
901 }
902 }
903 }
904 }
905 } elseif ($request['provider'] === 'edd') {
906 // Check if Pro version is active (EDD provider is Pro-only)
907 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\EDD')) {
908 $new_data = [
909 'data' => [
910 'error' => __('EDD sources require Reviews Feed Pro.', 'reviews-feed')
911 ],
912 'message' => __('Please upgrade to Reviews Feed Pro to display EDD reviews.', 'reviews-feed')
913 ];
914 } else {
915 // EDD is a local provider, fetch reviews directly from database
916 $edd_provider = new \SmashBalloon\Reviews\Pro\Integrations\Providers\EDD();
917
918 // Check if EDD with reviews capability is active
919 if (!$edd_provider->is_edd_active()) {
920 // Provide specific error based on what's missing
921 if ($edd_provider->is_edd_core_only_active()) {
922 // EDD core is active but Reviews extension is missing
923 $new_data = [
924 'data' => [
925 'error' => __('EDD Reviews extension is not active.', 'reviews-feed')
926 ],
927 'message' => __('Please install and activate the EDD Reviews extension to display download reviews.', 'reviews-feed')
928 ];
929 } else {
930 // EDD core is not active
931 $new_data = [
932 'data' => [
933 'error' => __('Easy Digital Downloads plugin is not active.', 'reviews-feed')
934 ],
935 'message' => __('Please activate Easy Digital Downloads to display reviews from this source.', 'reviews-feed')
936 ];
937 }
938 } else {
939 // EDD is fully active - fetch reviews
940 $is_multi_download = strpos($request['account_id'], 'edd_multi_') === 0;
941
942 if ($type === 'reviews') {
943 if ($is_multi_download) {
944 // Multi-download source: extract download IDs from info
945 $download_ids = [];
946 if (!empty($request['info']['downloads']) && is_array($request['info']['downloads'])) {
947 foreach ($request['info']['downloads'] as $download_info) {
948 if (!empty($download_info['id'])) {
949 $download_ids[] = absint($download_info['id']);
950 }
951 }
952 }
953
954 if (!empty($download_ids)) {
955 $reviews = $edd_provider->fetch_reviews_multi($download_ids);
956 $normalized_reviews = $edd_provider->normalize_reviews_multi($reviews);
957 $new_data = [
958 'data' => [
959 'reviews' => $normalized_reviews
960 ]
961 ];
962 } else {
963 $new_data = [
964 'data' => [
965 'error' => __('No valid downloads found in EDD multi-download source.', 'reviews-feed')
966 ],
967 'message' => __('The EDD source has no valid downloads configured.', 'reviews-feed')
968 ];
969 }
970 } else {
971 // Single download source
972 $download = get_post($request['account_id']);
973 if ($download && $download->post_type === 'download') {
974 $reviews = $edd_provider->fetch_reviews($request['account_id']);
975 $normalized_reviews = $edd_provider->normalize_reviews($reviews, $download);
976 $new_data = [
977 'data' => [
978 'reviews' => $normalized_reviews
979 ]
980 ];
981 } else {
982 // Download not found or deleted
983 $new_data = [
984 'data' => [
985 'error' => __('EDD download not found or has been deleted.', 'reviews-feed')
986 ],
987 'message' => sprintf(
988 /* translators: %s: download ID */
989 __('The EDD download (ID: %s) no longer exists. Please update or remove this source.', 'reviews-feed'),
990 esc_html($request['account_id'])
991 )
992 ];
993 }
994 }
995 } elseif ($type === 'sources') {
996 if ($is_multi_download) {
997 // Multi-download source: aggregate info from all downloads
998 $download_ids = [];
999 if (!empty($request['info']['downloads']) && is_array($request['info']['downloads'])) {
1000 foreach ($request['info']['downloads'] as $download_info) {
1001 if (!empty($download_info['id'])) {
1002 $download_ids[] = absint($download_info['id']);
1003 }
1004 }
1005 }
1006
1007 if (!empty($download_ids)) {
1008 $new_data = [
1009 'data' => [
1010 'info' => $edd_provider->get_multi_source_info($download_ids, $request['account_id'], $request['info'])
1011 ]
1012 ];
1013 }
1014 } else {
1015 // Single download source
1016 $download = get_post($request['account_id']);
1017 if ($download && $download->post_type === 'download') {
1018 $new_data = [
1019 'data' => [
1020 'info' => $edd_provider->get_source_info($download)
1021 ]
1022 ];
1023 }
1024 }
1025 }
1026 }
1027 }
1028 } else {
1029 $remote_request = new RemoteRequest($request['provider'], $request, $type);
1030 $new_data = $remote_request->fetch();
1031 }
1032
1033 // If no data was returned, fall back to stored source info for header requests
1034 if (!isset($new_data['data'])) {
1035 if ($type === 'sources' && !empty($request['info'])) {
1036 $fallback_info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
1037 if (!empty($fallback_info) && is_array($fallback_info)) {
1038 array_push($data, ['info' => $fallback_info]);
1039 }
1040 }
1041 continue;
1042 }
1043
1044 // Handle errors — for source requests, fall back to stored info
1045 if (! empty($new_data['data']['error'])) {
1046 $used_fallback = false;
1047 if ($type === 'sources' && !empty($request['info'])) {
1048 $fallback_info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
1049 if (!empty($fallback_info) && is_array($fallback_info)) {
1050 array_push($data, ['info' => $fallback_info]);
1051 $used_fallback = true;
1052 }
1053 }
1054 $message = ! empty(( $new_data['message'] )) ? wp_strip_all_tags($new_data['message']) : 'An error has occurred when fetching new reviews';
1055 if (is_array($new_data['data']['error'])) {
1056 $message .= '<br>';
1057 foreach ($new_data['data']['error'] as $key => $value) {
1058 $message .= '<br>' . $key . ': ' . wp_strip_all_tags($value);
1059 }
1060 }
1061 $message .= '<br><br>';
1062 $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']));
1063 $message .= '<br><br>';
1064 $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>'));
1065 // For source requests: always skip the normal data push after error handling
1066 // (error structures lack 'info' key and would break update_header_cache)
1067 // For review requests: preserve original fall-through behavior
1068 if ($type === 'sources') {
1069 continue;
1070 }
1071 }
1072
1073 $new_data = $this->add_source_to_post_set($request, $new_data);
1074 $to_push = $type === 'reviews' ? [
1075 'provider_id' => $request['account_id'],
1076 'data' => $new_data['data']
1077 ] : $new_data['data'];
1078 array_push($data, $to_push);
1079 }
1080
1081 return $data;
1082 }
1083
1084 public function add_source_to_post_set($source, $post_set)
1085 {
1086 // `reviews` must be a real list before we iterate. On an error-shaped
1087 // payload the container can itself be a scalar (e.g. 'reviews' => 'error
1088 // message'); `isset($reviews[0])` alone is fooled by string-offset
1089 // semantics (isset($str[0]) is true), which would make the foreach below
1090 // emit a "foreach() argument must be of type array|object" warning. Guard
1091 // that it's an array first (SMASH-1578 / PR #478 review).
1092 if (! is_array($post_set['data']['reviews'] ?? null) || ! isset($post_set['data']['reviews'][0])) {
1093 return $post_set;
1094 }
1095 foreach ($post_set['data']['reviews'] as $index => $review) {
1096 // Skip non-array entries from a malformed/error-shaped payload
1097 // (SMASH-1578): the write below assigns a 'source' offset, which on a
1098 // scalar (string) entry is a fatal TypeError on PHP 8.0+. This runs
1099 // upstream of cache_single_posts_from_set, so it must guard too.
1100 if (! is_array($review)) {
1101 continue;
1102 }
1103 $post_set['data']['reviews'][ $index ]['source'] = array(
1104 'id' => $source['info']['id'] ?? $source['account_id'] ?? '',
1105 'url' => $source['info']['url'] ?? '',
1106 );
1107 }
1108
1109 return $post_set;
1110 }
1111
1112 public function get_post_set_page($page = 1)
1113 {
1114 if ($this->is_single_manual_review()) {
1115 return [
1116 $this->hydrate_single_manual_review($this->settings['singleManualReviewContent'])
1117 ];
1118 }
1119
1120 $posts = $this->get_posts();
1121 $max = $this->settings['numPostDesktop'];
1122 if ($this->settings['numPostTablet'] > $this->settings['numPostDesktop']) {
1123 $max = $this->settings['numPostTablet'];
1124 }
1125 if ($this->settings['numPostMobile'] > $this->settings['numPostTablet']) {
1126 $max = $this->settings['numPostMobile'];
1127 }
1128
1129 $offset = ($page - 1) * $max;
1130 return is_array($posts) ? array_slice($posts, $offset, $max) : [];
1131 }
1132
1133 public function is_last_page($page)
1134 {
1135 $posts = $this->get_posts();
1136 $posts_counts = count($posts);
1137 $posts_per_page = $this->settings['numPostDesktop'];
1138 if ($this->settings['numPostTablet'] > $this->settings['numPostDesktop']) {
1139 $posts_per_page = $this->settings['numPostTablet'];
1140 }
1141 if ($this->settings['numPostMobile'] > $this->settings['numPostTablet']) {
1142 $posts_per_page = $this->settings['numPostMobile'];
1143 }
1144 $posts_per_page = (int) $posts_per_page;
1145 return $posts_counts <= ($page * $posts_per_page);
1146 }
1147
1148 public function hydrate_sources()
1149 {
1150
1151 if (!is_array($this->settings['sources'])) {
1152 $this->settings['sources'] = explode(',', $this->settings['sources']);
1153 }
1154
1155 $db_sources = SBR_Sources::get_sources_list([
1156 'id' => $this->settings['sources']
1157 ]);
1158
1159 $hydrated_sources = array();
1160 foreach ($this->settings['sources'] as $single_source) {
1161 foreach ($db_sources as $db_source) {
1162 if (
1163 !is_array($single_source)
1164 && !empty($db_source['account_id'])
1165 && (string) $db_source['account_id'] === $single_source
1166 ) {
1167 $final_source = $db_source;
1168 $final_source['business'] = $db_source['account_id'];
1169 if (!empty($final_source['info'])) {
1170 $decoded = json_decode($final_source['info'], true);
1171 // Handle malformed JSON by setting empty array to prevent null access errors
1172 $final_source['info'] = is_array($decoded) ? $decoded : [];
1173 } else {
1174 // Ensure info is always an array even when empty/falsy
1175 $final_source['info'] = [];
1176 }
1177 if ($final_source['provider'] === 'google') {
1178 $final_source['lang'] = $this->settings['apiCallLanguage'];
1179 }
1180 $hydrated_sources[] = $final_source;
1181 }
1182 }
1183 }
1184
1185 $this->settings['sources'] = $hydrated_sources;
1186 }
1187
1188 protected function get_db_lang($provider_id)
1189 {
1190 $settings = $this->get_settings();
1191 if ('google' === $this->provider_for_provider_id($provider_id)) {
1192 return $settings['apiCallLanguage'];
1193 }
1194
1195 return '';
1196 }
1197
1198
1199 protected function provider_for_provider_id($provider_id)
1200 {
1201 foreach ($this->settings['sources'] as $single_source) {
1202 if ($provider_id === $single_source['account_id']) {
1203 return $single_source['provider'];
1204 }
1205 }
1206
1207 return '';
1208 }
1209
1210 public function filter_posts($posts, $settings, $moderatePosts = false)
1211 {
1212
1213 $filtered_posts = [];
1214
1215 $is_star_filters = isset($settings['includedStarFilters']) && sizeof($settings['includedStarFilters']) > 0 ? true : false;
1216 $is_includeword = isset($settings['includeWords']) && !empty($settings['includeWords']) ? true : false;
1217 $is_excludeword = isset($settings['excludeWords']) && !empty($settings['excludeWords']) ? true : false;
1218
1219 $is_sortbydate = isset($settings['sortByDateEnabled']) && !empty($settings['sortByDateEnabled']) && $settings['sortByDateEnabled'] == true ? true : false;
1220 $is_sortbyrating = isset($settings['sortByRatingEnabled']) && !empty($settings['sortByRatingEnabled']) && $settings['sortByRatingEnabled'] == true ? true : false;
1221 $is_randomize = isset($settings['sortRandomEnabled']) && !empty($settings['sortRandomEnabled']) && $settings['sortRandomEnabled'] == true ? true : false;
1222
1223 $is_minchar = isset($settings['filterCharCountMin']) && !empty($settings['filterCharCountMin']) ? true : false;
1224 $is_maxchar = isset($settings['filterCharCountMax']) && !empty($settings['filterCharCountMax']) ? true : false;
1225
1226 $sort_by_date = $settings['sortByDate'];
1227 $sort_by_rating = $settings['sortByRating'];
1228
1229 $includewords = $is_includeword ? explode(',', $settings['includeWords']) : [];
1230 $excludewords = $is_excludeword ? explode(',', $settings['excludeWords']) : [];
1231
1232
1233 foreach ($posts as $post) {
1234 if (!is_null($post)) {
1235 $keep_post = false;
1236 //Work Around for facebook Positive / Negative Reviews
1237 if (!empty($post['provider']['name']) && $post['provider']['name'] === 'facebook') {
1238 if (in_array($post['rating'], [ 'positive', 'negative' ])) {
1239 $post['rating'] = $post['rating'] === 'positive' ? 5 : 1;
1240 }
1241 }
1242
1243 $passes_star_filter = !$is_star_filters || ($is_star_filters && (isset($post['rating']) && in_array($post['rating'], $settings['includedStarFilters']))) ? true : false;
1244 $has_includeword = false;
1245 $has_excludeword = false;
1246
1247 $passes_word_filter = false;
1248 $passes_moderation = true;
1249
1250
1251 if ($is_includeword && !empty($includewords)) {
1252 foreach ($includewords as $includeword) {
1253 if (strpos(strtolower($post['text']), strtolower($includeword)) !== false) {
1254 $has_includeword = true;
1255 }
1256 }
1257 }
1258
1259 if ($is_excludeword && !empty($excludewords)) {
1260 foreach ($excludewords as $excludeword) {
1261 if (strpos(strtolower($post['text']), strtolower($excludeword)) !== false) {
1262 $has_excludeword = true;
1263 }
1264 }
1265 }
1266
1267 if (!empty($excludewords) && !empty($includewords)) {
1268 $passes_word_filter = $has_includeword && !$has_excludeword;
1269 } elseif (!empty($includewords)) {
1270 $passes_word_filter = $has_includeword;
1271 } else {
1272 $passes_word_filter = !$has_excludeword;
1273 }
1274
1275
1276 if ($moderatePosts === true && isset($settings['moderationEnabled']) && $settings['moderationEnabled'] === true) {
1277 $moderation_ids = isset($settings['moderationType']) && $settings['moderationType'] === 'allow' ? $settings['moderationAllowList'] : $settings['moderationBlockList'];
1278 if ($settings['moderationType'] === 'allow') {
1279 $passes_moderation = in_array($post['review_id'], $moderation_ids);
1280 }
1281 if ($settings['moderationType'] === 'block') {
1282 $passes_moderation = !in_array($post['review_id'], $moderation_ids);
1283 }
1284 }
1285
1286 //Max Length and Min Length checking
1287 $text_length = strlen($post['text']);
1288 $passes_minchar_filter = ( !$is_minchar || ( $is_minchar && $text_length >= intval($settings['filterCharCountMin']) ) ) ? true : false;
1289 $passes_maxchar_filter = ( !$is_maxchar || ( $is_maxchar && $text_length <= intval($settings['filterCharCountMax']) ) ) ? true : false;
1290
1291
1292 if ($passes_star_filter === true && $passes_word_filter && $passes_moderation && $passes_minchar_filter && $passes_maxchar_filter) {
1293 $keep_post = true;
1294 }
1295
1296 // $keep_post = apply_filters( 'sbr_passes_filter', $keep_post, $post, $settings );
1297 if ($keep_post) {
1298 $filtered_posts[] = $post;
1299 }
1300 }
1301 }
1302
1303 if (!$is_randomize) {
1304 if ($is_sortbydate && !$is_sortbyrating) {
1305 $filtered_posts = $this->sort_array_bydate($filtered_posts, $sort_by_date);
1306 }
1307
1308 if ($is_sortbyrating && !$is_sortbydate) {
1309 $filtered_posts = $this->sort_array_byrating($filtered_posts, $sort_by_rating);
1310 }
1311
1312 if ($is_sortbyrating && $is_sortbydate) {
1313 $filtered_posts = $this->sort_array_byrating_and_date($filtered_posts, $sort_by_rating, $sort_by_date);
1314 }
1315 }
1316
1317 return $filtered_posts;
1318 }
1319
1320
1321 public function sort_array_bydate($posts, $type = 'latest')
1322 {
1323 usort($posts, function ($a, $b) use ($type) {
1324 return $type == 'latest' ? $b['time'] <=> $a['time'] : $a['time'] <=> $b['time'];
1325 });
1326 return $posts;
1327 }
1328
1329 public function sort_array_byrating($posts, $type = 'lowest')
1330 {
1331 usort($posts, function ($a, $b) use ($type) {
1332 return $type == 'highest' ? $b['rating'] <=> $a['rating'] : $a['rating'] <=> $b['rating'];
1333 });
1334 return $posts;
1335 }
1336
1337 public function sort_array_byrating_and_date($posts, $rating_type = 'lowest', $date_type = 'latest')
1338 {
1339 usort($posts, function ($a, $b) use ($rating_type, $date_type) {
1340 if ($a['rating'] === $b['rating']) {
1341 return $date_type == 'latest' ? $b['time'] <=> $a['time'] : $a['time'] <=> $b['time'];
1342 }
1343 return $rating_type == 'highest' ? $b['rating'] <=> $a['rating'] : $a['rating'] <=> $b['rating'];
1344 });
1345 return $posts;
1346 }
1347
1348 public function get_posts_for_moderation()
1349 {
1350 $settings = $this->get_settings();
1351 $aggregator = new PostAggregator();
1352 // Pass limit from settings (default 150 for backward compatibility)
1353 $limit = isset($settings['numPostDesktop']) ? max(150, (int) $settings['numPostDesktop']) : 150;
1354 $posts = $aggregator->db_post_set($settings['sources'], null, $limit);
1355 $posts = $aggregator->normalize_db_post_set($posts);
1356 $post_set = $this->filter_posts($posts, $settings);
1357 return $post_set;
1358 }
1359
1360 public function hydrate_single_manual_review($review)
1361 {
1362 return [
1363 'review_id' => uniqid(),
1364 'text' => $review['content'],
1365 'rating' => $review['rating'],
1366 'time' => $review['time'],
1367 'reviewer' => [
1368 'name' => $review['name'],
1369 'avatar' => $review['avatar']
1370 ],
1371 'provider' => [
1372 'name' => $review['provider']
1373 ]
1374 ];
1375 }
1376
1377 public function is_init_wpml()
1378 {
1379 return false;
1380 }
1381
1382 }
1383