PluginProbe
WebberZone Top 10 — Popular Posts / 4.3.2
WebberZone Top 10 — Popular Posts v4.3.2
4.5.1 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 All 117 releases
top-10 / includes / admin / class-wpp-importer.php

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

837 lines 31.4 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-ten' ); ?></th>
91 <td>
92 <fieldset>
93 <legend class="screen-reader-text"><?php esc_html_e( 'Import Mode', 'top-ten' ); ?></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-ten' ); ?></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-ten' ); ?></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 exists for the same post.', 'top-ten' ); ?></p>
105 </td>
106 </tr>
107 <tr>
108 <th scope="row"><?php esc_html_e( 'Data to Import', 'top-ten' ); ?></th>
109 <td>
110 <fieldset>
111 <legend class="screen-reader-text"><?php esc_html_e( 'Data to Import', 'top-ten' ); ?></legend>
112 <label>
113 <input type="radio" name="import_data" value="total" checked="checked">
114 <span><?php esc_html_e( 'Total counts only', 'top-ten' ); ?></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-ten' ); ?></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-ten' ); ?></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-ten' ); ?></p>
135 </td>
136 </tr>
137 <tr>
138 <th scope="row"><?php esc_html_e( 'Dry Run', 'top-ten' ); ?></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-ten' ); ?>
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-ten' ); ?></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-ten' ); ?></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-ten' ), '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_exist = true;
297
298 $data_table = $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}popularpostsdata'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
299 $summary_table = $wpdb->get_var( "SHOW TABLES LIKE '{$wpdb->prefix}popularpostssummary'" ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
300
301 if ( ! $data_table || ! $summary_table ) {
302 $tables_exist = false;
303 }
304
305 return $tables_exist;
306 }
307
308 /**
309 * Enqueues required scripts and styles.
310 *
311 * @since 4.1.0
312 * @return void
313 */
314 public function enqueue_scripts(): void {
315 wp_enqueue_script( 'top-ten-wpp-importer-js' );
316 wp_localize_script(
317 'top-ten-wpp-importer-js',
318 'topTenWPPImporter',
319 array(
320 'nonce' => wp_create_nonce( 'top_ten_import_wpp_nonce' ),
321 'ajaxurl' => admin_url( 'admin-ajax.php' ),
322 'strings' => array(
323 'confirm' => esc_html__( 'Are you sure you want to proceed with the import? This operation cannot be undone.', 'top-10' ),
324 'importing' => esc_html__( 'Importing data...', 'top-10' ),
325 'import_complete' => esc_html__( 'Import completed successfully!', 'top-10' ),
326 'import_error' => esc_html__( 'An error occurred during import:', 'top-10' ),
327 'cancel_confirm' => esc_html__( 'Are you sure you want to cancel the import? All progress will be lost.', 'top-10' ),
328 'cancel_button' => esc_html__( 'Cancel Import', 'top-10' ),
329 'import_cancelled' => esc_html__( 'Import cancelled by user.', 'top-10' ),
330 'no_sites_selected' => esc_html__( 'Please select at least one site to import data from.', 'top-10' ),
331 'invalid_min_views' => esc_html__( 'Minimum views must be a non-negative number.', 'top-10' ),
332 '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' ),
333 'processing_site' => esc_html__( 'Processing Site', 'top-10' ),
334 'batch' => esc_html__( 'Batch', 'top-10' ),
335 'of' => esc_html__( 'of', 'top-10' ),
336 'dry_run' => esc_html__( 'Dry Run -', 'top-10' ),
337 'sites_processed' => esc_html__( 'Sites Processed:', 'top-10' ),
338 'blog_id' => esc_html__( 'Blog ID', 'top-10' ),
339 'posts_processed' => esc_html__( 'Posts Processed:', 'top-10' ),
340 'total_records' => esc_html__( 'Total Records:', 'top-10' ),
341 'total_views_found' => esc_html__( 'Total Views Found:', 'top-10' ),
342 'daily_records' => esc_html__( 'Daily Records:', 'top-10' ),
343 'daily_views_found' => esc_html__( 'Daily Views Found:', 'top-10' ),
344 'errors' => esc_html__( 'Errors:', 'top-10' ),
345 'results' => esc_html__( 'Results:', 'top-10' ),
346 'error' => esc_html__( 'Error:', 'top-10' ),
347 'server_error' => esc_html__( 'Server error:', 'top-10' ),
348 'unknown_error' => esc_html__( 'Unknown error occurred during import', 'top-10' ),
349 'starting' => esc_html__( 'Starting...', 'top-10' ),
350 ),
351 )
352 );
353 }
354
355 /**
356 * Handles the import form submission.
357 *
358 * @since 4.1.0
359 *
360 * @return void
361 */
362 public function handle_import_request() {
363 // Verify nonce.
364 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' ) ) {
365 wp_die( esc_html__( 'Nonce verification failed', 'top-ten' ) );
366 }
367
368 // Check user capabilities.
369 if ( ! current_user_can( 'manage_options' ) ) {
370 wp_die( esc_html__( 'You are not allowed to perform this action', 'top-ten' ) );
371 }
372
373 // Get import parameters using our helper method.
374 $params = $this->process_import_parameters( $_POST );
375
376 // Determine sites.
377 $sites = array();
378 $is_network_admin = is_multisite() && is_network_admin();
379
380 if ( isset( $_POST['sites'] ) && is_array( $_POST['sites'] ) ) {
381 $sites = array_map( 'absint', $_POST['sites'] );
382 } elseif ( ! $is_network_admin ) {
383 $sites[] = $params['blog_id'];
384 }
385
386 if ( empty( $sites ) ) {
387 wp_die( esc_html__( 'No sites selected for import.', 'top-ten' ) );
388 }
389
390 $import_results = array();
391
392 // Process each selected site.
393 foreach ( $sites as $blog_id ) {
394 /**
395 * Action fired before processing a site.
396 *
397 * @param int $blog_id The blog ID being processed.
398 */
399 do_action( 'top_10_importer_before', $blog_id );
400
401 $result = self::do_import_for_site(
402 $blog_id,
403 $params['import_mode'],
404 $params['dry_run'],
405 $params['import_data'],
406 $params['min_views']
407 );
408
409 $import_results[ $blog_id ] = $result;
410 /**
411 * Action fired after processing a site.
412 *
413 * @param int $blog_id The blog ID that was processed.
414 * @param array $result The results array.
415 */
416 do_action( 'top_10_importer_after', $blog_id, $result );
417 }
418
419 // Build summary message.
420 $message = '';
421 foreach ( $import_results as $blog_id => $result ) {
422 $blog_details = get_blog_details( $blog_id );
423 /* translators: 1. Blog ID. */
424 $blog_name = $blog_details ? $blog_details->blogname : sprintf( __( 'Blog ID %d', 'top-ten' ), $blog_id );
425 $message .= sprintf(
426 /* translators: 1: Site name, 2: Blog ID, 3: Total records processed, 4: Daily records processed, 5: Dry run notice */
427 __( 'Site %1$s (ID: %2$d): Total Counts Processed: %3$d, Daily Records Processed: %4$d%5$s', 'top-ten' ),
428 esc_html( $blog_name ),
429 $blog_id,
430 $result['total_counts'] ?? 0,
431 $result['daily_counts'] ?? 0,
432 $params['dry_run'] ? ' (' . esc_html__( 'Dry Run', 'top-10' ) . ')' : ''
433 ) . '<br>';
434 }
435
436 $redirect_url = add_query_arg( 'top_ten_import_message', rawurlencode( $message ), wp_get_referer() );
437 wp_safe_redirect( $redirect_url );
438 exit;
439 }
440
441 /**
442 * Performs the import for a single site.
443 *
444 * @since 4.1.0
445 *
446 * @param int $blog_id Blog ID to import from.
447 * @param string $import_mode Either 'merge' or 'replace'.
448 * @param bool $dry_run Whether to simulate the import.
449 * @param string $import_data Type of data to import: 'total', 'daily', or 'both'.
450 * @param int $min_views Minimum number of views to import.
451 * @return array Result counts and status.
452 * @throws \Exception If there's an error during the import process.
453 */
454 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 {
455 global $wpdb;
456
457 $results = array(
458 'total_counts' => 0,
459 'daily_counts' => 0,
460 'posts_processed' => 0,
461 'total_views_imported' => 0,
462 'daily_views_imported' => 0,
463 'errors' => array(),
464 'status' => 'success',
465 );
466
467 // If multisite and blog is not current, switch context.
468 $switched = false;
469 if ( is_multisite() && get_current_blog_id() !== $blog_id ) {
470 switch_to_blog( $blog_id );
471 $switched = true;
472 }
473
474 try {
475 // Verify WPP tables exist.
476 if ( ! self::wpp_tables_exist() ) {
477 throw new \Exception( sprintf( 'WPP tables not found for blog ID: %d', $blog_id ) );
478 }
479
480 // Process total counts if needed.
481 if ( 'total' === $import_data || 'both' === $import_data ) {
482 // Import total view counts using the efficient bulk method.
483 if ( ! $dry_run ) {
484 $total_import_result = self::import_total_counts( $import_mode, $min_views, $blog_id );
485
486 // Merge any errors from the total import.
487 if ( ! empty( $total_import_result['errors'] ) ) {
488 $results['errors'] = array_merge( $results['errors'], $total_import_result['errors'] );
489 }
490
491 // Update the results counts.
492 $results['total_views_imported'] = $total_import_result['views'];
493 $results['total_counts'] = $total_import_result['rows_affected'] ?? 0;
494
495 // Increment posts processed count.
496 $results['posts_processed'] += $results['total_counts'];
497 } else {
498 // For dry run, just count records that would be imported.
499 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
500 $count_results = $wpdb->get_row(
501 $wpdb->prepare(
502 "SELECT
503 COUNT(DISTINCT postid) AS post_count,
504 SUM(pageviews) AS total_views
505 FROM (
506 SELECT
507 postid,
508 COUNT(*) AS pageviews
509 FROM {$wpdb->prefix}popularpostsdata
510 GROUP BY postid
511 HAVING pageviews >= %d
512 ) AS subquery",
513 $min_views
514 )
515 );
516
517 $results['total_counts'] = $count_results->post_count ?? 0;
518 $results['total_views_imported'] = $count_results->total_views ?? 0;
519 $results['posts_processed'] += $results['total_counts'];
520 }
521 }
522
523 // Process daily views.
524 if ( 'daily' === $import_data || 'both' === $import_data ) {
525 // Import daily view counts from WPP using the efficient bulk method.
526 if ( ! $dry_run ) {
527 $daily_import_result = self::import_daily_counts( $import_mode, $min_views, $blog_id );
528
529 // Merge any errors from the daily import.
530 if ( ! empty( $daily_import_result['errors'] ) ) {
531 $results['errors'] = array_merge( $results['errors'], $daily_import_result['errors'] );
532 }
533
534 // Update the results counts.
535 $results['daily_views_imported'] = $daily_import_result['views'];
536 $results['daily_counts'] = $daily_import_result['rows_affected'] ?? 0;
537
538 // Increment posts processed count (estimate based on rows affected).
539 $results['posts_processed'] += $results['daily_counts'];
540 } else {
541 // For dry run, just count records that would be imported.
542 $count_results = $wpdb->get_row(// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
543 $wpdb->prepare(
544 "SELECT
545 COUNT(*) AS total_entries,
546 COUNT(DISTINCT postid) AS post_count,
547 SUM(hourly_views) AS total_views
548 FROM (
549 SELECT
550 postid,
551 DATE_FORMAT(MIN(view_datetime), '%%Y-%%m-%%d %%H:00:00') AS dp_date,
552 COUNT(*) AS hourly_views
553 FROM {$wpdb->prefix}popularpostssummary
554 GROUP BY postid, DATE(view_datetime), HOUR(view_datetime)
555 HAVING hourly_views >= %d
556 ) AS subquery",
557 $min_views
558 )
559 );
560
561 $results['daily_counts'] = $count_results->post_count ?? 0;
562 $results['daily_views_imported'] = $count_results->total_views ?? 0;
563 $results['posts_processed'] += $results['daily_counts'];
564 }
565 }
566 } catch ( \Exception $e ) {
567 $results['errors'][] = $e->getMessage();
568 $results['status'] = 'error';
569 } finally {
570 // Restore original blog if switched.
571 if ( $switched ) {
572 restore_current_blog();
573 }
574 }
575
576 return $results;
577 }
578
579 /**
580 * Efficiently import total counts using a bulk operation approach.
581 *
582 * @since 4.1.0
583 * @param string $import_mode Either 'merge' or 'replace'.
584 * @param int $min_views Minimum number of views to import.
585 * @param int $blog_id Blog ID for multisite.
586 * @return array Result with views count and any errors.
587 */
588 private function import_total_counts( string $import_mode, int $min_views, int $blog_id ): array {
589 global $wpdb;
590
591 $result = array(
592 'views' => 0,
593 'errors' => array(),
594 'rows_affected' => 0,
595 );
596
597 try {
598 // Prepare a temporary table to hold the aggregated view counts.
599 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
600 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
601 'CREATE TEMPORARY TABLE IF NOT EXISTS temp_total_views (
602 post_id BIGINT,
603 total_views BIGINT
604 )'
605 );
606
607 // Clear the temporary table if it exists.
608 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
609 'TRUNCATE TABLE temp_total_views'
610 );
611
612 // Aggregate WPP views into total counts.
613 $aggregate_query = $wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
614 "INSERT INTO temp_total_views (post_id, total_views)
615 SELECT
616 postid,
617 SUM(pageviews) AS total_views
618 FROM {$wpdb->prefix}popularpostsdata
619 GROUP BY postid
620 HAVING total_views >= %d",
621 $min_views
622 );
623
624 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
625 $wpdb->query( $aggregate_query );
626
627 // Get total views before proceeding.
628 $total_views = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
629 'SELECT SUM(total_views) FROM temp_total_views'
630 ) ?? 0;
631 $result['views'] = (int) $total_views;
632
633 if ( 'merge' === $import_mode ) {
634 // Merge query using temporary table.
635 $merge_query = $wpdb->prepare(
636 "INSERT INTO {$wpdb->base_prefix}top_ten (postnumber, cntaccess, blog_id)
637 SELECT
638 post_id,
639 total_views,
640 %d
641 FROM temp_total_views
642 ON DUPLICATE KEY UPDATE
643 cntaccess = cntaccess + VALUES(cntaccess)",
644 $blog_id
645 );
646
647 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
648 $rows_affected = $wpdb->query( $merge_query );
649 } else { // Replace mode
650 // First, get a list of the post IDs we're about to import.
651 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
652 $post_ids = $wpdb->get_col( 'SELECT DISTINCT post_id FROM temp_total_views' );
653
654 if ( ! empty( $post_ids ) ) {
655 $post_ids_list = implode( ',', array_map( 'intval', $post_ids ) );
656
657 // Clear existing entries for these posts and this blog_id.
658 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
659 $wpdb->prepare(
660 "DELETE FROM {$wpdb->base_prefix}top_ten
661 WHERE blog_id = %d AND postnumber IN ({$post_ids_list})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
662 $blog_id
663 )
664 );
665 }
666
667 // Then insert new data from temporary table.
668 $insert_query = $wpdb->prepare(
669 "INSERT INTO {$wpdb->base_prefix}top_ten (postnumber, cntaccess, blog_id)
670 SELECT
671 post_id,
672 total_views,
673 %d
674 FROM temp_total_views",
675 $blog_id
676 );
677
678 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
679 $rows_affected = $wpdb->query( $insert_query );
680 }
681
682 // Store the number of rows affected.
683 $result['rows_affected'] = false !== $rows_affected ? $rows_affected : 0;
684
685 // Drop the temporary table.
686 $wpdb->query( 'DROP TEMPORARY TABLE IF EXISTS temp_total_views' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
687
688 if ( false === $rows_affected ) {
689 $result['errors'][] = __( 'Error importing total counts', 'top-10' );
690 }
691 } catch ( \Exception $e ) {
692 $result['errors'][] = $e->getMessage();
693
694 // Make sure we clean up the temporary table even if there's an error.
695 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
696 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
697 'DROP TEMPORARY TABLE IF EXISTS temp_total_views'
698 );
699 }
700
701 return $result;
702 }
703
704 /**
705 * Efficiently import daily counts using a temporary table approach.
706 *
707 * @since 4.1.0
708 * @param string $import_mode Either 'merge' or 'replace'.
709 * @param int $min_views Minimum number of views to import.
710 * @param int $blog_id Blog ID for multisite.
711 * @return array Result with views count and any errors.
712 */
713 private function import_daily_counts( string $import_mode, int $min_views, int $blog_id ): array {
714 global $wpdb;
715
716 $result = array(
717 'views' => 0,
718 'errors' => array(),
719 'rows_affected' => 0,
720 );
721
722 try {
723 // Prepare a temporary table to aggregate hourly views.
724 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
725 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
726 'CREATE TEMPORARY TABLE IF NOT EXISTS temp_hourly_views (
727 post_id BIGINT,
728 dp_date DATETIME,
729 hourly_views BIGINT
730 )'
731 );
732
733 // Clear the temporary table if it exists.
734 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
735 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
736 'TRUNCATE TABLE temp_hourly_views'
737 );
738
739 // Aggregate WPP views into hourly counts.
740 $aggregate_query = $wpdb->prepare(
741 "INSERT INTO temp_hourly_views (post_id, dp_date, hourly_views)
742 SELECT
743 postid,
744 DATE_FORMAT(MIN(view_datetime), '%%Y-%%m-%%d %%H:00:00') AS dp_date,
745 COUNT(*) AS hourly_views
746 FROM {$wpdb->prefix}popularpostssummary
747 GROUP BY postid, DATE(view_datetime), HOUR(view_datetime)
748 HAVING hourly_views >= %d",
749 $min_views
750 );
751
752 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
753 $wpdb->query( $aggregate_query );
754
755 // Get total views before proceeding.
756 $total_views = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
757 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
758 'SELECT SUM(hourly_views) FROM temp_hourly_views'
759 ) ?? 0;
760 $result['views'] = (int) $total_views;
761
762 if ( 'merge' === $import_mode ) {
763 // Merge query using temporary table.
764 $merge_query = $wpdb->prepare(
765 "INSERT INTO {$wpdb->base_prefix}top_ten_daily (postnumber, cntaccess, dp_date, blog_id)
766 SELECT
767 post_id,
768 hourly_views,
769 dp_date,
770 %d
771 FROM temp_hourly_views
772 ON DUPLICATE KEY UPDATE
773 cntaccess = cntaccess + VALUES(cntaccess)",
774 $blog_id
775 );
776
777 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
778 $rows_affected = $wpdb->query( $merge_query );
779 } else { // Replace mode
780 // First, get a list of the post IDs we're about to import.
781 $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
782
783 if ( ! empty( $post_ids ) ) {
784 $post_ids_list = implode( ',', array_map( 'intval', $post_ids ) );
785
786 // Clear existing entries for these posts and this blog_id.
787 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
788 $wpdb->prepare(
789 "DELETE FROM {$wpdb->base_prefix}top_ten_daily
790 WHERE blog_id = %d AND postnumber IN ({$post_ids_list})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
791 $blog_id
792 )
793 );
794 }
795
796 // Then insert new data from temporary table.
797 $insert_query = $wpdb->prepare(
798 "INSERT INTO {$wpdb->base_prefix}top_ten_daily (postnumber, cntaccess, dp_date, blog_id)
799 SELECT
800 post_id,
801 hourly_views,
802 dp_date,
803 %d
804 FROM temp_hourly_views",
805 $blog_id
806 );
807
808 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
809 $rows_affected = $wpdb->query( $insert_query );
810 }
811
812 // Store the number of rows affected.
813 $result['rows_affected'] = false !== $rows_affected ? $rows_affected : 0;
814
815 // Drop the temporary table.
816 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
817 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
818 'DROP TEMPORARY TABLE IF EXISTS temp_hourly_views'
819 );
820
821 if ( false === $rows_affected ) {
822 $result['errors'][] = __( 'Error importing daily counts', 'top-10' );
823 }
824 } catch ( \Exception $e ) {
825 $result['errors'][] = $e->getMessage();
826
827 // Make sure we clean up the temporary table even if there's an error.
828 $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
829 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange
830 'DROP TEMPORARY TABLE IF EXISTS temp_hourly_views'
831 );
832 }
833
834 return $result;
835 }
836 }
837