| 1 |
<?php |
| 2 |
/** |
| 3 |
* Per-doc analytics ability. |
| 4 |
* |
| 5 |
* @package BetterDocs |
| 6 |
* @since 4.9.0 |
| 7 |
*/ |
| 8 |
|
| 9 |
namespace WPDeveloper\BetterDocs\Abilities\Analytics; |
| 10 |
|
| 11 |
if ( ! defined( 'ABSPATH' ) ) { |
| 12 |
exit; // Exit if accessed directly. |
| 13 |
} |
| 14 |
|
| 15 |
use WPDeveloper\BetterDocs\Abilities\AbilityBase; |
| 16 |
use WPDeveloper\BetterDocs\Abilities\AbilityError; |
| 17 |
use WPDeveloper\BetterDocs\Abilities\Traits\ShapesDocs; |
| 18 |
|
| 19 |
/** |
| 20 |
* How one doc is doing: views, unique views and the three reaction counts, for |
| 21 |
* all time or a date range, optionally day by day. |
| 22 |
* |
| 23 |
* **What the numbers mean matters more than the numbers.** Free records a view |
| 24 |
* from a JavaScript beacon on a **single doc page** — `AnalyticsTracker` posts |
| 25 |
* to `betterdocs/v1/analytics/view` once the page is open — so archives, search |
| 26 |
* results and REST reads count for nothing, a visitor with JavaScript off is |
| 27 |
* invisible, and the `analytics_from` setting can exclude logged-in users or |
| 28 |
* guests entirely. `unique_views` only moves when the browser says this is its |
| 29 |
* first view of that doc **and** `unique_visitor_count` is on. The reactions |
| 30 |
* come from the feedback widget on the same page. |
| 31 |
* |
| 32 |
* Site-wide analytics — leading docs, categories, searches — is |
| 33 |
* `bd-get-analytics`, which needs BetterDocs Pro. |
| 34 |
* |
| 35 |
* @since 4.9.0 |
| 36 |
*/ |
| 37 |
class GetDocAnalytics extends AbilityBase { |
| 38 |
|
| 39 |
use ShapesDocs; |
| 40 |
|
| 41 |
/** |
| 42 |
* Post meta BetterDocs Pro keeps its own view rollup in, for the "popular |
| 43 |
* docs" ordering its widgets use. Reported when it exists, never written. |
| 44 |
* |
| 45 |
* @since 4.9.0 |
| 46 |
*/ |
| 47 |
const PRO_VIEWS_META = '_betterdocs_meta_views'; |
| 48 |
|
| 49 |
/** |
| 50 |
* @since 4.9.0 |
| 51 |
*/ |
| 52 |
public function __construct() { |
| 53 |
$this->id = 'betterdocs/get-doc-analytics'; |
| 54 |
$this->label = __( 'Get doc analytics', 'betterdocs' ); |
| 55 |
$this->description = __( 'Read one doc\'s views, unique views and reactions, for all time or a date range, optionally broken down by day. BetterDocs Free counts a view only when someone opens the single doc page in a browser, so these are page views, not REST reads. Site-wide analytics is bd-get-analytics, which needs BetterDocs Pro.', 'betterdocs' ); |
| 56 |
$this->capability = 'read_docs_analytics'; |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* @since 4.9.0 |
| 61 |
* |
| 62 |
* @return array |
| 63 |
*/ |
| 64 |
public function get_annotations() { |
| 65 |
return [ |
| 66 |
'readonly' => true, |
| 67 |
'destructive' => false, |
| 68 |
'idempotent' => true, |
| 69 |
'priority' => 1.5, |
| 70 |
'openWorldHint' => false |
| 71 |
]; |
| 72 |
} |
| 73 |
|
| 74 |
/** |
| 75 |
* @since 4.9.0 |
| 76 |
* |
| 77 |
* @return array |
| 78 |
*/ |
| 79 |
public function get_input_schema() { |
| 80 |
return [ |
| 81 |
'type' => 'object', |
| 82 |
'additionalProperties' => false, |
| 83 |
'required' => [ 'doc_id' ], |
| 84 |
'properties' => [ |
| 85 |
'doc_id' => [ |
| 86 |
'type' => 'integer', |
| 87 |
'description' => __( 'The doc to report on. Required.', 'betterdocs' ) |
| 88 |
], |
| 89 |
'start_date' => [ |
| 90 |
'type' => 'string', |
| 91 |
'description' => __( 'First day to count, as YYYY-MM-DD. Omit for all time.', 'betterdocs' ) |
| 92 |
], |
| 93 |
'end_date' => [ |
| 94 |
'type' => 'string', |
| 95 |
'description' => __( 'Last day to count, as YYYY-MM-DD, inclusive. Omit for all time.', 'betterdocs' ) |
| 96 |
], |
| 97 |
'group_by' => [ |
| 98 |
'type' => 'string', |
| 99 |
'enum' => [ 'none', 'day' ], |
| 100 |
'default' => 'none', |
| 101 |
'description' => __( 'day also returns a per-day series. Days with no activity are absent from it rather than reported as zero.', 'betterdocs' ) |
| 102 |
] |
| 103 |
], |
| 104 |
'default' => [] |
| 105 |
]; |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* @since 4.9.0 |
| 110 |
* |
| 111 |
* @return array |
| 112 |
*/ |
| 113 |
public function get_output_schema() { |
| 114 |
$day = [ |
| 115 |
'date' => [ 'type' => 'string' ], |
| 116 |
'views' => [ 'type' => 'integer' ], |
| 117 |
'unique_views' => [ 'type' => 'integer' ], |
| 118 |
'happy' => [ 'type' => 'integer' ], |
| 119 |
'normal' => [ 'type' => 'integer' ], |
| 120 |
'sad' => [ 'type' => 'integer' ] |
| 121 |
]; |
| 122 |
|
| 123 |
return [ |
| 124 |
'type' => 'object', |
| 125 |
'properties' => [ |
| 126 |
'doc_id' => [ 'type' => 'integer' ], |
| 127 |
'title' => [ 'type' => 'string' ], |
| 128 |
'url' => [ 'type' => 'string' ], |
| 129 |
'range' => [ |
| 130 |
'type' => 'object', |
| 131 |
'properties' => [ |
| 132 |
'start' => [ 'type' => [ 'string', 'null' ] ], |
| 133 |
'end' => [ 'type' => [ 'string', 'null' ] ] |
| 134 |
] |
| 135 |
], |
| 136 |
'views' => [ 'type' => 'integer' ], |
| 137 |
'unique_views' => [ 'type' => 'integer' ], |
| 138 |
'reactions' => [ |
| 139 |
'type' => 'object', |
| 140 |
'properties' => [ |
| 141 |
'happy' => [ 'type' => 'integer' ], |
| 142 |
'normal' => [ 'type' => 'integer' ], |
| 143 |
'sad' => [ 'type' => 'integer' ] |
| 144 |
] |
| 145 |
], |
| 146 |
'series' => [ |
| 147 |
'type' => 'array', |
| 148 |
'items' => [ |
| 149 |
'type' => 'object', |
| 150 |
'properties' => $day |
| 151 |
] |
| 152 |
], |
| 153 |
'pro_extras' => [ |
| 154 |
'type' => 'object', |
| 155 |
'properties' => [ |
| 156 |
'meta_views' => [ 'type' => 'integer' ] |
| 157 |
] |
| 158 |
] |
| 159 |
] |
| 160 |
]; |
| 161 |
} |
| 162 |
|
| 163 |
/** |
| 164 |
* @since 4.9.0 |
| 165 |
* |
| 166 |
* @param array $input Validated input. |
| 167 |
* @return array|\WP_Error |
| 168 |
*/ |
| 169 |
public function execute( $input ) { |
| 170 |
$doc_id = isset( $input['doc_id'] ) ? (int) $input['doc_id'] : 0; |
| 171 |
$doc = $this->require_doc( $doc_id ); |
| 172 |
|
| 173 |
if ( is_wp_error( $doc ) ) { |
| 174 |
return $doc; |
| 175 |
} |
| 176 |
|
| 177 |
$start = $this->date_input( $input, 'start_date' ); |
| 178 |
|
| 179 |
if ( is_wp_error( $start ) ) { |
| 180 |
return $start; |
| 181 |
} |
| 182 |
|
| 183 |
$end = $this->date_input( $input, 'end_date' ); |
| 184 |
|
| 185 |
if ( is_wp_error( $end ) ) { |
| 186 |
return $end; |
| 187 |
} |
| 188 |
|
| 189 |
if ( null !== $start && null !== $end && $start > $end ) { |
| 190 |
return AbilityError::invalid_input( |
| 191 |
'start_date', |
| 192 |
__( 'start_date is after end_date.', 'betterdocs' ) |
| 193 |
); |
| 194 |
} |
| 195 |
|
| 196 |
$series = 'day' === ( isset( $input['group_by'] ) ? $input['group_by'] : 'none' ); |
| 197 |
$rows = $this->rows( $doc_id, $start, $end, $series ); |
| 198 |
$totals = [ |
| 199 |
'views' => 0, |
| 200 |
'unique_views' => 0, |
| 201 |
'happy' => 0, |
| 202 |
'normal' => 0, |
| 203 |
'sad' => 0 |
| 204 |
]; |
| 205 |
$per_day = []; |
| 206 |
|
| 207 |
foreach ( $rows as $row ) { |
| 208 |
$totals['views'] += (int) $row['views']; |
| 209 |
$totals['unique_views'] += (int) $row['unique_views']; |
| 210 |
$totals['happy'] += (int) $row['happy']; |
| 211 |
$totals['normal'] += (int) $row['normal']; |
| 212 |
$totals['sad'] += (int) $row['sad']; |
| 213 |
|
| 214 |
if ( $series ) { |
| 215 |
$per_day[] = [ |
| 216 |
'date' => (string) $row['date'], |
| 217 |
'views' => (int) $row['views'], |
| 218 |
'unique_views' => (int) $row['unique_views'], |
| 219 |
'happy' => (int) $row['happy'], |
| 220 |
'normal' => (int) $row['normal'], |
| 221 |
'sad' => (int) $row['sad'] |
| 222 |
]; |
| 223 |
} |
| 224 |
} |
| 225 |
|
| 226 |
$out = [ |
| 227 |
'doc_id' => $doc_id, |
| 228 |
'title' => (string) get_the_title( $doc_id ), |
| 229 |
'url' => (string) get_permalink( $doc_id ), |
| 230 |
'range' => [ |
| 231 |
'start' => $start, |
| 232 |
'end' => $end |
| 233 |
], |
| 234 |
'views' => $totals['views'], |
| 235 |
'unique_views' => $totals['unique_views'], |
| 236 |
'reactions' => [ |
| 237 |
'happy' => $totals['happy'], |
| 238 |
'normal' => $totals['normal'], |
| 239 |
'sad' => $totals['sad'] |
| 240 |
] |
| 241 |
]; |
| 242 |
|
| 243 |
if ( $series ) { |
| 244 |
$out['series'] = $per_day; |
| 245 |
} |
| 246 |
|
| 247 |
$extras = $this->pro_extras( $doc_id ); |
| 248 |
|
| 249 |
if ( null !== $extras ) { |
| 250 |
$out['pro_extras'] = $extras; |
| 251 |
} |
| 252 |
|
| 253 |
return $out; |
| 254 |
} |
| 255 |
|
| 256 |
/** |
| 257 |
* The analytics rows for one doc, as `[ date, views, unique_views, happy, |
| 258 |
* normal, sad ]`. |
| 259 |
* |
| 260 |
* A read, and only a read. Two shapes rather than one because a doc viewed |
| 261 |
* every day for three years has a thousand rows and the common call wants a |
| 262 |
* single number: without `group_by: day` the database does the summing. |
| 263 |
* |
| 264 |
* @since 4.9.0 |
| 265 |
* |
| 266 |
* @param int $doc_id Doc id. |
| 267 |
* @param string|null $start `YYYY-MM-DD` or null. |
| 268 |
* @param string|null $end `YYYY-MM-DD` or null. |
| 269 |
* @param bool $series Whether the per-day rows are wanted. |
| 270 |
* @return array[] |
| 271 |
*/ |
| 272 |
protected function rows( $doc_id, $start, $end, $series ) { |
| 273 |
global $wpdb; |
| 274 |
|
| 275 |
$table = $wpdb->prefix . 'betterdocs_analytics'; |
| 276 |
$where = 'post_id = %d'; |
| 277 |
$params = [ (int) $doc_id ]; |
| 278 |
|
| 279 |
if ( null !== $start ) { |
| 280 |
$where .= ' AND created_at >= %s'; |
| 281 |
$params[] = $start; |
| 282 |
} |
| 283 |
|
| 284 |
if ( null !== $end ) { |
| 285 |
$where .= ' AND created_at <= %s'; |
| 286 |
$params[] = $end; |
| 287 |
} |
| 288 |
|
| 289 |
$columns = $series |
| 290 |
? 'created_at AS date, impressions AS views, unique_visit AS unique_views, happy, normal, sad' |
| 291 |
: "'' AS date, SUM(impressions) AS views, SUM(unique_visit) AS unique_views, SUM(happy) AS happy, SUM(normal) AS normal, SUM(sad) AS sad"; |
| 292 |
|
| 293 |
$order = $series ? ' ORDER BY created_at ASC' : ''; |
| 294 |
|
| 295 |
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,PluginCheck.Security.DirectDB.UnescapedDBParameter -- read-only aggregate over BetterDocs' own analytics table; $table and $columns are literals, the %d/%s placeholders live in $where (also built from literals) so the sniff cannot see them, every value is prepared, and there is no cache layer for this table (the plugin's own REST readers do the same). |
| 296 |
$rows = $wpdb->get_results( |
| 297 |
$wpdb->prepare( |
| 298 |
"SELECT {$columns} FROM {$table} WHERE {$where}{$order}", |
| 299 |
$params |
| 300 |
), |
| 301 |
ARRAY_A |
| 302 |
); |
| 303 |
// phpcs:enable |
| 304 |
|
| 305 |
return is_array( $rows ) ? $rows : []; |
| 306 |
} |
| 307 |
|
| 308 |
/** |
| 309 |
* A `YYYY-MM-DD` input, or null when it was not sent. |
| 310 |
* |
| 311 |
* @since 4.9.0 |
| 312 |
* |
| 313 |
* @param array $input Validated input. |
| 314 |
* @param string $field Field name. |
| 315 |
* @return string|null|\WP_Error |
| 316 |
*/ |
| 317 |
protected function date_input( array $input, $field ) { |
| 318 |
if ( ! isset( $input[ $field ] ) || '' === trim( (string) $input[ $field ] ) ) { |
| 319 |
return null; |
| 320 |
} |
| 321 |
|
| 322 |
$value = trim( (string) $input[ $field ] ); |
| 323 |
$parts = explode( '-', $value ); |
| 324 |
|
| 325 |
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) || ! checkdate( (int) $parts[1], (int) $parts[2], (int) $parts[0] ) ) { |
| 326 |
return AbilityError::invalid_input( |
| 327 |
$field, |
| 328 |
sprintf( |
| 329 |
/* translators: 1: field name, 2: the value sent. */ |
| 330 |
__( '%1$s must be a date as YYYY-MM-DD; got "%2$s".', 'betterdocs' ), |
| 331 |
$field, |
| 332 |
$value |
| 333 |
) |
| 334 |
); |
| 335 |
} |
| 336 |
|
| 337 |
return $value; |
| 338 |
} |
| 339 |
|
| 340 |
/** |
| 341 |
* Pro's own numbers for this doc, when Pro has recorded any. |
| 342 |
* |
| 343 |
* @since 4.9.0 |
| 344 |
* |
| 345 |
* @param int $doc_id Doc id. |
| 346 |
* @return array|null |
| 347 |
*/ |
| 348 |
protected function pro_extras( $doc_id ) { |
| 349 |
if ( ! metadata_exists( 'post', (int) $doc_id, self::PRO_VIEWS_META ) ) { |
| 350 |
return null; |
| 351 |
} |
| 352 |
|
| 353 |
return [ 'meta_views' => (int) get_post_meta( (int) $doc_id, self::PRO_VIEWS_META, true ) ]; |
| 354 |
} |
| 355 |
} |
| 356 |
|