PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.11.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / Feed.php
reviews-feed / class / Common Last commit date
Admin 1 week ago Builder 1 week ago Customizer 1 week ago Exceptions 1 week ago Helpers 1 week ago Integrations 1 week ago Migrations 1 week ago ReviewAlerts 1 week ago Services 1 week ago Settings 1 week ago Support 1 week ago Traits 1 week ago UsageTracking 1 week ago Utils 1 week ago AuthorizationStatusCheck.php 1 week ago BusinessDataCache.php 1 week ago Clear_Cache.php 1 week ago Container.php 1 week ago DisplayElements.php 1 week ago Email_Notification.php 1 week ago Error_Reporter.php 1 week ago Feed.php 1 week ago FeedCache.php 1 week ago FeedCacheUpdater.php 1 week ago FeedDisplay.php 1 week ago Feed_Locator.php 1 week ago Parser.php 1 week ago PostAggregator.php 1 week ago RemoteRequest.php 1 week ago SBR_Education.php 1 week ago SBR_Schema_Service.php 1 week ago SBR_Settings.php 1 week ago ServiceContainer.php 1 week ago SinglePostCache.php 1 week ago TemplateRenderer.php 1 week ago Tooltip_Wizard.php 1 week ago Util.php 1 week ago
Feed.php
1394 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 // SMASH-1785 — rebuild a localized avatar whose file has gone missing
702 // (Clear Local Images, a migration, host cleanup). Only brand-new
703 // reviews were ever resized, so a cleared avatar stayed dead forever.
704 // This is the fetch path, so the work is bounded by the refresh
705 // cadence, not per page view.
706 if (
707 Util::should_store_local_images()
708 && $single_post_cache->localized_avatar_missing()
709 ) {
710 $single_post_cache->resize_avatar(150);
711 }
712 $single_post_cache->update_single();
713 }
714 }
715 }
716
717
718
719 /**
720 * Push a source's stored `info` into the header results so the source is
721 * still counted when its fresh remote fetch is skipped (API key limit or
722 * free-tier per-provider call cap). Without this, a rate-limited source
723 * vanishes from the multi-source header total — the front-end then
724 * under-reports the combined review count versus the Feed Builder preview,
725 * which always aggregates every source's stored info (SMASH-1583 parity).
726 *
727 * No-op for review requests and for sources with no stored info.
728 *
729 * @param array $data Results accumulator (by reference).
730 * @param mixed $request The hydrated source request.
731 * @param string $type 'sources' or 'reviews'.
732 * @return void
733 */
734 private function push_stored_source_info(&$data, $request, $type)
735 {
736 if ($type !== 'sources' || empty($request['info'])) {
737 return;
738 }
739 $info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
740 if (!empty($info) && is_array($info)) {
741 $data[] = ['info' => $info];
742 }
743 }
744
745 public function api_request($requests_needed, $type = 'reviews')
746 {
747 $data = array();
748
749 foreach ($requests_needed as $request) {
750 // Handle collections separately
751 if ($request['provider'] === 'collection') {
752 if ($type === 'sources') {
753 $collection = SBR_Sources::update_collection_ratings($request['account_id']);
754 $info = isset($collection['info']) ? json_decode($collection['info'], true) : [];
755 $data[] = [
756 'info' => $info
757 ];
758 }
759 continue;
760 }
761
762 // Apply Trustpilot-specific settings
763 if ($type === 'reviews' && $request['provider'] === 'trustpilot') {
764 $request['language'] = !empty($this->settings['trustpilotLanguage'])
765 ? $this->settings['trustpilotLanguage']
766 : 'default';
767 $request['starsFilter'] = !empty($this->settings['includedStarFilters'])
768 ? implode(',', $this->settings['includedStarFilters'])
769 : '';
770 }
771
772 // Skip if API limit reached for this provider. For header (sources)
773 // requests still count the source via its stored info so a
774 // rate-limited source isn't dropped from the multi-source header
775 // count — the admin preview always aggregates every source, so the
776 // front-end must too (SMASH-1583 front-end parity).
777 if (SBR_Feed_Saver_Manager::check_api_limit($request['provider'])) {
778 $this->push_stored_source_info($data, $request, $type);
779 continue;
780 }
781
782 // Skip if provider call limit reached — same stored-info fallback,
783 // otherwise a free-tier per-provider call cap silently removes the
784 // source from the header total (SMASH-1583).
785 if (SBR_Feed_Saver_Manager::limit_provider_api_calls($request['provider'], $request['account_id'])) {
786 $this->push_stored_source_info($data, $request, $type);
787 continue;
788 }
789
790 // Apply language settings if applicable
791 if (in_array($request['provider'], $this->providers_languages)) {
792 $request['language'] = Util::get_api_call_language($this->settings);
793 }
794
795 // Fetch data from the appropriate provider
796 $new_data = null;
797 if ($request['provider'] === 'facebook') {
798 // Check if Pro version is active (Facebook provider is Pro-only)
799 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\Facebook')) {
800 $new_data = [
801 'data' => [
802 'error' => __('Facebook sources require Reviews Feed Pro.', 'reviews-feed')
803 ],
804 'message' => __('Please upgrade to Reviews Feed Pro to display Facebook reviews.', 'reviews-feed')
805 ];
806 } else {
807 $new_data = \SmashBalloon\Reviews\Pro\Integrations\Providers\Facebook::get_facebook_info($type, $request);
808 }
809 } elseif ($request['provider'] === 'woocommerce') {
810 // Check if Pro version is active (WooCommerce provider is Pro-only)
811 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\WooCommerce')) {
812 $new_data = [
813 'data' => [
814 'error' => __('WooCommerce sources require Reviews Feed Pro.', 'reviews-feed')
815 ],
816 'message' => __('Please upgrade to Reviews Feed Pro to display WooCommerce reviews.', 'reviews-feed')
817 ];
818 } elseif (!function_exists('wc_get_product')) {
819 // Check if WooCommerce plugin is active
820 $new_data = [
821 'data' => [
822 'error' => __('WooCommerce plugin is not active.', 'reviews-feed')
823 ],
824 'message' => __('Please activate WooCommerce to display reviews from this source.', 'reviews-feed')
825 ];
826 } else {
827 // WooCommerce is a local provider, fetch reviews directly from database
828 $woocommerce = new \SmashBalloon\Reviews\Pro\Integrations\Providers\WooCommerce();
829 $is_multi_product = strpos($request['account_id'], 'wc_multi_') === 0;
830
831 if ($type === 'reviews') {
832 if ($is_multi_product) {
833 // Multi-product source: extract product IDs from info
834 $product_ids = [];
835 if (!empty($request['info']['products']) && is_array($request['info']['products'])) {
836 foreach ($request['info']['products'] as $product_info) {
837 if (!empty($product_info['id'])) {
838 $product_ids[] = absint($product_info['id']);
839 }
840 }
841 }
842
843 if (!empty($product_ids)) {
844 $reviews = $woocommerce->fetch_reviews_multi($product_ids);
845 $normalized_reviews = $woocommerce->normalize_reviews_multi($reviews);
846 $new_data = [
847 'data' => [
848 'reviews' => $normalized_reviews
849 ]
850 ];
851 } else {
852 $new_data = [
853 'data' => [
854 'error' => __('No valid products found in WooCommerce multi-product source.', 'reviews-feed')
855 ],
856 'message' => __('The WooCommerce source has no valid products configured.', 'reviews-feed')
857 ];
858 }
859 } else {
860 // Single product source
861 $product = wc_get_product($request['account_id']);
862 if ($product) {
863 $reviews = $woocommerce->fetch_reviews($request['account_id']);
864 $normalized_reviews = $woocommerce->normalize_reviews($reviews, $product);
865 $new_data = [
866 'data' => [
867 'reviews' => $normalized_reviews
868 ]
869 ];
870 } else {
871 // Product not found or deleted
872 $new_data = [
873 'data' => [
874 'error' => __('WooCommerce product not found or has been deleted.', 'reviews-feed')
875 ],
876 'message' => sprintf(
877 /* translators: %s: product ID */
878 __('The WooCommerce product (ID: %s) no longer exists. Please update or remove this source.', 'reviews-feed'),
879 esc_html($request['account_id'])
880 )
881 ];
882 }
883 }
884 } elseif ($type === 'sources') {
885 if ($is_multi_product) {
886 // Multi-product source: aggregate info from all products
887 $product_ids = [];
888 if (!empty($request['info']['products']) && is_array($request['info']['products'])) {
889 foreach ($request['info']['products'] as $product_info) {
890 if (!empty($product_info['id'])) {
891 $product_ids[] = absint($product_info['id']);
892 }
893 }
894 }
895
896 if (!empty($product_ids)) {
897 $new_data = [
898 'data' => [
899 'info' => $woocommerce->get_multi_source_info($product_ids, $request['account_id'], $request['info'])
900 ]
901 ];
902 }
903 } else {
904 // Single product source
905 $product = wc_get_product($request['account_id']);
906 if ($product) {
907 $new_data = [
908 'data' => [
909 'info' => $woocommerce->get_source_info($product)
910 ]
911 ];
912 }
913 }
914 }
915 }
916 } elseif ($request['provider'] === 'edd') {
917 // Check if Pro version is active (EDD provider is Pro-only)
918 if (!Util::sbr_is_pro() || !class_exists('\SmashBalloon\Reviews\Pro\Integrations\Providers\EDD')) {
919 $new_data = [
920 'data' => [
921 'error' => __('EDD sources require Reviews Feed Pro.', 'reviews-feed')
922 ],
923 'message' => __('Please upgrade to Reviews Feed Pro to display EDD reviews.', 'reviews-feed')
924 ];
925 } else {
926 // EDD is a local provider, fetch reviews directly from database
927 $edd_provider = new \SmashBalloon\Reviews\Pro\Integrations\Providers\EDD();
928
929 // Check if EDD with reviews capability is active
930 if (!$edd_provider->is_edd_active()) {
931 // Provide specific error based on what's missing
932 if ($edd_provider->is_edd_core_only_active()) {
933 // EDD core is active but Reviews extension is missing
934 $new_data = [
935 'data' => [
936 'error' => __('EDD Reviews extension is not active.', 'reviews-feed')
937 ],
938 'message' => __('Please install and activate the EDD Reviews extension to display download reviews.', 'reviews-feed')
939 ];
940 } else {
941 // EDD core is not active
942 $new_data = [
943 'data' => [
944 'error' => __('Easy Digital Downloads plugin is not active.', 'reviews-feed')
945 ],
946 'message' => __('Please activate Easy Digital Downloads to display reviews from this source.', 'reviews-feed')
947 ];
948 }
949 } else {
950 // EDD is fully active - fetch reviews
951 $is_multi_download = strpos($request['account_id'], 'edd_multi_') === 0;
952
953 if ($type === 'reviews') {
954 if ($is_multi_download) {
955 // Multi-download source: extract download IDs from info
956 $download_ids = [];
957 if (!empty($request['info']['downloads']) && is_array($request['info']['downloads'])) {
958 foreach ($request['info']['downloads'] as $download_info) {
959 if (!empty($download_info['id'])) {
960 $download_ids[] = absint($download_info['id']);
961 }
962 }
963 }
964
965 if (!empty($download_ids)) {
966 $reviews = $edd_provider->fetch_reviews_multi($download_ids);
967 $normalized_reviews = $edd_provider->normalize_reviews_multi($reviews);
968 $new_data = [
969 'data' => [
970 'reviews' => $normalized_reviews
971 ]
972 ];
973 } else {
974 $new_data = [
975 'data' => [
976 'error' => __('No valid downloads found in EDD multi-download source.', 'reviews-feed')
977 ],
978 'message' => __('The EDD source has no valid downloads configured.', 'reviews-feed')
979 ];
980 }
981 } else {
982 // Single download source
983 $download = get_post($request['account_id']);
984 if ($download && $download->post_type === 'download') {
985 $reviews = $edd_provider->fetch_reviews($request['account_id']);
986 $normalized_reviews = $edd_provider->normalize_reviews($reviews, $download);
987 $new_data = [
988 'data' => [
989 'reviews' => $normalized_reviews
990 ]
991 ];
992 } else {
993 // Download not found or deleted
994 $new_data = [
995 'data' => [
996 'error' => __('EDD download not found or has been deleted.', 'reviews-feed')
997 ],
998 'message' => sprintf(
999 /* translators: %s: download ID */
1000 __('The EDD download (ID: %s) no longer exists. Please update or remove this source.', 'reviews-feed'),
1001 esc_html($request['account_id'])
1002 )
1003 ];
1004 }
1005 }
1006 } elseif ($type === 'sources') {
1007 if ($is_multi_download) {
1008 // Multi-download source: aggregate info from all downloads
1009 $download_ids = [];
1010 if (!empty($request['info']['downloads']) && is_array($request['info']['downloads'])) {
1011 foreach ($request['info']['downloads'] as $download_info) {
1012 if (!empty($download_info['id'])) {
1013 $download_ids[] = absint($download_info['id']);
1014 }
1015 }
1016 }
1017
1018 if (!empty($download_ids)) {
1019 $new_data = [
1020 'data' => [
1021 'info' => $edd_provider->get_multi_source_info($download_ids, $request['account_id'], $request['info'])
1022 ]
1023 ];
1024 }
1025 } else {
1026 // Single download source
1027 $download = get_post($request['account_id']);
1028 if ($download && $download->post_type === 'download') {
1029 $new_data = [
1030 'data' => [
1031 'info' => $edd_provider->get_source_info($download)
1032 ]
1033 ];
1034 }
1035 }
1036 }
1037 }
1038 }
1039 } else {
1040 $remote_request = new RemoteRequest($request['provider'], $request, $type);
1041 $new_data = $remote_request->fetch();
1042 }
1043
1044 // If no data was returned, fall back to stored source info for header requests
1045 if (!isset($new_data['data'])) {
1046 if ($type === 'sources' && !empty($request['info'])) {
1047 $fallback_info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
1048 if (!empty($fallback_info) && is_array($fallback_info)) {
1049 array_push($data, ['info' => $fallback_info]);
1050 }
1051 }
1052 continue;
1053 }
1054
1055 // Handle errors — for source requests, fall back to stored info
1056 if (! empty($new_data['data']['error'])) {
1057 $used_fallback = false;
1058 if ($type === 'sources' && !empty($request['info'])) {
1059 $fallback_info = is_string($request['info']) ? json_decode($request['info'], true) : $request['info'];
1060 if (!empty($fallback_info) && is_array($fallback_info)) {
1061 array_push($data, ['info' => $fallback_info]);
1062 $used_fallback = true;
1063 }
1064 }
1065 $message = ! empty(( $new_data['message'] )) ? wp_strip_all_tags($new_data['message']) : 'An error has occurred when fetching new reviews';
1066 if (is_array($new_data['data']['error'])) {
1067 $message .= '<br>';
1068 foreach ($new_data['data']['error'] as $key => $value) {
1069 $message .= '<br>' . $key . ': ' . wp_strip_all_tags($value);
1070 }
1071 }
1072 $message .= '<br><br>';
1073 $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']));
1074 $message .= '<br><br>';
1075 $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>'));
1076 // For source requests: always skip the normal data push after error handling
1077 // (error structures lack 'info' key and would break update_header_cache)
1078 // For review requests: preserve original fall-through behavior
1079 if ($type === 'sources') {
1080 continue;
1081 }
1082 }
1083
1084 $new_data = $this->add_source_to_post_set($request, $new_data);
1085 $to_push = $type === 'reviews' ? [
1086 'provider_id' => $request['account_id'],
1087 'data' => $new_data['data']
1088 ] : $new_data['data'];
1089 array_push($data, $to_push);
1090 }
1091
1092 return $data;
1093 }
1094
1095 public function add_source_to_post_set($source, $post_set)
1096 {
1097 // `reviews` must be a real list before we iterate. On an error-shaped
1098 // payload the container can itself be a scalar (e.g. 'reviews' => 'error
1099 // message'); `isset($reviews[0])` alone is fooled by string-offset
1100 // semantics (isset($str[0]) is true), which would make the foreach below
1101 // emit a "foreach() argument must be of type array|object" warning. Guard
1102 // that it's an array first (SMASH-1578 / PR #478 review).
1103 if (! is_array($post_set['data']['reviews'] ?? null) || ! isset($post_set['data']['reviews'][0])) {
1104 return $post_set;
1105 }
1106 foreach ($post_set['data']['reviews'] as $index => $review) {
1107 // Skip non-array entries from a malformed/error-shaped payload
1108 // (SMASH-1578): the write below assigns a 'source' offset, which on a
1109 // scalar (string) entry is a fatal TypeError on PHP 8.0+. This runs
1110 // upstream of cache_single_posts_from_set, so it must guard too.
1111 if (! is_array($review)) {
1112 continue;
1113 }
1114 $post_set['data']['reviews'][ $index ]['source'] = array(
1115 'id' => $source['info']['id'] ?? $source['account_id'] ?? '',
1116 'url' => $source['info']['url'] ?? '',
1117 );
1118 }
1119
1120 return $post_set;
1121 }
1122
1123 public function get_post_set_page($page = 1)
1124 {
1125 if ($this->is_single_manual_review()) {
1126 return [
1127 $this->hydrate_single_manual_review($this->settings['singleManualReviewContent'])
1128 ];
1129 }
1130
1131 $posts = $this->get_posts();
1132 $max = $this->settings['numPostDesktop'];
1133 if ($this->settings['numPostTablet'] > $this->settings['numPostDesktop']) {
1134 $max = $this->settings['numPostTablet'];
1135 }
1136 if ($this->settings['numPostMobile'] > $this->settings['numPostTablet']) {
1137 $max = $this->settings['numPostMobile'];
1138 }
1139
1140 $offset = ($page - 1) * $max;
1141 return is_array($posts) ? array_slice($posts, $offset, $max) : [];
1142 }
1143
1144 public function is_last_page($page)
1145 {
1146 $posts = $this->get_posts();
1147 $posts_counts = count($posts);
1148 $posts_per_page = $this->settings['numPostDesktop'];
1149 if ($this->settings['numPostTablet'] > $this->settings['numPostDesktop']) {
1150 $posts_per_page = $this->settings['numPostTablet'];
1151 }
1152 if ($this->settings['numPostMobile'] > $this->settings['numPostTablet']) {
1153 $posts_per_page = $this->settings['numPostMobile'];
1154 }
1155 $posts_per_page = (int) $posts_per_page;
1156 return $posts_counts <= ($page * $posts_per_page);
1157 }
1158
1159 public function hydrate_sources()
1160 {
1161
1162 if (!is_array($this->settings['sources'])) {
1163 $this->settings['sources'] = explode(',', $this->settings['sources']);
1164 }
1165
1166 $db_sources = SBR_Sources::get_sources_list([
1167 'id' => $this->settings['sources']
1168 ]);
1169
1170 $hydrated_sources = array();
1171 foreach ($this->settings['sources'] as $single_source) {
1172 foreach ($db_sources as $db_source) {
1173 if (
1174 !is_array($single_source)
1175 && !empty($db_source['account_id'])
1176 && (string) $db_source['account_id'] === $single_source
1177 ) {
1178 $final_source = $db_source;
1179 $final_source['business'] = $db_source['account_id'];
1180 if (!empty($final_source['info'])) {
1181 $decoded = json_decode($final_source['info'], true);
1182 // Handle malformed JSON by setting empty array to prevent null access errors
1183 $final_source['info'] = is_array($decoded) ? $decoded : [];
1184 } else {
1185 // Ensure info is always an array even when empty/falsy
1186 $final_source['info'] = [];
1187 }
1188 if ($final_source['provider'] === 'google') {
1189 $final_source['lang'] = $this->settings['apiCallLanguage'];
1190 }
1191 $hydrated_sources[] = $final_source;
1192 }
1193 }
1194 }
1195
1196 $this->settings['sources'] = $hydrated_sources;
1197 }
1198
1199 protected function get_db_lang($provider_id)
1200 {
1201 $settings = $this->get_settings();
1202 if ('google' === $this->provider_for_provider_id($provider_id)) {
1203 return $settings['apiCallLanguage'];
1204 }
1205
1206 return '';
1207 }
1208
1209
1210 protected function provider_for_provider_id($provider_id)
1211 {
1212 foreach ($this->settings['sources'] as $single_source) {
1213 if ($provider_id === $single_source['account_id']) {
1214 return $single_source['provider'];
1215 }
1216 }
1217
1218 return '';
1219 }
1220
1221 public function filter_posts($posts, $settings, $moderatePosts = false)
1222 {
1223
1224 $filtered_posts = [];
1225
1226 $is_star_filters = isset($settings['includedStarFilters']) && sizeof($settings['includedStarFilters']) > 0 ? true : false;
1227 $is_includeword = isset($settings['includeWords']) && !empty($settings['includeWords']) ? true : false;
1228 $is_excludeword = isset($settings['excludeWords']) && !empty($settings['excludeWords']) ? true : false;
1229
1230 $is_sortbydate = isset($settings['sortByDateEnabled']) && !empty($settings['sortByDateEnabled']) && $settings['sortByDateEnabled'] == true ? true : false;
1231 $is_sortbyrating = isset($settings['sortByRatingEnabled']) && !empty($settings['sortByRatingEnabled']) && $settings['sortByRatingEnabled'] == true ? true : false;
1232 $is_randomize = isset($settings['sortRandomEnabled']) && !empty($settings['sortRandomEnabled']) && $settings['sortRandomEnabled'] == true ? true : false;
1233
1234 $is_minchar = isset($settings['filterCharCountMin']) && !empty($settings['filterCharCountMin']) ? true : false;
1235 $is_maxchar = isset($settings['filterCharCountMax']) && !empty($settings['filterCharCountMax']) ? true : false;
1236
1237 $sort_by_date = $settings['sortByDate'];
1238 $sort_by_rating = $settings['sortByRating'];
1239
1240 $includewords = $is_includeword ? explode(',', $settings['includeWords']) : [];
1241 $excludewords = $is_excludeword ? explode(',', $settings['excludeWords']) : [];
1242
1243
1244 foreach ($posts as $post) {
1245 if (!is_null($post)) {
1246 $keep_post = false;
1247 //Work Around for facebook Positive / Negative Reviews
1248 if (!empty($post['provider']['name']) && $post['provider']['name'] === 'facebook') {
1249 if (in_array($post['rating'], [ 'positive', 'negative' ])) {
1250 $post['rating'] = $post['rating'] === 'positive' ? 5 : 1;
1251 }
1252 }
1253
1254 $passes_star_filter = !$is_star_filters || ($is_star_filters && (isset($post['rating']) && in_array($post['rating'], $settings['includedStarFilters']))) ? true : false;
1255 $has_includeword = false;
1256 $has_excludeword = false;
1257
1258 $passes_word_filter = false;
1259 $passes_moderation = true;
1260
1261
1262 if ($is_includeword && !empty($includewords)) {
1263 foreach ($includewords as $includeword) {
1264 if (strpos(strtolower($post['text']), strtolower($includeword)) !== false) {
1265 $has_includeword = true;
1266 }
1267 }
1268 }
1269
1270 if ($is_excludeword && !empty($excludewords)) {
1271 foreach ($excludewords as $excludeword) {
1272 if (strpos(strtolower($post['text']), strtolower($excludeword)) !== false) {
1273 $has_excludeword = true;
1274 }
1275 }
1276 }
1277
1278 if (!empty($excludewords) && !empty($includewords)) {
1279 $passes_word_filter = $has_includeword && !$has_excludeword;
1280 } elseif (!empty($includewords)) {
1281 $passes_word_filter = $has_includeword;
1282 } else {
1283 $passes_word_filter = !$has_excludeword;
1284 }
1285
1286
1287 if ($moderatePosts === true && isset($settings['moderationEnabled']) && $settings['moderationEnabled'] === true) {
1288 $moderation_ids = isset($settings['moderationType']) && $settings['moderationType'] === 'allow' ? $settings['moderationAllowList'] : $settings['moderationBlockList'];
1289 if ($settings['moderationType'] === 'allow') {
1290 $passes_moderation = in_array($post['review_id'], $moderation_ids);
1291 }
1292 if ($settings['moderationType'] === 'block') {
1293 $passes_moderation = !in_array($post['review_id'], $moderation_ids);
1294 }
1295 }
1296
1297 //Max Length and Min Length checking
1298 $text_length = strlen($post['text']);
1299 $passes_minchar_filter = ( !$is_minchar || ( $is_minchar && $text_length >= intval($settings['filterCharCountMin']) ) ) ? true : false;
1300 $passes_maxchar_filter = ( !$is_maxchar || ( $is_maxchar && $text_length <= intval($settings['filterCharCountMax']) ) ) ? true : false;
1301
1302
1303 if ($passes_star_filter === true && $passes_word_filter && $passes_moderation && $passes_minchar_filter && $passes_maxchar_filter) {
1304 $keep_post = true;
1305 }
1306
1307 // $keep_post = apply_filters( 'sbr_passes_filter', $keep_post, $post, $settings );
1308 if ($keep_post) {
1309 $filtered_posts[] = $post;
1310 }
1311 }
1312 }
1313
1314 if (!$is_randomize) {
1315 if ($is_sortbydate && !$is_sortbyrating) {
1316 $filtered_posts = $this->sort_array_bydate($filtered_posts, $sort_by_date);
1317 }
1318
1319 if ($is_sortbyrating && !$is_sortbydate) {
1320 $filtered_posts = $this->sort_array_byrating($filtered_posts, $sort_by_rating);
1321 }
1322
1323 if ($is_sortbyrating && $is_sortbydate) {
1324 $filtered_posts = $this->sort_array_byrating_and_date($filtered_posts, $sort_by_rating, $sort_by_date);
1325 }
1326 }
1327
1328 return $filtered_posts;
1329 }
1330
1331
1332 public function sort_array_bydate($posts, $type = 'latest')
1333 {
1334 usort($posts, function ($a, $b) use ($type) {
1335 return $type == 'latest' ? $b['time'] <=> $a['time'] : $a['time'] <=> $b['time'];
1336 });
1337 return $posts;
1338 }
1339
1340 public function sort_array_byrating($posts, $type = 'lowest')
1341 {
1342 usort($posts, function ($a, $b) use ($type) {
1343 return $type == 'highest' ? $b['rating'] <=> $a['rating'] : $a['rating'] <=> $b['rating'];
1344 });
1345 return $posts;
1346 }
1347
1348 public function sort_array_byrating_and_date($posts, $rating_type = 'lowest', $date_type = 'latest')
1349 {
1350 usort($posts, function ($a, $b) use ($rating_type, $date_type) {
1351 if ($a['rating'] === $b['rating']) {
1352 return $date_type == 'latest' ? $b['time'] <=> $a['time'] : $a['time'] <=> $b['time'];
1353 }
1354 return $rating_type == 'highest' ? $b['rating'] <=> $a['rating'] : $a['rating'] <=> $b['rating'];
1355 });
1356 return $posts;
1357 }
1358
1359 public function get_posts_for_moderation()
1360 {
1361 $settings = $this->get_settings();
1362 $aggregator = new PostAggregator();
1363 // Pass limit from settings (default 150 for backward compatibility)
1364 $limit = isset($settings['numPostDesktop']) ? max(150, (int) $settings['numPostDesktop']) : 150;
1365 $posts = $aggregator->db_post_set($settings['sources'], null, $limit);
1366 $posts = $aggregator->normalize_db_post_set($posts);
1367 $post_set = $this->filter_posts($posts, $settings);
1368 return $post_set;
1369 }
1370
1371 public function hydrate_single_manual_review($review)
1372 {
1373 return [
1374 'review_id' => uniqid(),
1375 'text' => $review['content'],
1376 'rating' => $review['rating'],
1377 'time' => $review['time'],
1378 'reviewer' => [
1379 'name' => $review['name'],
1380 'avatar' => $review['avatar']
1381 ],
1382 'provider' => [
1383 'name' => $review['provider']
1384 ]
1385 ];
1386 }
1387
1388 public function is_init_wpml()
1389 {
1390 return false;
1391 }
1392
1393 }
1394