PluginProbe
WebberZone Top 10 — Popular Posts / trunk
WebberZone Top 10 — Popular Posts vtrunk
4.5.0 4.4.3 4.4.2 4.4.1 4.4.0 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 trunk 1.0 1.0.1 1.1 1.2 1.3 1.4 1.4.1 1.5 1.5.1 1.5.2 1.5.3 1.6 1.6.1 1.6.2 All 116 releases
top-10 / includes / admin / class-wpp-importer.php

class-wpp-importer.php in WebberZone Top 10 — Popular Posts trunk, at includes/admin/class-wpp-importer.php

854 lines 31.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * WPP Importer class.
4 *
5 * @package WebberZone\Top_Ten\Admin
6 */
7
8 declare(strict_types=1);
9
10 namespace WebberZone\Top_Ten\Admin;
11
12 if ( ! defined( 'WPINC' ) ) {
13 die;
14 }
15
16 /**
17 * Class WPP_Importer
18 *
19 * Handles importing WordPress Popular Posts data into Top 10 tables.
20 *
21 * @since 4.1.0
22 */
23 class WPP_Importer {
24
25 /**
26 * Initialize hooks.
27 *
28 * @since 4.1.0
29 * @return void
30 */
31 public function __construct() {
32 add_action( 'tptn_admin_import_export_tab_content', array( $this, 'render_page' ) );
33 add_action( 'wp_ajax_top_ten_import_wpp', array( $this, 'process_ajax_import' ) );
34 add_action( 'admin_post_top_ten_import_wpp', array( $this, 'handle_import_request' ) );
35 }
36
37 /**
38 * Renders the importer form.
39 *
40 * This is meant to be called within tools-page.php.
41 *
42 * @since 4.1.0
43 * @return void
44 */
45 public function render_page() {
46 // Enqueue required scripts.
47 $this->enqueue_scripts();
48 ?>
49 <div class="wrap">
50 <hr />
51 <h2 style="padding-left:0px;font-size: 1.5em;"><?php esc_html_e( 'Migrate WordPress Popular Posts Data to Top 10', 'top-10' ); ?></h2>
52
53 <?php
54 $wpp_active = self::is_wpp_active();
55 $tables_exist = self::wpp_tables_exist();
56 ?>
57
58 <?php if ( ! $wpp_active && ! $tables_exist ) : ?>
59 <p class="notice notice-error"><?php esc_html_e( 'WordPress Popular Posts plugin is not active and no WPP tables were found in the database. Import is not possible.', 'top-10' ); ?></p>
60 <?php elseif ( ! $tables_exist ) : ?>
61 <p class="notice notice-error"><?php esc_html_e( 'WordPress Popular Posts tables were not found in the database. Import is not possible.', 'top-10' ); ?></p>
62 <?php else : ?>
63 <p><?php esc_html_e( 'This tool allows you to import post view counts from WordPress Popular Posts into Top 10.', 'top-10' ); ?></p>
64 <div id="top-ten-import-notice" class="notice notice-warning">
65 <p>
66 <strong><?php esc_html_e( 'Important:', 'top-10' ); ?></strong>
67 <?php esc_html_e( 'Please backup your database before proceeding with the import.', 'top-10' ); ?>
68 </p>
69 </div>
70
71 <div class="postbox">
72 <h3 class="hndle"><?php esc_html_e( 'Important Notes', 'top-10' ); ?></h3>
73 <div class="inside">
74 <ul style="list-style: disc; padding-left: 20px;">
75 <li><?php esc_html_e( 'WordPress Popular Posts tracks each individual view, while Top 10 tracks cumulative views and daily views by hour.', 'top-10' ); ?></li>
76 <li><?php esc_html_e( 'For daily data, the importer will consolidate WPP data by hour to match Top 10\'s format.', 'top-10' ); ?></li>
77 <li><?php esc_html_e( 'It\'s recommended to backup your database before running this import.', 'top-10' ); ?></li>
78 <li><?php esc_html_e( 'Large sites may experience timeouts during import. If this happens, try importing with a higher minimum view count.', 'top-10' ); ?></li>
79 </ul>
80 </div>
81 </div>
82 <?php endif; ?>
83
84 <?php if ( $wpp_active && $tables_exist ) : ?>
85 <form id="top-ten-wpp-import-form" method="post">
86 <?php wp_nonce_field( 'top_ten_import_wpp_nonce', 'top_ten_import_wpp_nonce_field' ); ?>
87 <input type="hidden" name="action" value="top_ten_import_wpp">
88 <table class="form-table">
89 <tr>
90 <th scope="row"><?php esc_html_e( 'Import Mode', 'top-10' ); ?></th>
91 <td>
92 <fieldset>
93 <legend class="screen-reader-text"><?php esc_html_e( 'Import Mode', 'top-10' ); ?></legend>
94 <label>
95 <input type="radio" name="import_mode" value="merge">
96 <span><?php esc_html_e( 'Merge data (add WPP counts to existing Top 10 counts)', 'top-10' ); ?></span>
97 </label>
98 <br>
99 <label>
100 <input type="radio" name="import_mode" value="replace" checked="checked">
101 <span><?php esc_html_e( 'Replace data (replace Top 10 counts with WPP counts)', 'top-10' ); ?></span>
102 </label>
103 </fieldset>
104 <p class="description"><?php esc_html_e( 'Merge mode will add WPP counts to existing Top 10 counts, while replace mode will replace Top 10 counts with WPP counts if they exist for the same post.', 'top-10' ); ?></p>
105 </td>
106 </tr>
107 <tr>
108 <th scope="row"><?php esc_html_e( 'Data to Import', 'top-10' ); ?></th>
109 <td>
110 <fieldset>
111 <legend class="screen-reader-text"><?php esc_html_e( 'Data to Import', 'top-10' ); ?></legend>
112 <label>
113 <input type="radio" name="import_data" value="total" checked="checked">
114 <span><?php esc_html_e( 'Total counts only', 'top-10' ); ?></span>
115 </label>
116 <br>
117 <label>
118 <input type="radio" name="import_data" value="daily">
119 <span><?php esc_html_e( 'Daily counts only', 'top-10' ); ?></span>
120 </label>
121 <br>
122 <label>
123 <input type="radio" name="import_data" value="both">
124 <span><?php esc_html_e( 'Both total and daily counts', 'top-10' ); ?></span>
125 </label>
126 </fieldset>
127 <p class="description"><?php esc_html_e( 'Total counts only will import only the total view counts from WPP, while daily counts only will import only the daily view counts from WPP. Both total and daily counts will import both total and daily view counts from WPP. If you have a very large dataset, it might be better to import total and daily counts separately.', 'top-10' ); ?></p>
128 </td>
129 </tr>
130 <tr>
131 <th scope="row"><?php esc_html_e( 'Minimum View Count', 'top-10' ); ?></th>
132 <td>
133 <input type="number" name="min_views" value="1" min="1" step="1">
134 <p class="description"><?php esc_html_e( 'Only import posts with at least this many views. Use this setting to filter out posts with very few views.', 'top-10' ); ?></p>
135 </td>
136 </tr>
137 <tr>
138 <th scope="row"><?php esc_html_e( 'Dry Run', 'top-10' ); ?></th>
139 <td>
140 <label>
141 <input type="checkbox" name="dry_run" value="1" checked="checked">
142 <?php esc_html_e( 'Enable Dry Run (simulate import)', 'top-10' ); ?>
143 </label>
144 <p class="description"><?php esc_html_e( 'A dry run will simulate the import process without actually updating the database. This is useful for testing the import parameters.', 'top-10' ); ?></p>
145 </td>
146 </tr>
147 <?php if ( is_multisite() && is_network_admin() ) : ?>
148 <tr>
149 <th scope="row"><?php esc_html_e( 'Select Sites', 'top-10' ); ?></th>
150 <td>
151 <?php
152 $sites = get_sites();
153 if ( ! empty( $sites ) ) {
154 foreach ( $sites as $site ) {
155 $blog_details = get_blog_details( $site->blog_id );
156 printf(
157 '<label><input type="checkbox" name="sites[]" value="%1$d"> %2$s (ID: %1$d)</label><br>',
158 absint( $site->blog_id ),
159 esc_html( $blog_details->blogname )
160 );
161 }
162 }
163 ?>
164 </td>
165 </tr>
166 <?php endif; ?>
167 </table>
168 <?php submit_button( esc_html__( 'Run Import', 'top-10' ), 'primary', 'submit', true, array( 'id' => 'top-ten-wpp-import-submit' ) ); ?>
169
170 <p id="top-ten-wpp-import-progress" class="hidden notice notice-info">
171 </p>
172 <p id="top-ten-wpp-import-results" class="hidden notice notice-info">
173 </p>
174 </form>
175 <?php endif; ?>
176 </div>
177 <?php
178 }
179 /**
180 * Process import parameters from a request array.
181 *
182 * @since 4.1.0
183 * @param array $request Request data array.
184 * @return array Processed import parameters
185 */
186 private function process_import_parameters( array $request ): array {
187 $parameters = array(
188 'import_mode' => isset( $request['import_mode'] ) && 'merge' === sanitize_text_field( wp_unslash( $request['import_mode'] ) ) ? 'merge' : 'replace',
189 'import_data' => isset( $request['import_data'] ) ? sanitize_text_field( wp_unslash( $request['import_data'] ) ) : 'total',
190 'min_views' => isset( $request['min_views'] ) ? absint( $request['min_views'] ) : 1,
191 'blog_id' => ! empty( $request['blog_id'] ) ? absint( $request['blog_id'] ) : get_current_blog_id(),
192 'dry_run' => isset( $request['dry_run'] ) && ( 1 === absint( $request['dry_run'] ) || true === $request['dry_run'] ),
193 );
194
195 // Validate import_data is one of the allowed values.
196 if ( ! in_array( $parameters['import_data'], array( 'total', 'daily', 'both' ), true ) ) {
197 $parameters['import_data'] = 'total';
198 }
199
200 return $parameters;
201 }
202
203 /**
204 * Process AJAX import request.
205 *
206 * @since 4.1.0
207 * @return void
208 */
209 public function process_ajax_import(): void {
210 check_ajax_referer( 'top_ten_import_wpp_nonce', 'nonce' );
211
212 if ( ! current_user_can( 'manage_options' ) ) {
213 wp_send_json_error(
214 array(
215 'message' => esc_html__( 'You do not have sufficient permissions to perform this action.', 'top-10' ),
216 )
217 );
218 }
219
220 // Validate WPP plugin and tables.
221 if ( ! self::is_wpp_active() || ! self::wpp_tables_exist() ) {
222 wp_send_json_error(
223 array(
224 'message' => esc_html__( 'WordPress Popular Posts plugin is not active or its tables are missing.', 'top-10' ),
225 )
226 );
227 }
228
229 // Get import options from AJAX request.
230 $params = $this->process_import_parameters( $_POST );
231
232 // Determine sites to process.
233 $sites = array();
234 $is_network_admin = is_multisite() && isset( $_POST['is_network_admin'] ) && '1' === $_POST['is_network_admin'];
235
236 if ( isset( $_POST['sites'] ) && is_array( $_POST['sites'] ) ) {
237 $sites = array_map( 'absint', $_POST['sites'] );
238 } elseif ( ! $is_network_admin ) {
239 $sites[] = $params['blog_id'];
240 }
241
242 // No sites to process.
243 if ( empty( $sites ) ) {
244 wp_send_json_error(
245 array(
246 'message' => esc_html__( 'No sites selected for import.', 'top-10' ),
247 )
248 );
249 }
250
251 $import_results = array();
252
253 // Process each selected site.
254 foreach ( $sites as $blog_id ) {
255 $result = self::do_import_for_site(
256 $blog_id,
257 $params['import_mode'],
258 $params['dry_run'],
259 $params['import_data'],
260 $params['min_views']
261 );
262
263 $import_results[ $blog_id ] = $result;
264 }
265
266 // Send success response with dry_run flag and all results.
267 wp_send_json_success(
268 array(
269 'message' => $params['dry_run'] ? esc_html__( 'Dry run completed successfully.', 'top-10' ) : esc_html__( 'Import completed successfully.', 'top-10' ),
270 'results' => $import_results,
271 'dry_run' => $params['dry_run'],
272 'sites_processed' => count( $sites ),
273 )
274 );
275 }
276
277 /**
278 * Check if WordPress Popular Posts plugin is active
279 *
280 * @since 4.1.0
281 * @return bool Whether WPP is active.
282 */
283 private function is_wpp_active(): bool {
284 return defined( 'WPP_VERSION' );
285 }
286
287 /**
288 * Check if WPP tables exist in the database
289 *
290 * @since 4.1.0
291 * @return bool Whether WPP tables exist.
292 */
293 private function wpp_tables_exist(): bool {
294 global $wpdb;
295
296 $tables = array(
297 $wpdb->prefix . 'popularpostsdata',
298 $wpdb->prefix . 'popularpostssummary',
299 );
300 $key = implode( '|', $tables );
301 static $cache = array();
302
303 if ( array_key_exists( $key, $cache ) ) {
304 return $cache[ $key ];
305 }
306
307 $tables_exist = true;
308 foreach ( $tables as $table ) {
309 $query = $wpdb->prepare( 'SELECT 1 FROM %i LIMIT 0', $table );
310 $suppress_errors = $wpdb->suppress_errors();
311
312 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
313 if ( false === $wpdb->query( $query ) ) {
314 $tables_exist = false;
315 }
316
317 $wpdb->suppress_errors( $suppress_errors );
318 }
319
320 $cache[ $key ] = $tables_exist;
321
322 return $tables_exist;
323 }
324
325 /**
326 * Enqueues required scripts and styles.
327 *
328 * @since 4.1.0
329 * @return void
330 */
331 public function enqueue_scripts(): void {
332 wp_enqueue_script( 'top-ten-wpp-importer-js' );
333 wp_localize_script(
334 'top-ten-wpp-importer-js',
335 'topTenWPPImporter',
336 array(
337 'nonce' => wp_create_nonce( 'top_ten_import_wpp_nonce' ),
338 'ajaxurl' => admin_url( 'admin-ajax.php' ),
339 'strings' => array(
340 'confirm' => esc_html__( 'Are you sure you want to proceed with the import? This operation cannot be undone.', 'top-10' ),
341 'importing' => esc_html__( 'Importing data...', 'top-10' ),
342 'import_complete' => esc_html__( 'Import completed successfully!', 'top-10' ),
343 'import_error' => esc_html__( 'An error occurred during import:', 'top-10' ),
344 'cancel_confirm' => esc_html__( 'Are you sure you want to cancel the import? All progress will be lost.', 'top-10' ),
345 'cancel_button' => esc_html__( 'Cancel Import', 'top-10' ),
346 'import_cancelled' => esc_html__( 'Import cancelled by user.', 'top-10' ),
347 'no_sites_selected' => esc_html__( 'Please select at least one site to import data from.', 'top-10' ),
348 'invalid_min_views' => esc_html__( 'Minimum views must be a non-negative number.', 'top-10' ),
349 'timeout_error' => esc_html__( 'Request timed out. The server might be processing a large amount of data. Try again with a smaller batch size or reduce the number of sites processed at once.', 'top-10' ),
350 'processing_site' => esc_html__( 'Processing Site', 'top-10' ),
351 'batch' => esc_html__( 'Batch', 'top-10' ),
352 'of' => esc_html__( 'of', 'top-10' ),
353 'dry_run' => esc_html__( 'Dry Run -', 'top-10' ),
354 'sites_processed' => esc_html__( 'Sites Processed:', 'top-10' ),
355 'blog_id' => esc_html__( 'Blog ID', 'top-10' ),
356 'posts_processed' => esc_html__( 'Posts Processed:', 'top-10' ),
357 'total_records' => esc_html__( 'Total Records:', 'top-10' ),
358 'total_views_found' => esc_html__( 'Total Views Found:', 'top-10' ),
359 'daily_records' => esc_html__( 'Daily Records:', 'top-10' ),
360 'daily_views_found' => esc_html__( 'Daily Views Found:', 'top-10' ),
361 'errors' => esc_html__( 'Errors:', 'top-10' ),
362 'results' => esc_html__( 'Results:', 'top-10' ),
363 'error' => esc_html__( 'Error:', 'top-10' ),
364 'server_error' => esc_html__( 'Server error:', 'top-10' ),
365 'unknown_error' => esc_html__( 'Unknown error occurred during import', 'top-10' ),
366 'starting' => esc_html__( 'Starting...', 'top-10' ),
367 ),
368 )
369 );
370 }
371
372 /**
373 * Handles the import form submission.
374 *
375 * @since 4.1.0
376 *
377 * @return void
378 */
379 public function handle_import_request() {
380 // Verify nonce.
381 if ( ! isset( $_POST['top_ten_import_wpp_nonce_field'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['top_ten_import_wpp_nonce_field'] ) ), 'top_ten_import_wpp_nonce' ) ) {
382 wp_die( esc_html__( 'Nonce verification failed', 'top-10' ) );
383 }
384
385 // Check user capabilities.
386 if ( ! current_user_can( 'manage_options' ) ) {
387 wp_die( esc_html__( 'You are not allowed to perform this action', 'top-10' ) );
388 }
389
390 // Get import parameters using our helper method.
391 $params = $this->process_import_parameters( $_POST );
392
393 // Determine sites.
394 $sites = array();
395 $is_network_admin = is_multisite() && is_network_admin();
396
397 if ( isset( $_POST['sites'] ) && is_array( $_POST['sites'] ) ) {
398 $sites = array_map( 'absint', $_POST['sites'] );
399 } elseif ( ! $is_network_admin ) {
400 $sites[] = $params['blog_id'];
401 }
402
403 if ( empty( $sites ) ) {
404 wp_die( esc_html__( 'No sites selected for import.', 'top-10' ) );
405 }
406
407 $import_results = array();
408
409 // Process each selected site.
410 foreach ( $sites as $blog_id ) {
411 /**
412 * Action fired before processing a site.
413 *
414 * @param int $blog_id The blog ID being processed.
415 */
416 do_action( 'top_10_importer_before', $blog_id );
417
418 $result = self::do_import_for_site(
419 $blog_id,
420 $params['import_mode'],
421 $params['dry_run'],
422 $params['import_data'],
423 $params['min_views']
424 );
425
426 $import_results[ $blog_id ] = $result;
427 /**
428 * Action fired after processing a site.
429 *
430 * @param int $blog_id The blog ID that was processed.
431 * @param array $result The results array.
432 */
433 do_action( 'top_10_importer_after', $blog_id, $result );
434 }
435
436 // Build summary message.
437 $message = '';
438 foreach ( $import_results as $blog_id => $result ) {
439 $blog_details = get_blog_details( $blog_id );
440 /* translators: 1. Blog ID. */
441 $blog_name = $blog_details ? $blog_details->blogname : sprintf( __( 'Blog ID %d', 'top-10' ), $blog_id );
442 $message .= sprintf(
443 /* translators: 1: Site name, 2: Blog ID, 3: Total records processed, 4: Daily records processed, 5: Dry run notice */
444 __( 'Site %1$s (ID: %2$d): Total Counts Processed: %3$d, Daily Records Processed: %4$d%5$s', 'top-10' ),
445 esc_html( $blog_name ),
446 $blog_id,
447 $result['total_counts'] ?? 0,
448 $result['daily_counts'] ?? 0,
449 $params['dry_run'] ? ' (' . esc_html__( 'Dry Run', 'top-10' ) . ')' : ''
450 ) . '<br>';
451 }
452
453 $redirect_url = add_query_arg( 'top_ten_import_message', rawurlencode( $message ), wp_get_referer() );
454 wp_safe_redirect( $redirect_url );
455 exit;
456 }
457
458 /**
459 * Performs the import for a single site.
460 *
461 * @since 4.1.0
462 *
463 * @param int $blog_id Blog ID to import from.
464 * @param string $import_mode Either 'merge' or 'replace'.
465 * @param bool $dry_run Whether to simulate the import.
466 * @param string $import_data Type of data to import: 'total', 'daily', or 'both'.
467 * @param int $min_views Minimum number of views to import.
468 * @return array Result counts and status.
469 * @throws \Exception If there's an error during the import process.
470 */
471 private function do_import_for_site( int $blog_id, string $import_mode = 'replace', bool $dry_run = false, string $import_data = 'both', int $min_views = 1 ): array {
472 global $wpdb;
473
474 $results = array(
475 'total_counts' => 0,
476 'daily_counts' => 0,
477 'posts_processed' => 0,
478 'total_views_imported' => 0,
479 'daily_views_imported' => 0,
480 'errors' => array(),
481 'status' => 'success',
482 );
483
484 // If multisite and blog is not current, switch context.
485 $switched = false;
486 if ( is_multisite() && get_current_blog_id() !== $blog_id ) {
487 switch_to_blog( $blog_id );
488 $switched = true;
489 }
490
491 try {
492 // Verify WPP tables exist.
493 if ( ! self::wpp_tables_exist() ) {
494 throw new \Exception( sprintf( 'WPP tables not found for blog ID: %d', $blog_id ) );
495 }
496
497 // Process total counts if needed.
498 if ( 'total' === $import_data || 'both' === $import_data ) {
499 // Import total view counts using the efficient bulk method.
500 if ( ! $dry_run ) {
501 $total_import_result = self::import_total_counts( $import_mode, $min_views, $blog_id );
502
503 // Merge any errors from the total import.
504 if ( ! empty( $total_import_result['errors'] ) ) {
505 $results['errors'] = array_merge( $results['errors'], $total_import_result['errors'] );
506 }
507
508 // Update the results counts.
509 $results['total_views_imported'] = $total_import_result['views'];
510 $results['total_counts'] = $total_import_result['rows_affected'] ?? 0;
511
512 // Increment posts processed count.
513 $results['posts_processed'] += $results['total_counts'];
514 } else {
515 // For dry run, just count records that would be imported.
516 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
517 $count_results = $wpdb->get_row(
518 $wpdb->prepare(
519 "SELECT
520 COUNT(DISTINCT postid) AS post_count,
521 SUM(pageviews) AS total_views
522 FROM (
523 SELECT
524 postid,
525 COUNT(*) AS pageviews
526 FROM {$wpdb->prefix}popularpostsdata
527 GROUP BY postid
528 HAVING pageviews >= %d
529 ) AS subquery",
530 $min_views
531 )
532 );
533
534 $results['total_counts'] = $count_results->post_count ?? 0;
535 $results['total_views_imported'] = $count_results->total_views ?? 0;
536 $results['posts_processed'] += $results['total_counts'];
537 }
538 }
539
540 // Process daily views.
541 if ( 'daily' === $import_data || 'both' === $import_data ) {
542 // Import daily view counts from WPP using the efficient bulk method.
543 if ( ! $dry_run ) {
544 $daily_import_result = self::import_daily_counts( $import_mode, $min_views, $blog_id );
545
546 // Merge any errors from the daily import.
547 if ( ! empty( $daily_import_result['errors'] ) ) {
548 $results['errors'] = array_merge( $results['errors'], $daily_import_result['errors'] );
549 }
550
551 // Update the results counts.
552 $results['daily_views_imported'] = $daily_import_result['views'];
553 $results['daily_counts'] = $daily_import_result['rows_affected'] ?? 0;
554
555 // Increment posts processed count (estimate based on rows affected).
556 $results['posts_processed'] += $results['daily_counts'];
557 } else {
558 // For dry run, just count records that would be imported.
559 $count_results = $wpdb->get_row(// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
560 $wpdb->prepare(
561 "SELECT
562 COUNT(*) AS total_entries,
563 COUNT(DISTINCT postid) AS post_count,
564 SUM(hourly_views) AS total_views
565 FROM (
566 SELECT
567 postid,
568 DATE_FORMAT(MIN(view_datetime), '%%Y-%%m-%%d %%H:00:00') AS dp_date,
569 COUNT(*) AS hourly_views
570 FROM {$wpdb->prefix}popularpostssummary
571 GROUP BY postid, DATE(view_datetime), HOUR(view_datetime)
572 HAVING hourly_views >= %d
573 ) AS subquery",
574 $min_views
575 )
576 );
577
578 $results['daily_counts'] = $count_results->post_count ?? 0;
579 $results['daily_views_imported'] = $count_results->total_views ?? 0;
580 $results['posts_processed'] += $results['daily_counts'];
581 }
582 }
583 } catch ( \Exception $e ) {
584 $results['errors'][] = $e->getMessage();
585 $results['status'] = 'error';
586 } finally {
587 // Restore original blog if switched.
588 if ( $switched ) {
589 restore_current_blog();
590 }
591 }
592
593 return $results;
594 }
595
596 /**
597 * Efficiently import total counts using a bulk operation approach.
598 *
599 * @since 4.1.0
600 * @param string $import_mode Either 'merge' or 'replace'.
601 * @param int $min_views Minimum number of views to import.
602 * @param int $blog_id Blog ID for multisite.
603 * @return array Result with views count and any errors.
604 */
605 private function import_total_counts( string $import_mode, int $min_views, int $blog_id ): array {
606 global $wpdb;
607
608 $result = array(
609 'views' => 0,
610 'errors' => array(),
611 'rows_affected' => 0,
612 );
613
614 try {
615 // Prepare a temporary table to hold the aggregated view counts.
616 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
617 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
618 'CREATE TEMPORARY TABLE IF NOT EXISTS temp_total_views (
619 post_id BIGINT,
620 total_views BIGINT
621 )'
622 );
623
624 // Clear the temporary table if it exists.
625 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
626 'TRUNCATE TABLE temp_total_views'
627 );
628
629 // Aggregate WPP views into total counts.
630 $aggregate_query = $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
631 "INSERT INTO temp_total_views (post_id, total_views)
632 SELECT
633 postid,
634 SUM(pageviews) AS total_views
635 FROM {$wpdb->prefix}popularpostsdata
636 GROUP BY postid
637 HAVING total_views >= %d",
638 $min_views
639 );
640
641 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
642 $wpdb->query( $aggregate_query );
643
644 // Get total views before proceeding.
645 $total_views = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
646 'SELECT SUM(total_views) FROM temp_total_views'
647 ) ?? 0;
648 $result['views'] = (int) $total_views;
649
650 if ( 'merge' === $import_mode ) {
651 // Merge query using temporary table.
652 $merge_query = $wpdb->prepare(
653 "INSERT INTO {$wpdb->base_prefix}top_ten (postnumber, cntaccess, blog_id)
654 SELECT
655 post_id,
656 total_views,
657 %d
658 FROM temp_total_views
659 ON DUPLICATE KEY UPDATE
660 cntaccess = cntaccess + VALUES(cntaccess)",
661 $blog_id
662 );
663
664 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
665 $rows_affected = $wpdb->query( $merge_query );
666 } else { // Replace mode
667 // First, get a list of the post IDs we're about to import.
668 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
669 $post_ids = $wpdb->get_col( 'SELECT DISTINCT post_id FROM temp_total_views' );
670
671 if ( ! empty( $post_ids ) ) {
672 $post_ids_list = implode( ',', array_map( 'intval', $post_ids ) );
673
674 // Clear existing entries for these posts and this blog_id.
675 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
676 $wpdb->prepare(
677 "DELETE FROM {$wpdb->base_prefix}top_ten
678 WHERE blog_id = %d AND postnumber IN ({$post_ids_list})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
679 $blog_id
680 )
681 );
682 }
683
684 // Then insert new data from temporary table.
685 $insert_query = $wpdb->prepare(
686 "INSERT INTO {$wpdb->base_prefix}top_ten (postnumber, cntaccess, blog_id)
687 SELECT
688 post_id,
689 total_views,
690 %d
691 FROM temp_total_views",
692 $blog_id
693 );
694
695 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
696 $rows_affected = $wpdb->query( $insert_query );
697 }
698
699 // Store the number of rows affected.
700 $result['rows_affected'] = false !== $rows_affected ? $rows_affected : 0;
701
702 // Drop the temporary table.
703 $wpdb->query( 'DROP TEMPORARY TABLE IF EXISTS temp_total_views' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
704
705 if ( false === $rows_affected ) {
706 $result['errors'][] = __( 'Error importing total counts', 'top-10' );
707 }
708 } catch ( \Exception $e ) {
709 $result['errors'][] = $e->getMessage();
710
711 // Make sure we clean up the temporary table even if there's an error.
712 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
713 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
714 'DROP TEMPORARY TABLE IF EXISTS temp_total_views'
715 );
716 }
717
718 return $result;
719 }
720
721 /**
722 * Efficiently import daily counts using a temporary table approach.
723 *
724 * @since 4.1.0
725 * @param string $import_mode Either 'merge' or 'replace'.
726 * @param int $min_views Minimum number of views to import.
727 * @param int $blog_id Blog ID for multisite.
728 * @return array Result with views count and any errors.
729 */
730 private function import_daily_counts( string $import_mode, int $min_views, int $blog_id ): array {
731 global $wpdb;
732
733 $result = array(
734 'views' => 0,
735 'errors' => array(),
736 'rows_affected' => 0,
737 );
738
739 try {
740 // Prepare a temporary table to aggregate hourly views.
741 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
742 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
743 'CREATE TEMPORARY TABLE IF NOT EXISTS temp_hourly_views (
744 post_id BIGINT,
745 dp_date DATETIME,
746 hourly_views BIGINT
747 )'
748 );
749
750 // Clear the temporary table if it exists.
751 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
752 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
753 'TRUNCATE TABLE temp_hourly_views'
754 );
755
756 // Aggregate WPP views into hourly counts.
757 $aggregate_query = $wpdb->prepare(
758 "INSERT INTO temp_hourly_views (post_id, dp_date, hourly_views)
759 SELECT
760 postid,
761 DATE_FORMAT(MIN(view_datetime), '%%Y-%%m-%%d %%H:00:00') AS dp_date,
762 COUNT(*) AS hourly_views
763 FROM {$wpdb->prefix}popularpostssummary
764 GROUP BY postid, DATE(view_datetime), HOUR(view_datetime)
765 HAVING hourly_views >= %d",
766 $min_views
767 );
768
769 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
770 $wpdb->query( $aggregate_query );
771
772 // Get total views before proceeding.
773 $total_views = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
774 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
775 'SELECT SUM(hourly_views) FROM temp_hourly_views'
776 ) ?? 0;
777 $result['views'] = (int) $total_views;
778
779 if ( 'merge' === $import_mode ) {
780 // Merge query using temporary table.
781 $merge_query = $wpdb->prepare(
782 "INSERT INTO {$wpdb->base_prefix}top_ten_daily (postnumber, cntaccess, dp_date, blog_id)
783 SELECT
784 post_id,
785 hourly_views,
786 dp_date,
787 %d
788 FROM temp_hourly_views
789 ON DUPLICATE KEY UPDATE
790 cntaccess = cntaccess + VALUES(cntaccess)",
791 $blog_id
792 );
793
794 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
795 $rows_affected = $wpdb->query( $merge_query );
796 } else { // Replace mode
797 // First, get a list of the post IDs we're about to import.
798 $post_ids = $wpdb->get_col( 'SELECT DISTINCT post_id FROM temp_hourly_views' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
799
800 if ( ! empty( $post_ids ) ) {
801 $post_ids_list = implode( ',', array_map( 'intval', $post_ids ) );
802
803 // Clear existing entries for these posts and this blog_id.
804 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
805 $wpdb->prepare(
806 "DELETE FROM {$wpdb->base_prefix}top_ten_daily
807 WHERE blog_id = %d AND postnumber IN ({$post_ids_list})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
808 $blog_id
809 )
810 );
811 }
812
813 // Then insert new data from temporary table.
814 $insert_query = $wpdb->prepare(
815 "INSERT INTO {$wpdb->base_prefix}top_ten_daily (postnumber, cntaccess, dp_date, blog_id)
816 SELECT
817 post_id,
818 hourly_views,
819 dp_date,
820 %d
821 FROM temp_hourly_views",
822 $blog_id
823 );
824
825 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
826 $rows_affected = $wpdb->query( $insert_query );
827 }
828
829 // Store the number of rows affected.
830 $result['rows_affected'] = false !== $rows_affected ? $rows_affected : 0;
831
832 // Drop the temporary table.
833 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
834 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
835 'DROP TEMPORARY TABLE IF EXISTS temp_hourly_views'
836 );
837
838 if ( false === $rows_affected ) {
839 $result['errors'][] = __( 'Error importing daily counts', 'top-10' );
840 }
841 } catch ( \Exception $e ) {
842 $result['errors'][] = $e->getMessage();
843
844 // Make sure we clean up the temporary table even if there's an error.
845 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
846 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
847 'DROP TEMPORARY TABLE IF EXISTS temp_hourly_views'
848 );
849 }
850
851 return $result;
852 }
853 }
854