PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Abilities / Analytics / GetDocAnalytics.php

GetDocAnalytics.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/Abilities/Analytics/GetDocAnalytics.php

356 lines 10.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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