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