PluginProbe ʕ •ᴥ•ʔ
Post Views Counter / 1.7.15
Post Views Counter v1.7.15
1.7.15 1.7.14 1.7.13 1.7.12 1.7.11 trunk 1.0.0 1.0.1 1.0.10 1.0.11 1.0.12 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.2.0 1.2.1 1.2.10 1.2.11 1.2.12 1.2.13 1.2.14 1.2.2 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3 1.3.1 1.3.10 1.3.11 1.3.12 1.3.13 1.3.2 1.3.2.1 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 1.3.9 1.4 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.7.0 1.7.1 1.7.10 1.7.2 1.7.3 1.7.4 1.7.5 1.7.6 1.7.7 1.7.8 1.7.9
post-views-counter / includes / class-import.php
post-views-counter / includes Last commit date
class-admin.php 2 weeks ago class-columns-modal.php 2 weeks ago class-columns.php 2 weeks ago class-counter.php 2 weeks ago class-crawler-detect.php 2 weeks ago class-cron.php 2 weeks ago class-dashboard.php 2 weeks ago class-emails-mailer.php 2 weeks ago class-emails-period.php 2 weeks ago class-emails-query.php 2 weeks ago class-emails-scheduler.php 2 weeks ago class-emails-template.php 2 weeks ago class-emails.php 2 weeks ago class-frontend.php 2 weeks ago class-functions.php 2 weeks ago class-import.php 2 weeks ago class-integration-gutenberg.php 2 weeks ago class-integrations.php 2 weeks ago class-query.php 2 weeks ago class-settings-api.php 2 weeks ago class-settings-display.php 2 weeks ago class-settings-emails.php 2 weeks ago class-settings-general.php 2 weeks ago class-settings-integrations.php 2 weeks ago class-settings-other.php 2 weeks ago class-settings-reports.php 2 weeks ago class-settings.php 2 weeks ago class-toolbar.php 2 weeks ago class-traffic-signals.php 2 weeks ago class-update.php 2 weeks ago class-widgets.php 2 weeks ago functions.php 2 weeks ago
class-import.php
2470 lines
1 <?php
2 // exit if accessed directly
3 if ( ! defined( 'ABSPATH' ) )
4 exit;
5
6 /**
7 * Post_Views_Counter_Import class.
8 *
9 * @class Post_Views_Counter_Import
10 */
11 class Post_Views_Counter_Import {
12 const SOURCE_BATCH_SIZE = 250;
13 const DESTINATION_CHUNK_SIZE = 500;
14 const MAX_SOURCE_BATCHES = 100000;
15
16 /**
17 * Import providers registry.
18 *
19 * @var array
20 */
21 private $import_providers = [];
22 private $import_provider_labels = [];
23 private $import_strategies = null;
24 private $default_import_strategy = 'merge';
25 private $statify_post_cache = [];
26 private $import_diagnostics = [];
27
28 /**
29 * Whether providers have been initialized.
30 *
31 * @var bool
32 */
33 private $import_providers_initialized = false;
34
35 /**
36 * Class constructor.
37 *
38 * @return void
39 */
40 public function __construct() {
41 // register import providers after translations are available
42 add_action( 'init', [ $this, 'initialize_import_providers' ], 5 );
43 }
44
45 /**
46 * Initialize import providers.
47 *
48 * @return void
49 */
50 public function initialize_import_providers() {
51 if ( $this->import_providers_initialized )
52 return;
53
54 $this->register_import_providers();
55 $this->import_providers_initialized = true;
56 }
57
58 /**
59 * Register import providers.
60 *
61 * @return void
62 */
63 private function register_import_providers() {
64 // custom meta key provider (always available)
65 $this->import_providers['custom_meta_key'] = [
66 'slug' => 'custom_meta_key',
67 'label' => __( 'Custom Meta Key', 'post-views-counter' ),
68 'supports' => [ 'total', 'post_types' ],
69 'is_available' => '__return_true',
70 'render' => [ $this, 'render_provider_custom_meta_key' ],
71 'sanitize' => [ $this, 'sanitize_provider_custom_meta_key' ],
72 'analyse' => [ $this, 'analyse_provider_custom_meta_key' ],
73 'import' => [ $this, 'import_provider_custom_meta_key' ]
74 ];
75
76 // wp-postviews provider (conditional)
77 $this->import_providers['wp_postviews'] = [
78 'slug' => 'wp_postviews',
79 'label' => 'WP-PostViews',
80 'supports' => [ 'total', 'post_types' ],
81 'is_available' => [ $this, 'is_wp_postviews_available' ],
82 'render' => [ $this, 'render_provider_wp_postviews' ],
83 'sanitize' => [ $this, 'sanitize_provider_wp_postviews' ],
84 'analyse' => [ $this, 'analyse_provider_wp_postviews' ],
85 'import' => [ $this, 'import_provider_wp_postviews' ]
86 ];
87
88 // statify provider (conditional)
89 $this->import_providers['statify'] = [
90 'slug' => 'statify',
91 'label' => 'Statify',
92 'supports' => [ 'total', 'yearly', 'monthly', 'weekly', 'daily', 'post_types' ],
93 'is_available' => [ $this, 'is_statify_available' ],
94 'render' => [ $this, 'render_provider_statify' ],
95 'sanitize' => [ $this, 'sanitize_provider_statify' ],
96 'analyse' => [ $this, 'analyse_provider_statify' ],
97 'import' => [ $this, 'import_provider_statify' ]
98 ];
99
100 // page views count provider (conditional)
101 $this->import_providers['page_views_count'] = [
102 'slug' => 'page_views_count',
103 'label' => 'Page Views Count',
104 'supports' => [ 'total', 'yearly', 'monthly', 'weekly', 'daily', 'post_types' ],
105 'is_available' => [ $this, 'is_page_views_count_available' ],
106 'render' => [ $this, 'render_provider_page_views_count' ],
107 'sanitize' => [ $this, 'sanitize_provider_page_views_count' ],
108 'analyse' => [ $this, 'analyse_provider_page_views_count' ],
109 'import' => [ $this, 'import_provider_page_views_count' ]
110 ];
111
112 // allow extensions to register additional providers without overriding core ones
113 $additional_providers = apply_filters( 'pvc_import_providers', [] );
114
115 if ( is_array( $additional_providers ) ) {
116 foreach ( $additional_providers as $slug => $provider ) {
117 if ( ! is_string( $slug ) || $slug === '' || isset( $this->import_providers[ $slug ] ) ) {
118 continue;
119 }
120
121 $this->import_providers[ $slug ] = $provider;
122 }
123 }
124
125 // ensure third-party providers have default supports
126 foreach ( $this->import_providers as $slug => $provider ) {
127 if ( ! isset( $provider['supports'] ) || ! is_array( $provider['supports'] ) ) {
128 $this->import_providers[ $slug ]['supports'] = [ 'total', 'post_types' ];
129 }
130 }
131
132 foreach ( $this->import_providers as $slug => $provider ) {
133 $this->import_provider_labels[ $slug ] = isset( $provider['label'] ) ? $provider['label'] : $slug;
134 }
135 }
136
137 /**
138 * Ensure import providers are loaded.
139 *
140 * @return void
141 */
142 private function ensure_import_providers_loaded() {
143 if ( ! $this->import_providers_initialized && did_action( 'init' ) )
144 $this->initialize_import_providers();
145 }
146
147 /**
148 * Get all import providers.
149 *
150 * @return array
151 */
152 public function get_all_providers() {
153 $this->ensure_import_providers_loaded();
154 return $this->import_providers;
155 }
156
157 /**
158 * Get available import providers.
159 *
160 * @return array
161 */
162 public function get_available_providers() {
163 $this->ensure_import_providers_loaded();
164
165 $available = [];
166
167 foreach ( $this->import_providers as $slug => $provider ) {
168 if ( is_callable( $provider['is_available'] ) && call_user_func( $provider['is_available'] ) ) {
169 $available[$slug] = $provider;
170 }
171 }
172
173 return $available;
174 }
175
176 /**
177 * Get a specific provider.
178 *
179 * @param string $slug
180 * @return array|null
181 */
182 public function get_provider( $slug ) {
183 $this->ensure_import_providers_loaded();
184 return isset( $this->import_providers[$slug] ) ? $this->import_providers[$slug] : null;
185 }
186
187 /**
188 * Get supports for a specific provider.
189 *
190 * @param string $slug
191 * @return array
192 */
193 public function get_provider_supports( $slug ) {
194 $provider = $this->get_provider( $slug );
195 $supports = $provider && isset( $provider['supports'] ) ? $provider['supports'] : [];
196 return apply_filters( 'pvc_import_provider_supports', $supports, $slug );
197 }
198
199 /**
200 * Get registered import strategies.
201 *
202 * @return array
203 */
204 public function get_import_strategies() {
205 if ( $this->import_strategies === null ) {
206 $this->import_strategies = [
207 'override' => [
208 'label' => __( 'Override existing views', 'post-views-counter' ),
209 'description' => __( 'Replace stored counts with the imported values.', 'post-views-counter' ),
210 'pro_only' => false,
211 'enabled' => true
212 ],
213 'merge' => [
214 'label' => __( 'Merge with existing views', 'post-views-counter' ),
215 'description' => __( 'Add imported counts on top of the existing values.', 'post-views-counter' ),
216 'pro_only' => false,
217 'enabled' => true
218 ],
219 'skip_existing' => [
220 'label' => __( 'Skip Existing', 'post-views-counter' ),
221 'description' => __( 'Only import data when the target record does not exist yet.', 'post-views-counter' ),
222 'pro_only' => true,
223 'enabled' => false
224 ],
225 'keep_higher_count' => [
226 'label' => __( 'Keep Higher Count', 'post-views-counter' ),
227 'description' => __( 'Keep whichever value is higher when comparing imported and stored counts.', 'post-views-counter' ),
228 'pro_only' => true,
229 'enabled' => false
230 ],
231 'fill_empty_only' => [
232 'label' => __( 'Fill Empty Counts', 'post-views-counter' ),
233 'description' => __( 'Only import data for posts or periods that currently store zero views.', 'post-views-counter' ),
234 'pro_only' => true,
235 'enabled' => false
236 ]
237 ];
238
239 /**
240 * Filter the available import strategies.
241 *
242 * @since 1.5.10
243 *
244 * @param array $strategies Strategy definitions.
245 * @param Post_Views_Counter_Import $importer Import handler instance.
246 */
247 $this->import_strategies = apply_filters( 'pvc_import_strategies', $this->import_strategies, $this );
248 }
249
250 return $this->import_strategies;
251 }
252
253 /**
254 * Get default import strategy.
255 *
256 * @return string
257 */
258 public function get_default_strategy() {
259 return $this->default_import_strategy;
260 }
261
262 /**
263 * Normalize import strategy against current availability.
264 *
265 * @param string $strategy
266 * @return string
267 */
268 public function normalize_strategy( $strategy ) {
269 $strategy = sanitize_key( $strategy );
270
271 if ( $this->is_strategy_enabled( $strategy ) ) {
272 return $strategy;
273 }
274
275 return $this->get_default_strategy();
276 }
277
278 /**
279 * Check if a strategy can be used in the current environment.
280 *
281 * @param string $strategy
282 * @return bool
283 */
284 public function is_strategy_enabled( $strategy ) {
285 $strategy = sanitize_key( $strategy );
286 $definition = $this->get_strategy_definition( $strategy );
287
288 if ( $definition === null ) {
289 return false;
290 }
291
292 if ( array_key_exists( 'enabled', $definition ) ) {
293 return (bool) $definition['enabled'];
294 }
295
296 // Preserve the pre-1.7.15 contract for filtered definitions that expose
297 // only the legacy Pro badge/availability flag.
298 if ( ! empty( $definition['pro_only'] ) ) {
299 return class_exists( 'Post_Views_Counter_Pro' );
300 }
301
302 return true;
303 }
304
305 /**
306 * Get strategy definition.
307 *
308 * @param string $strategy
309 * @return array|null
310 */
311 private function get_strategy_definition( $strategy ) {
312 $strategy = sanitize_key( $strategy );
313 $strategies = $this->get_import_strategies();
314
315 return isset( $strategies[ $strategy ] ) ? $strategies[ $strategy ] : null;
316 }
317
318 /**
319 * Generate a description for a provider based on its supports.
320 *
321 * @param array $supports Provider capabilities.
322 * @param string $provider Provider slug.
323 * @return string
324 */
325 private function generate_provider_description( $supports, $provider ) {
326 $parts = [];
327
328 // Date periods
329 $dates = [];
330 if ( in_array( 'total', $supports, true ) ) {
331 $dates[] = _x( 'total', 'view_counts', 'post-views-counter' );
332 }
333 if ( in_array( 'yearly', $supports, true ) ) {
334 $dates[] = _x( 'yearly', 'view_counts', 'post-views-counter' );
335 }
336 if ( in_array( 'monthly', $supports, true ) ) {
337 $dates[] = _x( 'monthly', 'view_counts', 'post-views-counter' );
338 }
339 if ( in_array( 'weekly', $supports, true ) ) {
340 $dates[] = _x( 'weekly', 'view_counts', 'post-views-counter' );
341 }
342 if ( in_array( 'daily', $supports, true ) ) {
343 $dates[] = _x( 'daily', 'view_counts', 'post-views-counter' );
344 }
345
346 // Content types
347 $content_labels = [
348 'post_types' => _x( 'post types', 'view_counts', 'post-views-counter' )
349 ];
350
351 $content = [];
352 $content_keys = [ 'post_types' ];
353
354 foreach ( $content_keys as $key ) {
355 if ( in_array( $key, $supports, true ) && isset( $content_labels[ $key ] ) ) {
356 $content[] = $content_labels[ $key ];
357 }
358 }
359
360 if ( ! empty( $dates ) && ! empty( $content ) ) {
361 $parts[] = sprintf( __( 'Imports %s view counts for %s', 'post-views-counter' ), $this->format_list( $dates ), $this->format_list( $content ) );
362 } elseif ( ! empty( $dates ) ) {
363 $parts[] = sprintf( __( 'Imports %s view counts', 'post-views-counter' ), $this->format_list( $dates ) );
364 }
365
366 /**
367 * Filter import provider description parts.
368 *
369 * Runs when a provider's settings fields are rendered.
370 *
371 * @since 1.7.15
372 *
373 * @param array $parts Description sentences without trailing periods.
374 * @param array $context Provider slug and declared supports.
375 * @param Post_Views_Counter_Import $importer Import handler instance.
376 */
377 $parts = apply_filters( 'pvc_import_provider_description_parts', $parts, [
378 'provider' => sanitize_key( $provider ),
379 'supports' => array_values( array_filter( array_map( 'sanitize_key', (array) $supports ) ) )
380 ], $this );
381 $parts = is_array( $parts ) ? array_filter( array_map( 'sanitize_text_field', $parts ) ) : [];
382
383 if ( empty( $parts ) ) {
384 return '';
385 }
386
387 return implode( '. ', $parts ) . '.';
388 }
389
390 /**
391 * Format a list of items into a human-readable string.
392 *
393 * @param array $items
394 * @return string
395 */
396 private function format_list( $items ) {
397 if ( count( $items ) === 1 ) {
398 return $items[0];
399 }
400
401 $last = array_pop( $items );
402 return implode( ', ', $items ) . ' ' . _x( 'and', 'view_counts', 'post-views-counter' ) . ' ' . $last;
403 }
404
405 /**
406 * Handle manual import/analyse action.
407 *
408 * @param array $request
409 * @return array
410 */
411 public function handle_manual_action( $request ) {
412 // get provider selection
413 $provider_slug = isset( $request['pvc_import_provider'] ) ? sanitize_key( $request['pvc_import_provider'] ) : 'custom_meta_key';
414
415 // get import strategy and validate
416 $strategy = isset( $request['pvc_import_strategy'] ) ? $this->normalize_strategy( $request['pvc_import_strategy'] ) : $this->get_default_strategy();
417
418 // get provider inputs
419 $provider_inputs = isset( $request['pvc_import_provider_inputs'] ) ? $request['pvc_import_provider_inputs'] : [];
420
421 // get available providers
422 $providers = $this->get_available_providers();
423
424 // validate provider exists
425 if ( ! isset( $providers[$provider_slug] ) ) {
426 return [
427 'success' => false,
428 'message' => __( 'Invalid import provider selected.', 'post-views-counter' ),
429 'type' => 'error'
430 ];
431 }
432
433 $provider = $providers[$provider_slug];
434
435 // sanitize provider inputs
436 $sanitized_inputs = [];
437 if ( is_callable( $provider['sanitize'] ) ) {
438 $sanitized_inputs = call_user_func( $provider['sanitize'], $provider_inputs );
439 }
440
441 // get main instance
442 $pvc = Post_Views_Counter();
443
444 // preserve existing provider settings, only update current provider
445 $existing_settings = isset( $pvc->options['other']['import_provider_settings'] ) ? $pvc->options['other']['import_provider_settings'] : [];
446
447 $provider_settings = array_merge(
448 $existing_settings,
449 [
450 'provider' => $provider_slug,
451 'strategy' => $strategy,
452 $provider_slug => $sanitized_inputs
453 ]
454 );
455
456 $result = [];
457 $mode = isset( $request['post_views_counter_analyse_views'] ) ? 'analyze' : ( isset( $request['post_views_counter_import_views'] ) ? 'import' : '' );
458
459 if ( $mode !== '' && $provider_slug !== 'statify' ) {
460 /**
461 * Fires before an import provider operation starts.
462 *
463 * @since 1.7.15
464 *
465 * @param array $context Operation mode, provider slug and normalized strategy.
466 * @param Post_Views_Counter_Import $importer Import handler instance.
467 */
468 do_action( 'pvc_import_before_provider', [
469 'mode' => $mode,
470 'provider' => $provider_slug,
471 'strategy' => $strategy
472 ], $this );
473 }
474
475 // handle analyse
476 if ( isset( $request['post_views_counter_analyse_views'] ) ) {
477 if ( is_callable( $provider['analyse'] ) ) {
478 $analyse_result = call_user_func( $provider['analyse'], $sanitized_inputs );
479
480 if ( isset( $analyse_result['message'] ) ) {
481 $result = [
482 'success' => ! isset( $analyse_result['type'] ) || $analyse_result['type'] !== 'error',
483 'message' => $analyse_result['message'],
484 'type' => isset( $analyse_result['type'] ) ? $analyse_result['type'] : 'updated',
485 'provider_settings' => $provider_settings
486 ];
487
488 if ( isset( $analyse_result['error_code'] ) ) {
489 $result['error_code'] = sanitize_key( $analyse_result['error_code'] );
490 }
491 }
492 }
493 // handle import
494 } elseif ( isset( $request['post_views_counter_import_views'] ) ) {
495 if ( is_callable( $provider['import'] ) ) {
496 $import_result = call_user_func( $provider['import'], $sanitized_inputs, $strategy );
497
498 if ( isset( $import_result['success'] ) && $import_result['success'] ) {
499 $result = [
500 'success' => true,
501 'message' => $import_result['message'],
502 'type' => isset( $import_result['type'] ) ? $import_result['type'] : 'updated',
503 'provider_settings' => $provider_settings
504 ];
505 } else if ( isset( $import_result['message'] ) ) {
506 $result = [
507 'success' => false,
508 'message' => $import_result['message'],
509 'type' => isset( $import_result['type'] ) ? $import_result['type'] : ( isset( $import_result['success'] ) && ! $import_result['success'] ? 'updated' : 'error' ),
510 'provider_settings' => $provider_settings
511 ];
512 }
513
514 if ( isset( $import_result['error_code'] ) ) {
515 $result['error_code'] = sanitize_key( $import_result['error_code'] );
516 }
517 }
518 }
519
520 return $result;
521 }
522
523 /**
524 * Prepare provider settings from request.
525 *
526 * @param array $request
527 * @return array
528 */
529 public function prepare_provider_settings_from_request( $request ) {
530 // get existing provider settings or initialize
531 $pvc = Post_Views_Counter();
532 $existing_settings = isset( $pvc->options['other']['import_provider_settings'] ) ? $pvc->options['other']['import_provider_settings'] : [];
533
534 // check if provider inputs were submitted
535 if ( isset( $request['pvc_import_provider'], $request['pvc_import_provider_inputs'], $request['pvc_import_strategy'] ) ) {
536 $provider_slug = sanitize_key( $request['pvc_import_provider'] );
537 $provider_inputs = $request['pvc_import_provider_inputs'];
538 $strategy = $this->normalize_strategy( $request['pvc_import_strategy'] );
539
540 // get available providers
541 $providers = $this->get_available_providers();
542
543 // validate provider exists
544 if ( isset( $providers[$provider_slug] ) ) {
545 $provider = $providers[$provider_slug];
546
547 // sanitize provider inputs
548 $sanitized_inputs = [];
549 if ( is_callable( $provider['sanitize'] ) ) {
550 $sanitized_inputs = call_user_func( $provider['sanitize'], $provider_inputs );
551 }
552
553 // update provider settings
554 return array_merge(
555 $existing_settings,
556 [
557 'provider' => $provider_slug,
558 'strategy' => $strategy,
559 $provider_slug => $sanitized_inputs
560 ]
561 );
562 }
563 }
564
565 // preserve existing settings if not changed
566 return $existing_settings;
567 }
568
569 /**
570 * Check if WP-PostViews is available.
571 *
572 * @return bool
573 */
574 public function is_wp_postviews_available() {
575 return function_exists( 'the_views' );
576 }
577
578 /**
579 * Render custom meta key provider fields.
580 *
581 * @return string
582 */
583 public function render_provider_custom_meta_key() {
584 // get main instance
585 $pvc = Post_Views_Counter();
586
587 // get saved meta key or default
588 $meta_key = isset( $pvc->options['other']['import_provider_settings']['custom_meta_key']['meta_key'] ) ? $pvc->options['other']['import_provider_settings']['custom_meta_key']['meta_key'] : 'views';
589
590 // get provider
591 $provider = $this->get_provider( 'custom_meta_key' );
592
593 // generate description
594 $description = $this->generate_provider_description( $provider['supports'], 'custom_meta_key' ) . ' ' . esc_html__( 'Enter the meta key from which the views data is to be retrieved during import.', 'post-views-counter' );
595
596 $html = '
597 <div class="pvc-provider-fields">
598 <input type="text" id="pvc_import_meta_key" class="regular-text" name="pvc_import_provider_inputs[meta_key]" value="' . esc_attr( $meta_key ) . '" />
599 <p class="description">' . $description . '</p>
600 </div>';
601
602 return $html;
603 }
604
605 /**
606 * Sanitize custom meta key provider inputs.
607 *
608 * @param array $inputs
609 * @return array
610 */
611 public function sanitize_provider_custom_meta_key( $inputs ) {
612 $sanitized = [];
613
614 if ( isset( $inputs['meta_key'] ) ) {
615 $sanitized['meta_key'] = sanitize_key( $inputs['meta_key'] );
616 }
617
618 return $sanitized;
619 }
620
621 /**
622 * Analyse custom meta key provider.
623 *
624 * @global object $wpdb
625 *
626 * @param array $inputs
627 * @return array
628 */
629 public function analyse_provider_custom_meta_key( $inputs ) {
630 $meta_key = isset( $inputs['meta_key'] ) ? sanitize_key( $inputs['meta_key'] ) : 'views';
631 $source = $this->collect_meta_source( $meta_key, 'custom_meta_key' );
632
633 if ( $source['status'] === 'error' ) {
634 return [
635 'count' => 0,
636 'message' => __( 'The source data could not be analyzed because a database operation failed.', 'post-views-counter' ),
637 'type' => 'error',
638 'error_code' => $source['error_code']
639 ];
640 }
641
642 if ( empty( $source['rows'] ) ) {
643 return [
644 'count' => 0,
645 'message' => sprintf( __( 'No valid views data found for %s.', 'post-views-counter' ), sprintf( __( 'meta key: %s', 'post-views-counter' ), esc_html( $meta_key ) ) )
646 ];
647 }
648
649 $stats = [
650 'total_views' => $source['total_views'],
651 'posts_processed' => count( $source['totals_map'] ),
652 'source' => $this->get_provider_label( 'custom_meta_key' ),
653 'additional_info' => sprintf( __( 'Meta key "%s".', 'post-views-counter' ), esc_html( $meta_key ) )
654 ];
655
656 return [
657 'count' => count( $source['rows'] ),
658 'message' => $this->generate_import_message( $stats, 'analyze' )
659 ];
660 }
661
662 /**
663 * Import custom meta key provider.
664 *
665 * @global object $wpdb
666 *
667 * @param array $inputs
668 * @param string $strategy
669 * @return array
670 */
671 public function import_provider_custom_meta_key( $inputs, $strategy ) {
672 global $wpdb;
673
674 $meta_key = isset( $inputs['meta_key'] ) ? sanitize_key( $inputs['meta_key'] ) : 'views';
675 $this->start_import_diagnostics( 'custom_meta_key', 'source' );
676 $source = $this->collect_meta_source( $meta_key, 'custom_meta_key' );
677
678 if ( $source['status'] === 'error' ) {
679 return $this->import_failure_result( 'custom_meta_key', $source['error_code'] );
680 }
681
682 if ( empty( $source['rows'] ) ) {
683 $this->log_import_diagnostics();
684 return [
685 'success' => false,
686 'message' => __( 'No valid post data found to import.', 'post-views-counter' )
687 ];
688 }
689
690 $sql = [];
691 foreach ( $source['rows'] as $view ) {
692 $post_id = (int) $view['post_id'];
693 $count = (int) $view['meta_value'];
694
695 $sql[] = $wpdb->prepare( "(%d, 4, 'total', %d)", $post_id, $count );
696 }
697
698 // the raw source rows are no longer needed once tuples are prepared
699 $source['rows'] = null;
700 unset( $source['rows'] );
701
702 $tuple_count = count( $sql );
703 $existing_totals = $this->snapshot_existing_total_counts( $source['totals_map'], $strategy );
704
705 $destination_start = microtime( true );
706 $write_result = $this->execute_provider_insert_query( $sql, $strategy, 'custom_meta_key' );
707 $sql = null;
708 unset( $sql );
709 $this->import_diagnostics['destination_elapsed_ms'] = round( ( microtime( true ) - $destination_start ) * 1000, 3 );
710 $this->import_diagnostics['destination_chunks'] = $write_result['chunks'];
711 if ( $write_result['status'] === 'error' ) {
712 return $this->import_failure_result( 'custom_meta_key', $write_result['error_code'] );
713 }
714
715 $stats = [
716 'total_views' => $source['total_views'],
717 'posts_processed' => count( $source['totals_map'] ),
718 'source' => $this->get_provider_label( 'custom_meta_key' ),
719 'additional_info' => sprintf( __( 'Meta key "%s".', 'post-views-counter' ), esc_html( $meta_key ) )
720 ];
721
722 $this->apply_skip_statistics( $stats, $source['totals_map'], $strategy, $existing_totals, $source['compare_map'] );
723
724 // this provider keeps its legacy absence of pvc_import_after_provider,
725 // but stored counts changed, so PVC's own caches must be invalidated
726 $this->flush_pvc_caches();
727
728 $this->import_diagnostics['destination_rows'] = [ 'total' => $tuple_count ];
729 $this->import_diagnostics['phase'] = 'complete';
730 $this->log_import_diagnostics();
731
732 return [
733 'success' => true,
734 'message' => $this->generate_import_message( $stats )
735 ];
736 }
737
738 /**
739 * Render WP-PostViews provider fields.
740 *
741 * @return string
742 */
743 public function render_provider_wp_postviews() {
744 // get provider
745 $provider = $this->get_provider( 'wp_postviews' );
746
747 // generate description
748 $description = $this->generate_provider_description( $provider['supports'], 'wp_postviews' );
749
750 $html = '
751 <div class="pvc-provider-fields">
752 <p class="description">' . $description . '</p>
753 </div>';
754
755 return $html;
756 }
757
758 /**
759 * Sanitize WP-PostViews provider inputs.
760 *
761 * @param array $inputs
762 * @return array
763 */
764 public function sanitize_provider_wp_postviews( $inputs ) {
765 // no inputs needed for WP-PostViews
766 return [];
767 }
768
769 /**
770 * Analyse WP-PostViews provider.
771 *
772 * @global object $wpdb
773 *
774 * @param array $inputs
775 * @return array
776 */
777 public function analyse_provider_wp_postviews( $inputs ) {
778 $source = $this->collect_meta_source( 'views', 'wp_postviews' );
779
780 if ( $source['status'] === 'error' ) {
781 return [
782 'count' => 0,
783 'message' => __( 'The source data could not be analyzed because a database operation failed.', 'post-views-counter' ),
784 'type' => 'error',
785 'error_code' => $source['error_code']
786 ];
787 }
788
789 if ( empty( $source['rows'] ) ) {
790 // diagnostics are only initialized for imports; analysis must not
791 // emit another provider's stale record
792 return [
793 'count' => 0,
794 'message' => sprintf( __( 'No valid views data found for %s.', 'post-views-counter' ), 'WP-PostViews' )
795 ];
796 }
797
798 $stats = [
799 'total_views' => $source['total_views'],
800 'posts_processed' => count( $source['totals_map'] ),
801 'source' => $this->get_provider_label( 'wp_postviews' )
802 ];
803
804 return [
805 'count' => count( $source['rows'] ),
806 'message' => $this->generate_import_message( $stats, 'analyze' )
807 ];
808 }
809
810 /**
811 * Import WP-PostViews provider.
812 *
813 * @global object $wpdb
814 *
815 * @param array $inputs
816 * @param string $strategy
817 * @return array
818 */
819 public function import_provider_wp_postviews( $inputs, $strategy ) {
820 global $wpdb;
821 $this->start_import_diagnostics( 'wp_postviews', 'source' );
822 $source = $this->collect_meta_source( 'views', 'wp_postviews' );
823
824 if ( $source['status'] === 'error' ) {
825 return $this->import_failure_result( 'wp_postviews', $source['error_code'] );
826 }
827
828 if ( empty( $source['rows'] ) ) {
829 $this->log_import_diagnostics();
830 return [
831 'success' => false,
832 'message' => __( 'No valid post data found to import.', 'post-views-counter' )
833 ];
834 }
835
836 $sql = [];
837 foreach ( $source['rows'] as $view ) {
838 $post_id = (int) $view['post_id'];
839 $count = (int) $view['meta_value'];
840
841 $sql[] = $wpdb->prepare( "(%d, 4, 'total', %d)", $post_id, $count );
842 }
843
844 // the raw source rows are no longer needed once tuples are prepared
845 $source['rows'] = null;
846 unset( $source['rows'] );
847
848 $tuple_count = count( $sql );
849 $existing_totals = $this->snapshot_existing_total_counts( $source['totals_map'], $strategy );
850
851 $destination_start = microtime( true );
852 $write_result = $this->execute_provider_insert_query( $sql, $strategy, 'wp_postviews' );
853 $sql = null;
854 unset( $sql );
855 $this->import_diagnostics['destination_elapsed_ms'] = round( ( microtime( true ) - $destination_start ) * 1000, 3 );
856 $this->import_diagnostics['destination_chunks'] = $write_result['chunks'];
857 if ( $write_result['status'] === 'error' ) {
858 return $this->import_failure_result( 'wp_postviews', $write_result['error_code'] );
859 }
860
861 $stats = [
862 'total_views' => $source['total_views'],
863 'posts_processed' => count( $source['totals_map'] ),
864 'source' => $this->get_provider_label( 'wp_postviews' )
865 ];
866
867 $this->apply_skip_statistics( $stats, $source['totals_map'], $strategy, $existing_totals, $source['compare_map'] );
868
869 // this provider keeps its legacy absence of pvc_import_after_provider,
870 // but stored counts changed, so PVC's own caches must be invalidated
871 $this->flush_pvc_caches();
872
873 $this->import_diagnostics['destination_rows'] = [ 'total' => $tuple_count ];
874 $this->import_diagnostics['phase'] = 'complete';
875 $this->log_import_diagnostics();
876
877 return [
878 'success' => true,
879 'message' => $this->generate_import_message( $stats )
880 ];
881 }
882
883 /**
884 * Collect one indexed metadata source using shared batch mechanics.
885 *
886 * The retained single read avoids forcing a meta_id sort without a matching
887 * composite meta_key/meta_id index.
888 *
889 * @param string $meta_key Metadata key.
890 * @param string $provider Provider slug.
891 * @return array
892 */
893 private function collect_meta_source( $meta_key, $provider ) {
894 global $wpdb;
895
896 $collected = [];
897 $start = microtime( true );
898 $fetch = function ( $cursor, $limit ) use ( $wpdb, $meta_key ) {
899 $wpdb->last_error = '';
900 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value > 0", $meta_key ), ARRAY_A );
901
902 if ( $wpdb->last_error !== '' ) {
903 return [ 'status' => 'error', 'rows' => [], 'next_cursor' => null, 'done' => true, 'error_code' => 'meta_source_query_failed' ];
904 }
905
906 return [ 'status' => 'success', 'rows' => is_array( $rows ) ? $rows : [], 'next_cursor' => null, 'done' => true, 'error_code' => '' ];
907 };
908 $consume = function ( $rows ) use ( &$collected ) {
909 $collected = $rows;
910 };
911 $run = $this->run_source_batches( null, $fetch, '__return_false', $consume );
912
913 if ( $run['status'] === 'error' ) {
914 return [ 'status' => 'error', 'rows' => [], 'totals_map' => [], 'compare_map' => [], 'total_views' => 0, 'error_code' => $run['error_code'] ];
915 }
916
917 $totals_map = [];
918 $compare_map = [];
919 $total_views = 0;
920 foreach ( $collected as $row ) {
921 $post_id = (int) $row['post_id'];
922 $count = (int) $row['meta_value'];
923 $total_views += $count;
924 $totals_map[ $post_id ] = ( isset( $totals_map[ $post_id ] ) ? $totals_map[ $post_id ] : 0 ) + $count;
925 $compare_map[ $post_id ] = isset( $compare_map[ $post_id ] ) ? max( $compare_map[ $post_id ], $count ) : $count;
926 }
927
928 if ( ! empty( $this->import_diagnostics ) && isset( $this->import_diagnostics['provider'] ) && $this->import_diagnostics['provider'] === $provider ) {
929 $this->import_diagnostics['raw_rows'] = count( $collected );
930 $this->import_diagnostics['logical_rows'] = count( $collected );
931 $this->import_diagnostics['distinct_posts'] = count( $totals_map );
932 $this->import_diagnostics['source_batches'] = $run['batches'];
933 $this->import_diagnostics['source_elapsed_ms'] = round( ( microtime( true ) - $start ) * 1000, 3 );
934 }
935
936 return [
937 'status' => 'success',
938 'rows' => $collected,
939 'totals_map' => $totals_map,
940 'compare_map' => $compare_map,
941 'total_views' => $total_views,
942 'error_code' => ''
943 ];
944 }
945
946 /**
947 * Check if Statify is available.
948 *
949 * @return bool
950 */
951 public function is_statify_available() {
952 global $wpdb;
953 $table = esc_sql( isset( $wpdb->statify ) ? $wpdb->statify : $wpdb->prefix . 'statify' );
954 return class_exists( 'Statify' ) && $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) ) === $table;
955 }
956
957 /**
958 * Render Statify provider fields.
959 *
960 * @return string
961 */
962 public function render_provider_statify() {
963 // get provider
964 $provider = $this->get_provider( 'statify' );
965
966 // generate description
967 $description = $this->generate_provider_description( $provider['supports'], 'statify' );
968
969 $html = '
970 <div class="pvc-provider-fields">
971 <p class="description">' . $description . '</p>
972 </div>';
973
974 return $html;
975 }
976
977 /**
978 * Sanitize Statify provider inputs.
979 *
980 * @param array $inputs
981 * @return array
982 */
983 public function sanitize_provider_statify( $inputs ) {
984 // no inputs needed for Statify
985 return [];
986 }
987
988 /**
989 * Analyse Statify provider.
990 *
991 * @global object $wpdb
992 *
993 * @param array $inputs
994 * @return array
995 */
996 public function analyse_provider_statify( $inputs ) {
997 return $this->process_statify_provider( 'analyze', 'merge' );
998 }
999
1000 /**
1001 * Import Statify provider.
1002 *
1003 * @global object $wpdb
1004 *
1005 * @param array $inputs
1006 * @param string $strategy
1007 * @return array
1008 */
1009 public function import_provider_statify( $inputs, $strategy ) {
1010 return $this->process_statify_provider( 'import', $strategy );
1011 }
1012
1013 /**
1014 * Analyze or import Statify through target-keyset source batches.
1015 *
1016 * @param string $mode analyze|import.
1017 * @param string $strategy Import strategy.
1018 * @return array
1019 */
1020 private function process_statify_provider( $mode, $strategy ) {
1021 global $wpdb;
1022
1023 /**
1024 * Fires before a Statify provider operation starts.
1025 *
1026 * This provider-level boundary also covers direct calls to the public
1027 * Statify analyze and import methods.
1028 *
1029 * @since 1.7.15
1030 *
1031 * @param array $context Operation mode, provider slug and normalized strategy.
1032 * @param Post_Views_Counter_Import $importer Import handler instance.
1033 */
1034 do_action( 'pvc_import_before_provider', [
1035 'mode' => $mode,
1036 'provider' => 'statify',
1037 'strategy' => $strategy
1038 ], $this );
1039
1040 if ( $mode === 'import' ) {
1041 $this->start_import_diagnostics( 'statify', 'source' );
1042 }
1043
1044 $source = $this->collect_statify_source( $mode, $strategy );
1045 if ( $source['status'] === 'error' ) {
1046 if ( $mode === 'import' ) {
1047 return $this->import_failure_result( 'statify', $source['error_code'] );
1048 }
1049
1050 return [
1051 'count' => 0,
1052 'message' => __( 'The Statify source could not be analyzed because a database operation failed.', 'post-views-counter' ),
1053 'type' => 'error',
1054 'error_code' => $source['error_code']
1055 ];
1056 }
1057
1058 // rows accepted through pvc_import_handle_non_post_row are valid provider
1059 // work even when no core post tuples remain, so the operation must still
1060 // reach its completion boundary and message filter
1061 $handled_rows = isset( $source['handled_rows'] ) ? (int) $source['handled_rows'] : 0;
1062
1063 if ( empty( $source['stats'] ) && $handled_rows === 0 ) {
1064 if ( $mode === 'analyze' ) {
1065 return [
1066 'count' => 0,
1067 'message' => sprintf( __( 'No valid views data found for %s.', 'post-views-counter' ), 'Statify' )
1068 ];
1069 }
1070
1071 $message = __( 'No valid post data found to import.', 'post-views-counter' );
1072
1073 // safe configuration hint; never a sample of skipped source URLs
1074 if ( empty( $source['tracked_post_types'] ) ) {
1075 $message .= ' ' . __( 'No post types are selected for tracking in settings.', 'post-views-counter' );
1076 }
1077
1078 $this->log_import_diagnostics();
1079
1080 return [ 'success' => false, 'message' => $message ];
1081 }
1082
1083 $period_counts = [ 'daily' => 0, 'weekly' => 0, 'monthly' => 0, 'yearly' => 0 ];
1084 $sql_parts = [];
1085 $totals_map = [];
1086 foreach ( $source['stats'] as $post_id => $periods ) {
1087 foreach ( [ 'daily' => 0, 'weekly' => 1, 'monthly' => 2, 'yearly' => 3 ] as $period_type => $type ) {
1088 foreach ( $periods[ $period_type ] as $period => $count ) {
1089 $period_counts[ $period_type ]++;
1090 if ( $mode === 'import' ) {
1091 $sql_parts[] = $wpdb->prepare( '(%d, %d, %s, %d)', $post_id, $type, $period, $count );
1092 }
1093 }
1094 }
1095
1096 $totals_map[ $post_id ] = $periods['total'];
1097 if ( $mode === 'import' ) {
1098 $sql_parts[] = $wpdb->prepare( "(%d, 4, 'total', %d)", $post_id, $periods['total'] );
1099 }
1100 }
1101
1102 // the normalized period structure is redundant once tuples exist
1103 $source['stats'] = null;
1104 unset( $source['stats'] );
1105
1106 if ( $mode === 'import' ) {
1107 // eligibility must be measured before the destination changes
1108 $existing_totals = $this->snapshot_existing_total_counts( $totals_map, $strategy );
1109
1110 $destination_start = microtime( true );
1111 $write_result = $this->execute_provider_insert_query( $sql_parts, $strategy, 'statify' );
1112 $sql_parts = null;
1113 unset( $sql_parts );
1114 $this->import_diagnostics['destination_elapsed_ms'] = round( ( microtime( true ) - $destination_start ) * 1000, 3 );
1115 $this->import_diagnostics['destination_chunks'] = $write_result['chunks'];
1116 if ( $write_result['status'] === 'error' ) {
1117 return $this->import_failure_result( 'statify', $write_result['error_code'] );
1118 }
1119
1120 do_action( 'pvc_import_after_provider', [
1121 'strategy' => $strategy,
1122 'source' => 'statify',
1123 'use_gmt' => $source['use_gmt']
1124 ] );
1125
1126 $this->flush_pvc_caches();
1127 }
1128
1129 $message_stats = [
1130 'total_views' => array_sum( $totals_map ),
1131 'posts_processed' => count( $totals_map ),
1132 'periods' => $period_counts,
1133 'source' => $this->get_provider_label( 'statify' )
1134 ];
1135
1136 if ( empty( $totals_map ) ) {
1137 $message_stats['no_post_rows_handled'] = true;
1138 }
1139
1140 if ( ! empty( $source['skipped_targets'] ) ) {
1141 $message_stats['additional_info'] = sprintf(
1142 $mode === 'analyze' ? __( 'Would skip %s non-post URLs.', 'post-views-counter' ) : __( 'Skipped %s non-post URLs.', 'post-views-counter' ),
1143 number_format_i18n( count( $source['skipped_targets'] ) )
1144 );
1145 }
1146
1147 if ( $mode === 'import' ) {
1148 $this->apply_skip_statistics( $message_stats, $totals_map, $strategy, $existing_totals );
1149 }
1150
1151 $message_stats = apply_filters( 'pvc_import_message_stats', $message_stats, [
1152 'mode' => $mode,
1153 'source' => 'statify'
1154 ] );
1155
1156 if ( $mode === 'import' ) {
1157 $this->import_diagnostics['destination_rows'] = array_merge( $period_counts, [ 'total' => count( $totals_map ) ] );
1158 $this->import_diagnostics['phase'] = 'complete';
1159 $this->log_import_diagnostics();
1160 return [ 'success' => true, 'message' => $this->generate_import_message( $message_stats ) ];
1161 }
1162
1163 return [ 'count' => array_sum( $totals_map ), 'message' => $this->generate_import_message( $message_stats, 'analyze' ) ];
1164 }
1165
1166 /**
1167 * Collect normalized Statify counts from target-keyset batches.
1168 *
1169 * @param string $mode analyze|import.
1170 * @param string $strategy Import strategy.
1171 * @return array
1172 */
1173 private function collect_statify_source( $mode, $strategy ) {
1174 global $wpdb;
1175
1176 $table = esc_sql( isset( $wpdb->statify ) ? $wpdb->statify : $wpdb->prefix . 'statify' );
1177 $settings = Post_Views_Counter()->options['general'];
1178 $use_gmt = $settings['count_time'] === 'gmt';
1179 $tracked_post_types = array_map( 'sanitize_key', (array) $settings['post_types_count'] );
1180 $this->statify_post_cache = [];
1181
1182 $wpdb->last_error = '';
1183 $snapshot = $wpdb->get_var( "SELECT MAX(id) FROM `{$table}`" );
1184 if ( $wpdb->last_error !== '' ) {
1185 return [ 'status' => 'error', 'error_code' => 'statify_snapshot_failed' ];
1186 }
1187
1188 if ( $snapshot === null ) {
1189 return [
1190 'status' => 'success',
1191 'stats' => [],
1192 'skipped_targets' => [],
1193 'handled_rows' => 0,
1194 'tracked_post_types' => $tracked_post_types,
1195 'use_gmt' => $use_gmt,
1196 'error_code' => ''
1197 ];
1198 }
1199
1200 $snapshot = (int) $snapshot;
1201 $stats = [];
1202 $skipped_targets = [];
1203 $handled_rows = 0;
1204 $raw_rows = 0;
1205 $logical_rows = 0;
1206 $date_min = '';
1207 $date_max = '';
1208 $mapping_elapsed = 0;
1209 $source_start = microtime( true );
1210
1211 $fetch = function ( $cursor, $limit ) use ( $wpdb, $table, $snapshot ) {
1212 $wpdb->last_error = '';
1213 if ( $cursor === null ) {
1214 $target_query = $wpdb->prepare( "SELECT DISTINCT target FROM `{$table}` WHERE id <= %d ORDER BY target LIMIT %d", $snapshot, $limit );
1215 } else {
1216 $target_query = $wpdb->prepare( "SELECT DISTINCT target FROM `{$table}` WHERE id <= %d AND target > %s ORDER BY target LIMIT %d", $snapshot, $cursor, $limit );
1217 }
1218 $targets = $wpdb->get_col( $target_query );
1219 if ( $wpdb->last_error !== '' ) {
1220 return [ 'status' => 'error', 'rows' => [], 'next_cursor' => $cursor, 'done' => true, 'error_code' => 'statify_target_batch_failed' ];
1221 }
1222
1223 if ( empty( $targets ) ) {
1224 return [ 'status' => 'success', 'rows' => [], 'next_cursor' => $cursor, 'done' => true, 'error_code' => '' ];
1225 }
1226
1227 $placeholders = implode( ',', array_fill( 0, count( $targets ), '%s' ) );
1228 $query_args = array_merge( [ $snapshot ], $targets );
1229 $aggregate_query = $wpdb->prepare( "SELECT target, created, COUNT(*) AS views FROM `{$table}` WHERE id <= %d AND target IN ({$placeholders}) GROUP BY target, created ORDER BY target, created", $query_args );
1230 $wpdb->last_error = '';
1231 $rows = $wpdb->get_results( $aggregate_query, ARRAY_A );
1232 if ( $wpdb->last_error !== '' ) {
1233 return [ 'status' => 'error', 'rows' => [], 'next_cursor' => end( $targets ), 'done' => true, 'error_code' => 'statify_aggregate_batch_failed' ];
1234 }
1235
1236 return [
1237 'status' => 'success',
1238 'rows' => is_array( $rows ) ? $rows : [],
1239 'next_cursor' => end( $targets ),
1240 'done' => count( $targets ) < $limit,
1241 'error_code' => ''
1242 ];
1243 };
1244
1245 // the SQL predicate target > last_target is authoritative; MySQL orders by
1246 // the column collation, so a PHP byte comparison would reject legitimate
1247 // mixed-case and non-ASCII progress. Only identity is checked here, with
1248 // MAX_SOURCE_BATCHES remaining the runaway-loop guard.
1249 $advanced = function ( $cursor, $next_cursor ) {
1250 return is_string( $next_cursor ) && $next_cursor !== $cursor;
1251 };
1252
1253 $consume = function ( $rows ) use ( &$stats, &$skipped_targets, &$handled_rows, &$raw_rows, &$logical_rows, &$date_min, &$date_max, &$mapping_elapsed, $tracked_post_types, $mode, $strategy, $use_gmt ) {
1254 foreach ( $rows as $row ) {
1255 $views = (int) $row['views'];
1256 $raw_rows += $views;
1257 $logical_rows++;
1258
1259 $mapping_start = microtime( true );
1260 $content = $this->map_target_to_content( $row['target'], $tracked_post_types, 'statify' );
1261 $mapping_elapsed += microtime( true ) - $mapping_start;
1262 $post_id = isset( $content['content_id'] ) ? (int) $content['content_id'] : 0;
1263 if ( ! $post_id ) {
1264 $skipped_targets[ $row['target'] ] = true;
1265 continue;
1266 }
1267
1268 $timestamp = strtotime( $row['created'] );
1269 $period_keys = $this->get_period_keys_from_timestamp( $timestamp, $use_gmt );
1270 if ( isset( $content['content_type'] ) && $content['content_type'] !== 'post' ) {
1271 $non_post_context = [
1272 'mode' => $mode,
1273 'source' => 'statify',
1274 'row' => $row,
1275 'content' => $content,
1276 'period_keys' => $period_keys,
1277 'timestamp' => $timestamp,
1278 'use_gmt' => $use_gmt
1279 ];
1280 if ( $mode === 'import' ) {
1281 $non_post_context['strategy'] = $strategy;
1282 }
1283 $handled = apply_filters( 'pvc_import_handle_non_post_row', false, $non_post_context, $this );
1284 if ( $handled ) {
1285 $handled_rows++;
1286 continue;
1287 }
1288 $skipped_targets[ $row['target'] ] = true;
1289 continue;
1290 }
1291
1292 if ( $this->is_valid_source_date( $row['created'] ) ) {
1293 $date_min = $date_min === '' || $row['created'] < $date_min ? $row['created'] : $date_min;
1294 $date_max = $date_max === '' || $row['created'] > $date_max ? $row['created'] : $date_max;
1295 }
1296
1297 if ( ! isset( $stats[ $post_id ] ) ) {
1298 $stats[ $post_id ] = [ 'daily' => [], 'weekly' => [], 'monthly' => [], 'yearly' => [], 'total' => 0 ];
1299 }
1300
1301 foreach ( [ 'day' => 'daily', 'week' => 'weekly', 'month' => 'monthly', 'year' => 'yearly' ] as $key => $period_type ) {
1302 $period = $period_keys[ $key ];
1303 $stats[ $post_id ][ $period_type ][ $period ] = ( isset( $stats[ $post_id ][ $period_type ][ $period ] ) ? $stats[ $post_id ][ $period_type ][ $period ] : 0 ) + $views;
1304 }
1305 $stats[ $post_id ]['total'] += $views;
1306 }
1307 };
1308
1309 $run = $this->run_source_batches( null, $fetch, $advanced, $consume );
1310 if ( $run['status'] === 'error' ) {
1311 return [ 'status' => 'error', 'error_code' => $run['error_code'] ];
1312 }
1313
1314 if ( $mode === 'import' ) {
1315 $this->import_diagnostics['snapshot_boundary'] = $snapshot;
1316 $this->import_diagnostics['raw_rows'] = $raw_rows;
1317 $this->import_diagnostics['logical_rows'] = $logical_rows;
1318 $this->import_diagnostics['distinct_targets'] = count( $this->statify_post_cache );
1319 $this->import_diagnostics['distinct_posts'] = count( $stats );
1320 $this->import_diagnostics['skipped_count'] = count( $skipped_targets );
1321 $this->import_diagnostics['handled_non_post_rows'] = $handled_rows;
1322 $this->import_diagnostics['source_date_min'] = $date_min;
1323 $this->import_diagnostics['source_date_max'] = $date_max;
1324 $this->import_diagnostics['source_batches'] = $run['batches'];
1325 $this->import_diagnostics['source_elapsed_ms'] = round( max( 0, ( microtime( true ) - $source_start ) - $mapping_elapsed ) * 1000, 3 );
1326 $this->import_diagnostics['mapping_elapsed_ms'] = round( $mapping_elapsed * 1000, 3 );
1327 }
1328
1329 return [
1330 'status' => 'success',
1331 'stats' => $stats,
1332 'skipped_targets' => $skipped_targets,
1333 'handled_rows' => $handled_rows,
1334 'tracked_post_types' => $tracked_post_types,
1335 'use_gmt' => $use_gmt,
1336 'error_code' => ''
1337 ];
1338 }
1339
1340 /**
1341 * Check if Page Views Count is available.
1342 *
1343 * @return bool
1344 */
1345 public function is_page_views_count_available() {
1346 global $wpdb;
1347 $table_total = $wpdb->prefix . 'pvc_total';
1348 $table_daily = $wpdb->prefix . 'pvc_daily';
1349 return $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table_total ) ) === $table_total &&
1350 $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table_daily ) ) === $table_daily;
1351 }
1352
1353 /**
1354 * Render Page Views Count provider fields.
1355 *
1356 * @return string
1357 */
1358 public function render_provider_page_views_count() {
1359 // get provider
1360 $provider = $this->get_provider( 'page_views_count' );
1361
1362 // generate description
1363 $description = $this->generate_provider_description( $provider['supports'], 'page_views_count' );
1364
1365 $html = '
1366 <div class="pvc-provider-fields">
1367 <p class="description">' . $description . '</p>
1368 </div>';
1369
1370 return $html;
1371 }
1372
1373 /**
1374 * Sanitize Page Views Count provider inputs.
1375 *
1376 * @param array $inputs
1377 * @return array
1378 */
1379 public function sanitize_provider_page_views_count( $inputs ) {
1380 // no inputs needed for Page Views Count
1381 return [];
1382 }
1383
1384 /**
1385 * Analyse Page Views Count provider.
1386 *
1387 * @global object $wpdb
1388 *
1389 * @param array $inputs
1390 * @return array
1391 */
1392 public function analyse_provider_page_views_count( $inputs ) {
1393 return $this->process_page_views_count_provider( 'analyze', 'merge' );
1394 }
1395
1396 /**
1397 * Import Page Views Count provider.
1398 *
1399 * @global object $wpdb
1400 *
1401 * @param array $inputs
1402 * @param string $strategy
1403 * @return array
1404 */
1405 public function import_provider_page_views_count( $inputs, $strategy ) {
1406 return $this->process_page_views_count_provider( 'import', $strategy );
1407 }
1408
1409 /**
1410 * Analyze or import normalized Page Views Count streams.
1411 *
1412 * @param string $mode analyze|import.
1413 * @param string $strategy Import strategy.
1414 * @return array
1415 */
1416 private function process_page_views_count_provider( $mode, $strategy ) {
1417 global $wpdb;
1418
1419 if ( $mode === 'import' ) {
1420 $this->start_import_diagnostics( 'page_views_count', 'source' );
1421 }
1422
1423 $source = $this->collect_page_views_count_source( $mode );
1424 if ( $source['status'] === 'error' ) {
1425 if ( $mode === 'import' ) {
1426 return $this->import_failure_result( 'page_views_count', $source['error_code'] );
1427 }
1428
1429 return [
1430 'count' => 0,
1431 'message' => __( 'The Page Views Count source could not be analyzed because a database operation failed.', 'post-views-counter' ),
1432 'type' => 'error',
1433 'error_code' => $source['error_code']
1434 ];
1435 }
1436
1437 if ( empty( $source['posts'] ) ) {
1438 $message = $mode === 'analyze'
1439 ? sprintf( __( 'No valid views data found for %s.', 'post-views-counter' ), 'Page Views Count' )
1440 : __( 'No valid post data found to import.', 'post-views-counter' );
1441 if ( $mode === 'import' ) {
1442 $this->log_import_diagnostics();
1443 }
1444 return $mode === 'analyze' ? [ 'count' => 0, 'message' => $message ] : [ 'success' => false, 'message' => $message ];
1445 }
1446
1447 $period_counts = [ 'daily' => 0, 'weekly' => 0, 'monthly' => 0, 'yearly' => 0 ];
1448 $sql_parts = [];
1449 if ( $mode === 'import' ) {
1450 foreach ( $source['totals'] as $post_id => $count ) {
1451 $sql_parts[] = $wpdb->prepare( "(%d, 4, 'total', %d)", $post_id, $count );
1452 }
1453 }
1454
1455 foreach ( $source['periods'] as $post_id => $periods ) {
1456 foreach ( [ 'daily' => 0, 'weekly' => 1, 'monthly' => 2, 'yearly' => 3 ] as $period_type => $type ) {
1457 foreach ( $periods[ $period_type ] as $period => $count ) {
1458 $period_counts[ $period_type ]++;
1459 if ( $mode === 'import' ) {
1460 $sql_parts[] = $wpdb->prepare( '(%d, %d, %s, %d)', $post_id, $type, $period, $count );
1461 }
1462 }
1463 }
1464 }
1465
1466 if ( $mode === 'import' ) {
1467 // eligibility must be measured before the destination changes
1468 $existing_totals = $this->snapshot_existing_total_counts( $source['totals'], $strategy );
1469
1470 $destination_start = microtime( true );
1471 $write_result = $this->execute_provider_insert_query( $sql_parts, $strategy, 'page_views_count' );
1472 $sql_parts = null;
1473 unset( $sql_parts );
1474 $this->import_diagnostics['destination_elapsed_ms'] = round( ( microtime( true ) - $destination_start ) * 1000, 3 );
1475 $this->import_diagnostics['destination_chunks'] = $write_result['chunks'];
1476 if ( $write_result['status'] === 'error' ) {
1477 return $this->import_failure_result( 'page_views_count', $write_result['error_code'] );
1478 }
1479
1480 do_action( 'pvc_import_after_provider', [
1481 'strategy' => $strategy,
1482 'source' => 'page_views_count',
1483 'use_gmt' => false
1484 ] );
1485 $this->flush_pvc_caches();
1486 }
1487
1488 $period_only_posts = 0;
1489 foreach ( $source['periods'] as $post_id => $periods_data ) {
1490 if ( ! isset( $source['totals'][ $post_id ] ) ) {
1491 $period_only_posts++;
1492 }
1493 }
1494
1495 // the total-views sentence describes posts with valid source totals only;
1496 // daily-only posts are reported once, as additional posts
1497 $message_stats = [
1498 'total_views' => array_sum( $source['totals'] ),
1499 'posts_processed' => count( $source['totals'] ),
1500 'source' => $this->get_provider_label( 'page_views_count' )
1501 ];
1502 if ( empty( $source['totals'] ) ) {
1503 $message_stats['period_data_only'] = true;
1504 $message_stats['posts_processed'] = count( $source['periods'] );
1505 } elseif ( $period_only_posts > 0 ) {
1506 $message_stats['period_only_posts'] = $period_only_posts;
1507 }
1508 if ( $mode === 'import' ) {
1509 $message_stats['periods'] = $period_counts;
1510 $this->apply_skip_statistics( $message_stats, $source['totals'], $strategy, $existing_totals );
1511
1512 // analysis intentionally keeps its legacy absence of this filter
1513 $message_stats = apply_filters( 'pvc_import_message_stats', $message_stats, [
1514 'mode' => $mode,
1515 'source' => 'page_views_count'
1516 ] );
1517
1518 $this->import_diagnostics['destination_rows'] = array_merge( $period_counts, [ 'total' => count( $source['totals'] ) ] );
1519 $this->import_diagnostics['phase'] = 'complete';
1520 $this->log_import_diagnostics();
1521 return [ 'success' => true, 'message' => $this->generate_import_message( $message_stats ) ];
1522 }
1523
1524 return [
1525 'count' => array_sum( $source['totals'] ) + $source['daily_views'],
1526 'message' => $this->generate_import_message( $message_stats, 'analyze' )
1527 ];
1528 }
1529
1530 /**
1531 * Read Page Views Count totals and dailies as independent ID streams.
1532 *
1533 * @param string $mode analyze|import.
1534 * @return array
1535 */
1536 private function collect_page_views_count_source( $mode ) {
1537 global $wpdb;
1538
1539 $total_table = esc_sql( $wpdb->prefix . 'pvc_total' );
1540 $daily_table = esc_sql( $wpdb->prefix . 'pvc_daily' );
1541 $tracked_post_types = array_map( 'sanitize_key', (array) Post_Views_Counter()->options['general']['post_types_count'] );
1542 $post_types = [];
1543 $totals = [];
1544 $periods = [];
1545 $posts = [];
1546 $daily_views = 0;
1547 $raw_rows = 0;
1548 $date_min = '';
1549 $date_max = '';
1550 $source_start = microtime( true );
1551
1552 $wpdb->last_error = '';
1553 $total_snapshot = $wpdb->get_var( "SELECT MAX(id) FROM `{$total_table}`" );
1554 if ( $wpdb->last_error !== '' ) {
1555 return [ 'status' => 'error', 'error_code' => 'page_views_total_snapshot_failed' ];
1556 }
1557 $wpdb->last_error = '';
1558 $daily_snapshot = $wpdb->get_var( "SELECT MAX(id) FROM `{$daily_table}`" );
1559 if ( $wpdb->last_error !== '' ) {
1560 return [ 'status' => 'error', 'error_code' => 'page_views_daily_snapshot_failed' ];
1561 }
1562
1563 $total_snapshot = $total_snapshot === null ? 0 : (int) $total_snapshot;
1564 $daily_snapshot = $daily_snapshot === null ? 0 : (int) $daily_snapshot;
1565 $advanced = function ( $cursor, $next_cursor ) {
1566 return (int) $next_cursor > (int) $cursor;
1567 };
1568
1569 $total_fetch = function ( $cursor, $limit ) use ( $wpdb, $total_table, $total_snapshot ) {
1570 if ( $total_snapshot === 0 ) {
1571 return [ 'status' => 'success', 'rows' => [], 'next_cursor' => $cursor, 'done' => true, 'error_code' => '' ];
1572 }
1573 $wpdb->last_error = '';
1574 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, postnum, postcount FROM `{$total_table}` WHERE id > %d AND id <= %d ORDER BY id LIMIT %d", $cursor, $total_snapshot, $limit ), ARRAY_A );
1575 if ( $wpdb->last_error !== '' ) {
1576 return [ 'status' => 'error', 'rows' => [], 'next_cursor' => $cursor, 'done' => true, 'error_code' => 'page_views_total_batch_failed' ];
1577 }
1578 $next_cursor = empty( $rows ) ? $cursor : (int) $rows[ count( $rows ) - 1 ]['id'];
1579 return [ 'status' => 'success', 'rows' => $rows, 'next_cursor' => $next_cursor, 'done' => count( $rows ) < $limit, 'error_code' => '' ];
1580 };
1581 $total_consume = function ( $rows ) use ( &$totals, &$posts, &$post_types, &$raw_rows, $tracked_post_types ) {
1582 foreach ( $rows as $row ) {
1583 $raw_rows++;
1584 $post_id = $this->parse_positive_integer( $row['postnum'] );
1585 $count = $this->parse_positive_integer( $row['postcount'] );
1586 if ( ! $post_id || ! $count || ! $this->is_tracked_source_post( $post_id, $tracked_post_types, $post_types ) ) {
1587 continue;
1588 }
1589 $totals[ $post_id ] = $count;
1590 $posts[ $post_id ] = true;
1591 }
1592 };
1593 $total_run = $this->run_source_batches( 0, $total_fetch, $advanced, $total_consume );
1594 if ( $total_run['status'] === 'error' ) {
1595 return [ 'status' => 'error', 'error_code' => $total_run['error_code'] ];
1596 }
1597
1598 $daily_fetch = function ( $cursor, $limit ) use ( $wpdb, $daily_table, $daily_snapshot ) {
1599 if ( $daily_snapshot === 0 ) {
1600 return [ 'status' => 'success', 'rows' => [], 'next_cursor' => $cursor, 'done' => true, 'error_code' => '' ];
1601 }
1602 $wpdb->last_error = '';
1603 $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, time, postnum, postcount FROM `{$daily_table}` WHERE id > %d AND id <= %d ORDER BY id LIMIT %d", $cursor, $daily_snapshot, $limit ), ARRAY_A );
1604 if ( $wpdb->last_error !== '' ) {
1605 return [ 'status' => 'error', 'rows' => [], 'next_cursor' => $cursor, 'done' => true, 'error_code' => 'page_views_daily_batch_failed' ];
1606 }
1607 $next_cursor = empty( $rows ) ? $cursor : (int) $rows[ count( $rows ) - 1 ]['id'];
1608 return [ 'status' => 'success', 'rows' => $rows, 'next_cursor' => $next_cursor, 'done' => count( $rows ) < $limit, 'error_code' => '' ];
1609 };
1610 $daily_consume = function ( $rows ) use ( &$periods, &$posts, &$post_types, &$daily_views, &$raw_rows, &$date_min, &$date_max, $tracked_post_types ) {
1611 foreach ( $rows as $row ) {
1612 $raw_rows++;
1613 $post_id = $this->parse_positive_integer( $row['postnum'] );
1614 $count = $this->parse_positive_integer( $row['postcount'] );
1615 if ( ! $post_id || ! $count || ! $this->is_valid_source_date( $row['time'] ) || ! $this->is_tracked_source_post( $post_id, $tracked_post_types, $post_types ) ) {
1616 continue;
1617 }
1618
1619 $timestamp = strtotime( $row['time'] . ' 00:00:00' );
1620 $keys = $this->get_period_keys_from_timestamp( $timestamp, false );
1621 if ( ! isset( $periods[ $post_id ] ) ) {
1622 $periods[ $post_id ] = [ 'daily' => [], 'weekly' => [], 'monthly' => [], 'yearly' => [] ];
1623 }
1624 foreach ( [ 'day' => 'daily', 'week' => 'weekly', 'month' => 'monthly', 'year' => 'yearly' ] as $key => $period_type ) {
1625 $period = $keys[ $key ];
1626 $periods[ $post_id ][ $period_type ][ $period ] = ( isset( $periods[ $post_id ][ $period_type ][ $period ] ) ? $periods[ $post_id ][ $period_type ][ $period ] : 0 ) + $count;
1627 }
1628 $daily_views += $count;
1629 $posts[ $post_id ] = true;
1630 $date_min = $date_min === '' || $row['time'] < $date_min ? $row['time'] : $date_min;
1631 $date_max = $date_max === '' || $row['time'] > $date_max ? $row['time'] : $date_max;
1632 }
1633 };
1634 $daily_run = $this->run_source_batches( 0, $daily_fetch, $advanced, $daily_consume );
1635 if ( $daily_run['status'] === 'error' ) {
1636 return [ 'status' => 'error', 'error_code' => $daily_run['error_code'] ];
1637 }
1638
1639 ksort( $totals );
1640 ksort( $periods );
1641 if ( $mode === 'import' ) {
1642 $this->import_diagnostics['snapshot_boundary'] = [ 'total' => $total_snapshot, 'daily' => $daily_snapshot ];
1643 $this->import_diagnostics['raw_rows'] = $raw_rows;
1644 $this->import_diagnostics['logical_rows'] = count( $totals ) + array_sum( array_map( function ( $item ) { return count( $item['daily'] ); }, $periods ) );
1645 $this->import_diagnostics['distinct_posts'] = count( $posts );
1646 $this->import_diagnostics['source_date_min'] = $date_min;
1647 $this->import_diagnostics['source_date_max'] = $date_max;
1648 $this->import_diagnostics['source_batches'] = $total_run['batches'] + $daily_run['batches'];
1649 $this->import_diagnostics['source_elapsed_ms'] = round( ( microtime( true ) - $source_start ) * 1000, 3 );
1650 }
1651
1652 return [
1653 'status' => 'success',
1654 'totals' => $totals,
1655 'periods' => $periods,
1656 'posts' => $posts,
1657 'daily_views' => $daily_views,
1658 'error_code' => ''
1659 ];
1660 }
1661
1662 /**
1663 * Parse a positive decimal integer without accepting partial strings.
1664 *
1665 * @param mixed $value Source value.
1666 * @return int
1667 */
1668 private function parse_positive_integer( $value ) {
1669 $value = is_scalar( $value ) ? trim( (string) $value ) : '';
1670 if ( $value === '' || preg_match( '/^\d+$/', $value ) !== 1 ) {
1671 return 0;
1672 }
1673
1674 $value = (int) $value;
1675 return $value > 0 ? $value : 0;
1676 }
1677
1678 /**
1679 * Validate a source date exactly.
1680 *
1681 * Rejects malformed values, zero dates, year zero and impossible calendar
1682 * dates. This strict policy applies to Page Views Count source rows and to
1683 * the reported source date range; Statify row handling is unchanged (D8-8).
1684 *
1685 * @param mixed $value Source date.
1686 * @return bool
1687 */
1688 private function is_valid_source_date( $value ) {
1689 if ( ! is_string( $value ) || preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $parts ) !== 1 ) {
1690 return false;
1691 }
1692
1693 $year = (int) $parts[1];
1694
1695 // a four-digit format still allows year zero, which is not a real date
1696 if ( $year < 1 || ! checkdate( (int) $parts[2], (int) $parts[3], $year ) ) {
1697 return false;
1698 }
1699
1700 $date = DateTime::createFromFormat( '!Y-m-d', $value );
1701 return $date instanceof DateTime && $date->format( 'Y-m-d' ) === $value;
1702 }
1703
1704 /**
1705 * Resolve and cache whether a source post is tracked.
1706 *
1707 * @param int $post_id Post ID.
1708 * @param array $tracked_post_types Tracked types.
1709 * @param array $cache Request-local type cache.
1710 * @return bool
1711 */
1712 private function is_tracked_source_post( $post_id, $tracked_post_types, &$cache ) {
1713 if ( ! array_key_exists( $post_id, $cache ) ) {
1714 $cache[ $post_id ] = get_post_type( $post_id );
1715 }
1716
1717 return $cache[ $post_id ] && in_array( $cache[ $post_id ], $tracked_post_types, true );
1718 }
1719
1720 /**
1721 * Get SQL clause for the provided strategy.
1722 *
1723 * @param string $strategy
1724 * @param string $provider
1725 * @return string
1726 */
1727 private function get_strategy_on_duplicate_clause( $strategy, $provider ) {
1728 $strategy_key = sanitize_key( $strategy );
1729
1730 $clauses = [
1731 'override' => 'count = VALUES(count)',
1732 'merge' => 'count = count + VALUES(count)'
1733 ];
1734
1735 $clause = isset( $clauses[ $strategy_key ] ) ? $clauses[ $strategy_key ] : $clauses['merge'];
1736
1737 /**
1738 * Filter the SQL ON DUPLICATE KEY UPDATE clause used during import.
1739 *
1740 * @since 1.5.10
1741 *
1742 * @param string $clause SQL clause for the duplicate key handler.
1743 * @param array $context {
1744 * @type string $strategy Selected strategy key.
1745 * @type string $provider Provider slug.
1746 * }
1747 * @param Post_Views_Counter_Import $importer Import handler instance.
1748 */
1749 return apply_filters( 'pvc_import_strategy_clause', $clause, [
1750 'strategy' => $strategy_key,
1751 'provider' => $provider
1752 ], $this );
1753 }
1754
1755 /**
1756 * Execute a provider insert query.
1757 *
1758 * @param array $sql_parts Prepared SQL value tuples.
1759 * @param string $strategy Selected strategy.
1760 * @param string $provider Provider slug.
1761 * @return array Structured result with status, affected_rows, chunks and error_code.
1762 */
1763 private function execute_provider_insert_query( $sql_parts, $strategy, $provider ) {
1764 global $wpdb;
1765
1766 if ( empty( $sql_parts ) ) {
1767 return $this->destination_success_result( 0, 0, 'not_applicable' );
1768 }
1769
1770 $on_duplicate = $this->get_strategy_on_duplicate_clause( $strategy, $provider );
1771 $table = $wpdb->prefix . 'post_views';
1772 $query_prefix = "INSERT INTO {$table} (id, type, period, count) VALUES ";
1773 $query_suffix = " ON DUPLICATE KEY UPDATE {$on_duplicate}";
1774 $has_query_filter = has_filter( 'pvc_import_provider_query' ) !== false;
1775
1776 // derive the equivalent legacy query size without joining every tuple
1777 $query_bytes = strlen( $query_prefix ) + strlen( $query_suffix ) + count( $sql_parts ) - 1;
1778 foreach ( $sql_parts as $tuple ) {
1779 $query_bytes += strlen( $tuple );
1780 }
1781
1782 $this->import_diagnostics['destination_tuple_count'] = count( $sql_parts );
1783 $this->import_diagnostics['destination_query_bytes'] = $query_bytes;
1784
1785 if ( $has_query_filter ) {
1786 $query = $query_prefix . implode( ',', $sql_parts ) . $query_suffix;
1787
1788 /**
1789 * Filter the SQL query used when inserting provider data.
1790 *
1791 * Returning false skips the default query. The legacy full query and
1792 * complete context are passed exactly once when callbacks are attached.
1793 *
1794 * @since 1.5.10
1795 *
1796 * @param string|false $query SQL query string or false to skip execution.
1797 * @param array $context Provider, strategy, tuples and clause.
1798 * @param Post_Views_Counter_Import $importer Import handler instance.
1799 */
1800 $query = apply_filters( 'pvc_import_provider_query', $query, [
1801 'strategy' => sanitize_key( $strategy ),
1802 'provider' => $provider,
1803 'sql_parts' => $sql_parts,
1804 'on_duplicate' => $on_duplicate
1805 ], $this );
1806
1807 if ( $query === false ) {
1808 return [
1809 'status' => 'handled',
1810 'affected_rows' => 0,
1811 'affected_rows_known' => true,
1812 'chunks' => 0,
1813 'transaction_state' => 'not_applicable',
1814 'error_code' => ''
1815 ];
1816 }
1817
1818 if ( ! is_string( $query ) || $wpdb->query( $query ) === false ) {
1819 return $this->destination_error_result( 'destination_write_failed', 1, 'not_applicable', 'destination_write_failed' );
1820 }
1821
1822 return $this->destination_success_result( (int) $wpdb->rows_affected, 1, 'not_applicable' );
1823 }
1824
1825 $engine = $this->get_destination_engine( $table );
1826 if ( $engine !== 'innodb' ) {
1827 $result = $wpdb->query( $query_prefix . implode( ',', $sql_parts ) . $query_suffix );
1828 if ( $result === false ) {
1829 return $this->destination_error_result( 'destination_write_failed', 1, 'not_applicable', 'destination_write_failed' );
1830 }
1831
1832 return $this->destination_success_result( (int) $result, 1, 'not_applicable' );
1833 }
1834
1835 if ( $wpdb->query( 'START TRANSACTION' ) === false ) {
1836 return $this->destination_error_result( 'transaction_start_failed', 0, 'not_started', 'transaction_start_failed' );
1837 }
1838
1839 $affected_rows = 0;
1840 $chunks = 0;
1841 foreach ( array_chunk( $sql_parts, self::DESTINATION_CHUNK_SIZE ) as $chunk ) {
1842 $chunk_query = $query_prefix . implode( ',', $chunk ) . $query_suffix;
1843 $result = $wpdb->query( $chunk_query );
1844
1845 if ( $result === false ) {
1846 // a failed rollback must never be reported as a clean rollback
1847 if ( $wpdb->query( 'ROLLBACK' ) === false ) {
1848 return $this->destination_error_result( 'transaction_rollback_failed', $chunks, 'uncertain', 'destination_chunk_failed' );
1849 }
1850
1851 return $this->destination_error_result( 'destination_chunk_failed', $chunks, 'rolled_back', 'destination_chunk_failed' );
1852 }
1853
1854 $affected_rows += (int) $result;
1855 $chunks++;
1856 }
1857
1858 if ( $wpdb->query( 'COMMIT' ) === false ) {
1859 // the unit may or may not have been committed; the rollback attempt
1860 // below only releases session state so a later START TRANSACTION
1861 // cannot implicitly commit it, and is not a claim that it was undone
1862 $wpdb->query( 'ROLLBACK' );
1863
1864 return $this->destination_error_result( 'transaction_commit_state_uncertain', $chunks, 'uncertain', 'transaction_commit_failed' );
1865 }
1866
1867 return $this->destination_success_result( $affected_rows, $chunks, 'committed' );
1868 }
1869
1870 /**
1871 * Build a destination success result with a known transaction outcome.
1872 *
1873 * @param int $affected_rows Destination affected rows.
1874 * @param int $chunks Executed chunk count.
1875 * @param string $transaction_state Resolved transaction state.
1876 * @return array
1877 */
1878 private function destination_success_result( $affected_rows, $chunks, $transaction_state ) {
1879 $this->import_diagnostics['destination_affected_rows'] = (int) $affected_rows;
1880 $this->import_diagnostics['destination_affected_rows_known'] = true;
1881 $this->import_diagnostics['transaction_state'] = $transaction_state;
1882
1883 return [
1884 'status' => 'success',
1885 'affected_rows' => (int) $affected_rows,
1886 'affected_rows_known' => true,
1887 'chunks' => (int) $chunks,
1888 'transaction_state' => $transaction_state,
1889 'error_code' => ''
1890 ];
1891 }
1892
1893 /**
1894 * Build a stable destination failure result.
1895 *
1896 * Affected rows are never presented as authoritative once the transaction
1897 * outcome is uncertain, and the original cause is preserved without any
1898 * database text.
1899 *
1900 * @param string $error_code Safe internal error code.
1901 * @param int $chunks Successful chunk count.
1902 * @param string $transaction_state not_started|rolled_back|uncertain|not_applicable.
1903 * @param string $failure_cause Safe original cause code.
1904 * @return array
1905 */
1906 private function destination_error_result( $error_code, $chunks, $transaction_state, $failure_cause ) {
1907 $known = $transaction_state !== 'uncertain';
1908
1909 $this->import_diagnostics['failure_code'] = $error_code;
1910 $this->import_diagnostics['failure_cause'] = $failure_cause;
1911 $this->import_diagnostics['phase'] = 'destination';
1912 $this->import_diagnostics['transaction_state'] = $transaction_state;
1913 $this->import_diagnostics['destination_affected_rows'] = 0;
1914 $this->import_diagnostics['destination_affected_rows_known'] = $known;
1915
1916 return [
1917 'status' => 'error',
1918 'affected_rows' => 0,
1919 'affected_rows_known' => $known,
1920 'chunks' => (int) $chunks,
1921 'transaction_state' => $transaction_state,
1922 'error_code' => $error_code
1923 ];
1924 }
1925
1926 /**
1927 * Detect the actual destination engine conservatively.
1928 *
1929 * @param string $table Destination table.
1930 * @return string Lowercase engine name or an empty string.
1931 */
1932 private function get_destination_engine( $table ) {
1933 global $wpdb;
1934
1935 $wpdb->last_error = '';
1936 $status = $wpdb->get_row( $wpdb->prepare( 'SHOW TABLE STATUS LIKE %s', $wpdb->esc_like( $table ) ), ARRAY_A );
1937 if ( $wpdb->last_error !== '' || ! is_array( $status ) || empty( $status['Engine'] ) ) {
1938 return '';
1939 }
1940
1941 return strtolower( (string) $status['Engine'] );
1942 }
1943
1944 /**
1945 * Execute provider-owned source batches with cursor safety checks.
1946 *
1947 * @param mixed $cursor Initial opaque cursor.
1948 * @param callable $fetch Provider batch fetcher.
1949 * @param callable $advanced Provider cursor comparison.
1950 * @param callable $consume Batch consumer.
1951 * @return array
1952 */
1953 private function run_source_batches( $cursor, $fetch, $advanced, $consume ) {
1954 $source_rows = 0;
1955 $batches = 0;
1956
1957 while ( $batches < self::MAX_SOURCE_BATCHES ) {
1958 $batch = call_user_func( $fetch, $cursor, self::SOURCE_BATCH_SIZE );
1959 $batches++;
1960
1961 if ( ! is_array( $batch ) || ! isset( $batch['status'] ) || $batch['status'] !== 'success' ) {
1962 return [
1963 'status' => 'error',
1964 'rows' => $source_rows,
1965 'batches' => $batches,
1966 'error_code' => isset( $batch['error_code'] ) ? $batch['error_code'] : 'source_batch_failed'
1967 ];
1968 }
1969
1970 $rows = isset( $batch['rows'] ) && is_array( $batch['rows'] ) ? $batch['rows'] : [];
1971 $source_rows += count( $rows );
1972 call_user_func( $consume, $rows );
1973
1974 if ( ! empty( $batch['done'] ) ) {
1975 return [
1976 'status' => 'success',
1977 'rows' => $source_rows,
1978 'batches' => $batches,
1979 'error_code' => ''
1980 ];
1981 }
1982
1983 $next_cursor = isset( $batch['next_cursor'] ) ? $batch['next_cursor'] : null;
1984 if ( ! call_user_func( $advanced, $cursor, $next_cursor ) ) {
1985 return [
1986 'status' => 'error',
1987 'rows' => $source_rows,
1988 'batches' => $batches,
1989 'error_code' => 'source_cursor_not_advanced'
1990 ];
1991 }
1992
1993 $cursor = $next_cursor;
1994 unset( $rows, $batch );
1995 }
1996
1997 return [
1998 'status' => 'error',
1999 'rows' => $source_rows,
2000 'batches' => $batches,
2001 'error_code' => 'source_batch_limit_exceeded'
2002 ];
2003 }
2004
2005 /**
2006 * Initialize safe diagnostics for one operation.
2007 *
2008 * @param string $provider Provider slug.
2009 * @param string $phase Initial phase.
2010 * @return void
2011 */
2012 private function start_import_diagnostics( $provider, $phase ) {
2013 $this->import_diagnostics = [
2014 'provider' => sanitize_key( $provider ),
2015 'phase' => sanitize_key( $phase ),
2016 'snapshot_boundary' => null,
2017 'raw_rows' => 0,
2018 'logical_rows' => 0,
2019 'distinct_targets' => 0,
2020 'distinct_posts' => 0,
2021 'skipped_count' => 0,
2022 'handled_non_post_rows' => 0,
2023 'source_date_min' => '',
2024 'source_date_max' => '',
2025 'destination_rows' => [],
2026 'source_batches' => 0,
2027 'destination_chunks' => 0,
2028 'source_elapsed_ms' => 0,
2029 'mapping_elapsed_ms' => 0,
2030 'destination_elapsed_ms' => 0,
2031 'destination_tuple_count' => 0,
2032 'destination_query_bytes' => 0,
2033 'destination_affected_rows' => 0,
2034 'destination_affected_rows_known' => false,
2035 'transaction_state' => 'not_applicable',
2036 'peak_memory' => memory_get_peak_usage( true ),
2037 'failure_code' => '',
2038 'failure_cause' => ''
2039 ];
2040 }
2041
2042 /**
2043 * Emit one safe debug record for an import.
2044 *
2045 * @return void
2046 */
2047 private function log_import_diagnostics() {
2048 $this->import_diagnostics['peak_memory'] = memory_get_peak_usage( true );
2049
2050 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
2051 error_log( 'PVC import diagnostics: ' . wp_json_encode( $this->import_diagnostics ) );
2052 }
2053 }
2054
2055 /**
2056 * Build a safe built-in provider failure result.
2057 *
2058 * The stable code is surfaced as a support reference. It never contains SQL,
2059 * table prefixes, URLs or database error text.
2060 *
2061 * @param string $provider Provider slug.
2062 * @param string $error_code Safe internal code.
2063 * @return array
2064 */
2065 private function import_failure_result( $provider, $error_code ) {
2066 $error_code = sanitize_key( $error_code );
2067
2068 if ( empty( $this->import_diagnostics['provider'] ) ) {
2069 $this->start_import_diagnostics( $provider, 'source' );
2070 }
2071
2072 $this->import_diagnostics['failure_code'] = $error_code;
2073
2074 if ( empty( $this->import_diagnostics['failure_cause'] ) ) {
2075 $this->import_diagnostics['failure_cause'] = $error_code;
2076 }
2077
2078 $message = __( 'The import stopped after a database error. Verify destination totals and restore a database backup before retrying or correcting the import.', 'post-views-counter' );
2079
2080 if ( isset( $this->import_diagnostics['transaction_state'] ) && $this->import_diagnostics['transaction_state'] === 'uncertain' ) {
2081 $message .= ' ' . __( 'The database transaction outcome could not be confirmed, so part of this import may have been stored.', 'post-views-counter' );
2082 }
2083
2084 $message .= ' ' . sprintf( __( 'Support reference: %s.', 'post-views-counter' ), $error_code );
2085
2086 $this->log_import_diagnostics();
2087
2088 return [
2089 'success' => false,
2090 'message' => $message,
2091 'type' => 'error',
2092 'error_code' => $error_code
2093 ];
2094 }
2095
2096 /**
2097 * Snapshot the destination totals an advanced strategy will compare against.
2098 *
2099 * Must be called before the destination write so eligibility is never
2100 * derived from already-modified state.
2101 *
2102 * @param array $totals_map Array of post_id => source total.
2103 * @param string $strategy Selected strategy.
2104 * @return array|null Pre-write totals, or null when the strategy needs none.
2105 */
2106 private function snapshot_existing_total_counts( $totals_map, $strategy ) {
2107 if ( empty( $totals_map ) || ! $this->is_advanced_strategy( $strategy ) ) {
2108 return null;
2109 }
2110
2111 return $this->get_existing_total_counts( array_keys( $totals_map ) );
2112 }
2113
2114 /**
2115 * Check whether a strategy compares against stored destination counts.
2116 *
2117 * @param string $strategy Selected strategy.
2118 * @return bool
2119 */
2120 private function is_advanced_strategy( $strategy ) {
2121 return in_array( sanitize_key( $strategy ), [ 'skip_existing', 'keep_higher_count', 'fill_empty_only' ], true );
2122 }
2123
2124 /**
2125 * Adjust import statistics to include skipped totals information.
2126 *
2127 * @param array $stats Message statistics.
2128 * @param array $totals_map Array of post_id => accepted source total.
2129 * @param string $strategy Selected strategy.
2130 * @param array|null $existing Pre-write destination totals.
2131 * @param array|null $compare_map Optional per-post comparison values.
2132 * @return void
2133 */
2134 private function apply_skip_statistics( &$stats, $totals_map, $strategy, $existing, $compare_map = null ) {
2135 if ( empty( $stats ) || empty( $totals_map ) || ! is_array( $existing ) ) {
2136 return;
2137 }
2138
2139 $skip_stats = $this->calculate_total_skip_stats( $totals_map, $strategy, $existing, $compare_map );
2140
2141 if ( empty( $skip_stats ) ) {
2142 return;
2143 }
2144
2145 $stats['skipped'] = $skip_stats;
2146
2147 if ( isset( $stats['total_views'] ) ) {
2148 $stats['total_views'] = max( 0, (int) $stats['total_views'] - $skip_stats['views'] );
2149 }
2150
2151 if ( isset( $stats['posts_processed'] ) ) {
2152 $stats['posts_processed'] = max( 0, (int) $stats['posts_processed'] - $skip_stats['posts'] );
2153 }
2154 }
2155
2156 /**
2157 * Calculate how many source totals the given strategy leaves unapplied.
2158 *
2159 * Statistics describe accepted versus skipped source totals measured against
2160 * the pre-write destination snapshot. They are not exact destination deltas:
2161 * ordered duplicate source tuples are summed for view accounting while the
2162 * comparison value follows the strategy's documented clause.
2163 *
2164 * @param array $totals_map Array of post_id => accepted source total.
2165 * @param string $strategy Selected strategy.
2166 * @param array $existing Pre-write destination totals.
2167 * @param array|null $compare_map Optional per-post comparison values.
2168 * @return array
2169 */
2170 private function calculate_total_skip_stats( $totals_map, $strategy, $existing, $compare_map = null ) {
2171 $strategy = sanitize_key( $strategy );
2172
2173 if ( empty( $totals_map ) || ! $this->is_advanced_strategy( $strategy ) ) {
2174 return [];
2175 }
2176
2177 $skipped_views = 0;
2178 $skipped_posts = 0;
2179
2180 foreach ( $totals_map as $post_id => $value ) {
2181 $current = isset( $existing[ $post_id ] ) ? (int) $existing[ $post_id ] : null;
2182
2183 // keep_higher_count resolves per tuple, so the highest source tuple
2184 // is the value the destination can reach for this post
2185 $candidate = is_array( $compare_map ) && isset( $compare_map[ $post_id ] ) ? (int) $compare_map[ $post_id ] : (int) $value;
2186 $skip = false;
2187
2188 if ( $strategy === 'skip_existing' && $current !== null ) {
2189 $skip = true;
2190 } elseif ( $strategy === 'keep_higher_count' && $current !== null && $current >= $candidate ) {
2191 $skip = true;
2192 } elseif ( $strategy === 'fill_empty_only' && $current !== null && $current > 0 ) {
2193 $skip = true;
2194 }
2195
2196 if ( $skip ) {
2197 $skipped_views += (int) $value;
2198 $skipped_posts++;
2199 }
2200 }
2201
2202 if ( $skipped_posts === 0 ) {
2203 return [];
2204 }
2205
2206 return [
2207 'views' => $skipped_views,
2208 'posts' => $skipped_posts
2209 ];
2210 }
2211
2212 /**
2213 * Get existing total counts for selected posts.
2214 *
2215 * @param array $post_ids
2216 * @return array
2217 */
2218 private function get_existing_total_counts( $post_ids ) {
2219 global $wpdb;
2220
2221 $post_ids = array_filter( array_map( 'intval', (array) $post_ids ) );
2222 $post_ids = array_values( array_unique( $post_ids ) );
2223
2224 if ( empty( $post_ids ) ) {
2225 return [];
2226 }
2227
2228 $existing = [];
2229
2230 foreach ( array_chunk( $post_ids, 500 ) as $chunk ) {
2231 $placeholders = implode( ',', array_fill( 0, count( $chunk ), '%d' ) );
2232 $sql = $wpdb->prepare(
2233 "SELECT id, count FROM {$wpdb->prefix}post_views WHERE type = 4 AND period = 'total' AND id IN ({$placeholders})",
2234 $chunk
2235 );
2236 $rows = $wpdb->get_results( $sql, ARRAY_A );
2237
2238 foreach ( (array) $rows as $row ) {
2239 $existing[ (int) $row['id'] ] = (int) $row['count'];
2240 }
2241 }
2242
2243 return $existing;
2244 }
2245
2246 /**
2247 * Generate import/analyze message with statistics.
2248 *
2249 * @param array $stats Import statistics
2250 * @param string $mode Mode: 'import' or 'analyze'
2251 * @return string
2252 */
2253 private function generate_import_message( $stats, $mode = 'import' ) {
2254 $message_parts = [];
2255
2256 $source_label = '';
2257
2258 if ( ! empty( $stats['source'] ) ) {
2259 $source_label = $stats['source'];
2260 }
2261
2262 if ( $source_label !== '' ) {
2263 if ( $mode === 'analyze' ) {
2264 $message_parts[] = sprintf( __( 'Analysis of %s:', 'post-views-counter' ), $source_label );
2265 } else {
2266 $message_parts[] = sprintf( __( 'Import from %s:', 'post-views-counter' ), $source_label );
2267 }
2268 }
2269
2270 // main success message
2271 if ( ! empty( $stats['no_post_rows_handled'] ) ) {
2272 if ( $mode === 'analyze' ) {
2273 $message_parts[] = __( 'No post view data was found. The valid source rows map to other content types.', 'post-views-counter' );
2274 } else {
2275 $message_parts[] = __( 'No post view data was imported. The valid source rows map to other content types.', 'post-views-counter' );
2276 }
2277 } elseif ( ! empty( $stats['period_data_only'] ) && isset( $stats['posts_processed'] ) ) {
2278 if ( $mode === 'analyze' ) {
2279 $message_parts[] = sprintf(
2280 __( 'Found period view data for %s posts, but no valid source total rows.', 'post-views-counter' ),
2281 number_format_i18n( $stats['posts_processed'] )
2282 );
2283 } else {
2284 $message_parts[] = sprintf(
2285 __( 'Imported period view data for %s posts; no valid source total rows were available.', 'post-views-counter' ),
2286 number_format_i18n( $stats['posts_processed'] )
2287 );
2288 }
2289 } elseif ( isset( $stats['total_views'] ) && isset( $stats['posts_processed'] ) ) {
2290 if ( $mode === 'analyze' ) {
2291 $message_parts[] = sprintf(
2292 __( 'Found %s total views for %s posts.', 'post-views-counter' ),
2293 number_format_i18n( $stats['total_views'] ),
2294 number_format_i18n( $stats['posts_processed'] )
2295 );
2296 } else {
2297 $message_parts[] = sprintf(
2298 __( 'Successfully imported %s total views for %s posts.', 'post-views-counter' ),
2299 number_format_i18n( $stats['total_views'] ),
2300 number_format_i18n( $stats['posts_processed'] )
2301 );
2302 }
2303 }
2304
2305 // posts that contributed only period data
2306 if ( ! empty( $stats['period_only_posts'] ) ) {
2307 if ( $mode === 'analyze' ) {
2308 $message_parts[] = sprintf(
2309 __( 'Also found period view data for %s additional posts without source totals.', 'post-views-counter' ),
2310 number_format_i18n( $stats['period_only_posts'] )
2311 );
2312 } else {
2313 $message_parts[] = sprintf(
2314 __( 'Also imported period view data for %s additional posts without source totals.', 'post-views-counter' ),
2315 number_format_i18n( $stats['period_only_posts'] )
2316 );
2317 }
2318 }
2319
2320 // additional info (like skipped URLs)
2321 if ( ! empty( $stats['additional_info'] ) ) {
2322 $message_parts[] = $stats['additional_info'];
2323 }
2324
2325 if ( isset( $stats['skipped']['views'], $stats['skipped']['posts'] ) ) {
2326 $message_parts[] = sprintf(
2327 __( 'Skipped %1$s total views for %2$s posts.', 'post-views-counter' ),
2328 number_format_i18n( $stats['skipped']['views'] ),
2329 number_format_i18n( $stats['skipped']['posts'] )
2330 );
2331 }
2332
2333 return implode( ' ', $message_parts );
2334 }
2335
2336 /**
2337 * Map provider target URL to content data.
2338 *
2339 * @param string $target
2340 * @param array $tracked_post_types
2341 * @param string $provider
2342 * @return array
2343 */
2344 private function map_target_to_content( $target, $tracked_post_types, $provider ) {
2345 if ( $provider === 'statify' && array_key_exists( $target, $this->statify_post_cache ) ) {
2346 $post_id = $this->statify_post_cache[ $target ];
2347 } else {
2348 $post_id = $this->map_target_to_post_id( $target, $tracked_post_types );
2349 if ( $provider === 'statify' ) {
2350 $this->statify_post_cache[ $target ] = $post_id;
2351 }
2352 }
2353
2354 $content = [
2355 'content_type' => 'post',
2356 'content_id' => $post_id
2357 ];
2358
2359 /**
2360 * Filter provider content mapping so it's possible to process non-post URLs.
2361 *
2362 * @since 1.5.10
2363 *
2364 * @param array $content {
2365 * @type string $content_type Resolved content type.
2366 * @type int $content_id Target identifier.
2367 * }
2368 * @param string $target Target path recorded by the provider.
2369 * @param string $provider Current import provider slug.
2370 * @param array $tracked_post_types Allowed post types for PVC.
2371 * @param Post_Views_Counter_Import $importer Import handler instance.
2372 */
2373 return apply_filters( 'pvc_import_map_target_to_content', $content, $target, $provider, $tracked_post_types, $this );
2374 }
2375
2376 /**
2377 * Map Statify target URL to post ID.
2378 *
2379 * @param string $target
2380 * @param array $tracked_post_types
2381 * @return int
2382 */
2383 private function map_target_to_post_id( $target, $tracked_post_types ) {
2384 // handle empty tracked post types
2385 if ( empty( $tracked_post_types ) ) {
2386 return 0;
2387 }
2388
2389 // handle homepage
2390 if ( $target === '/' ) {
2391 $post_id = get_option( 'page_on_front' );
2392 if ( $post_id && in_array( get_post_type( $post_id ), $tracked_post_types, true ) ) {
2393 return (int) $post_id;
2394 }
2395 }
2396
2397 // build full URL from relative target path
2398 $url = home_url( $target );
2399 $post_id = url_to_postid( $url );
2400
2401 // verify post exists and is a tracked type
2402 if ( $post_id ) {
2403 $post_type = get_post_type( $post_id );
2404 if ( $post_type && in_array( $post_type, $tracked_post_types, true ) ) {
2405 return (int) $post_id;
2406 }
2407 }
2408
2409 return 0;
2410 }
2411
2412 /**
2413 * Flush caches specific to Post Views Counter.
2414 *
2415 * @return void
2416 */
2417 private function flush_pvc_caches() {
2418 global $wp_object_cache;
2419
2420 if ( ! is_object( $wp_object_cache ) || ! property_exists( $wp_object_cache, 'cache' ) ) {
2421 return;
2422 }
2423
2424 $groups = [ 'pvc', 'pvc-get_post_views', 'pvc-get_views' ];
2425
2426 foreach ( $groups as $group ) {
2427 if ( empty( $wp_object_cache->cache[$group] ) || ! is_array( $wp_object_cache->cache[$group] ) ) {
2428 continue;
2429 }
2430
2431 foreach ( array_keys( $wp_object_cache->cache[$group] ) as $key ) {
2432 wp_cache_delete( $key, $group );
2433 }
2434 }
2435 }
2436
2437 /**
2438 * Retrieve the human readable label for a provider.
2439 *
2440 * @param string $slug
2441 * @return string
2442 */
2443 private function get_provider_label( $slug ) {
2444 if ( isset( $this->import_provider_labels[ $slug ] ) ) {
2445 return $this->import_provider_labels[ $slug ];
2446 }
2447
2448 return ucwords( str_replace( '_', ' ', $slug ) );
2449 }
2450
2451 /**
2452 * Build period keys for a timestamp.
2453 *
2454 * @param int $timestamp
2455 * @param bool $use_gmt
2456 * @return array
2457 */
2458 private function get_period_keys_from_timestamp( $timestamp, $use_gmt ) {
2459 $date = $use_gmt ? gmdate( 'W-d-m-Y-o', $timestamp ) : date( 'W-d-m-Y-o', $timestamp );
2460 $parts = explode( '-', $date );
2461
2462 return [
2463 'day' => $parts[3] . $parts[2] . $parts[1],
2464 'week' => $parts[4] . $parts[0],
2465 'month' => $parts[3] . $parts[2],
2466 'year' => $parts[3]
2467 ];
2468 }
2469 }
2470