PluginProbe
weDocs: AI Powered Knowledge Base, Docs, Documentation, Wiki & AI Chatbot / trunk
weDocs: AI Powered Knowledge Base, Docs, Documentation, Wiki & AI Chatbot vtrunk
2.5.0 2.4.1 2.4.0 2.3.1 2.3.0 2.2.5 2.2.6 2.2.7 2.2.2 2.2.3 2.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.4 1.4.1 1.5 1.6 1.6.1 1.6.2 1.6.3 1.7 1.7.1 1.7.2 All 54 releases
wedocs / includes / Analytics.php

Analytics.php in weDocs: AI Powered Knowledge Base, Docs, Documentation, Wiki & AI Chatbot trunk, at includes/Analytics.php

304 lines 10.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WeDevs\WeDocs;
4
5 use WP_REST_Server;
6 use WP_REST_Response;
7
8 /**
9 * weDocs Analytics.
10 *
11 * Lightweight, dependency-free analytics for the admin Dashboard:
12 * - counts a per-doc view (post meta `wedocs_views`) on the single doc page
13 * - exposes aggregated stats over REST for the React Dashboard
14 *
15 * No custom tables — keeps the free plugin lean. Time-series / search
16 * analytics live in weDocs Pro.
17 *
18 * @since 2.3.0
19 */
20 class Analytics {
21
22 /**
23 * Post meta key holding the cumulative view count for a doc.
24 *
25 * @var string
26 */
27 const VIEWS_META = 'wedocs_views';
28
29 /**
30 * Constructor.
31 */
32 public function __construct() {
33 add_action( 'template_redirect', [ $this, 'maybe_count_view' ] );
34 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
35 }
36
37 /**
38 * Count a view for the current single doc.
39 *
40 * Skips bots, previews, feed/REST requests and users who can edit the
41 * doc (so authors/editors don't inflate their own numbers). De-dupes
42 * repeat views from the same visitor within 6 hours via a transient.
43 *
44 * @since 2.3.0
45 *
46 * @return void
47 */
48 public function maybe_count_view() {
49 if ( ! is_singular( 'docs' ) || is_preview() || is_feed() ) {
50 return;
51 }
52
53 $post_id = get_queried_object_id();
54 if ( ! $post_id ) {
55 return;
56 }
57
58 // Don't count people who can edit the doc.
59 if ( current_user_can( 'edit_post', $post_id ) ) {
60 return;
61 }
62
63 // De-dupe per visitor for 6 hours.
64 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
65 if ( $user_agent && preg_match( '/bot|crawl|spider|slurp|facebookexternalhit|mediapartners/i', $user_agent ) ) {
66 return;
67 }
68
69 $visitor = md5( $this->get_visitor_ip() . $user_agent );
70 $key = 'wedocs_view_' . $post_id . '_' . $visitor;
71 if ( get_transient( $key ) ) {
72 return;
73 }
74 set_transient( $key, 1, 6 * HOUR_IN_SECONDS );
75
76 global $wpdb;
77
78 $updated = (int) $wpdb->query(
79 $wpdb->prepare(
80 "UPDATE {$wpdb->postmeta}
81 SET meta_value = CAST( meta_value AS UNSIGNED ) + 1
82 WHERE post_id = %d AND meta_key = %s
83 LIMIT 1",
84 $post_id,
85 self::VIEWS_META
86 )
87 );
88
89 if ( 0 === $updated ) {
90 add_post_meta( $post_id, self::VIEWS_META, 1, true );
91 }
92 }
93
94 /**
95 * Best-effort visitor IP for de-duplication only (not stored).
96 *
97 * @since 2.3.0
98 *
99 * @return string
100 */
101 protected function get_visitor_ip() {
102 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
103
104 return $ip;
105 }
106
107 /**
108 * Register the dashboard stats route.
109 *
110 * @since 2.3.0
111 *
112 * @return void
113 */
114 public function register_routes() {
115 register_rest_route(
116 'wp/v2',
117 '/docs/dashboard-stats',
118 [
119 [
120 'methods' => WP_REST_Server::READABLE,
121 'callback' => [ $this, 'get_dashboard_stats' ],
122 'permission_callback' => [ $this, 'permissions_check' ],
123 ],
124 ]
125 );
126 }
127
128 /**
129 * Only users who can manage docs may read the stats.
130 *
131 * @since 2.3.0
132 *
133 * @return bool
134 */
135 public function permissions_check() {
136 return current_user_can( 'manage_options' ) || current_user_can( wedocs_get_publish_cap() );
137 }
138
139 /**
140 * Build the aggregated dashboard payload.
141 *
142 * @since 2.3.0
143 *
144 * @return WP_REST_Response
145 */
146 public function get_dashboard_stats() {
147 global $wpdb;
148
149 // ---- Counts -------------------------------------------------------
150 $parents = (int) $wpdb->get_var(
151 "SELECT COUNT(*) FROM {$wpdb->posts}
152 WHERE post_type = 'docs' AND post_status = 'publish' AND post_parent = 0"
153 );
154
155 $published = (int) $wpdb->get_var(
156 "SELECT COUNT(*) FROM {$wpdb->posts}
157 WHERE post_type = 'docs' AND post_status = 'publish'"
158 );
159
160 $drafts = (int) $wpdb->get_var(
161 "SELECT COUNT(*) FROM {$wpdb->posts}
162 WHERE post_type = 'docs' AND post_status IN ( 'draft', 'pending', 'private' )"
163 );
164
165 // Everything published that isn't a top-level doc is a section/article.
166 $articles = max( 0, $published - $parents );
167
168 // ---- Views --------------------------------------------------------
169 $total_views = (int) $wpdb->get_var(
170 $wpdb->prepare(
171 "SELECT COALESCE( SUM( CAST( pm.meta_value AS UNSIGNED ) ), 0 )
172 FROM {$wpdb->postmeta} pm
173 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
174 WHERE pm.meta_key = %s AND p.post_type = 'docs' AND p.post_status = 'publish'",
175 self::VIEWS_META
176 )
177 );
178
179 // ---- Helpful votes ------------------------------------------------
180 $positive = (int) $wpdb->get_var(
181 "SELECT COALESCE( SUM( CAST( pm.meta_value AS UNSIGNED ) ), 0 )
182 FROM {$wpdb->postmeta} pm
183 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
184 WHERE pm.meta_key = 'positive' AND p.post_type = 'docs' AND p.post_status = 'publish'"
185 );
186 $negative = (int) $wpdb->get_var(
187 "SELECT COALESCE( SUM( CAST( pm.meta_value AS UNSIGNED ) ), 0 )
188 FROM {$wpdb->postmeta} pm
189 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
190 WHERE pm.meta_key = 'negative' AND p.post_type = 'docs' AND p.post_status = 'publish'"
191 );
192
193 // ---- Popular docs (by views) -------------------------------------
194 $popular_rows = $wpdb->get_results(
195 $wpdb->prepare(
196 "SELECT p.ID, p.post_title, CAST( pm.meta_value AS UNSIGNED ) AS views
197 FROM {$wpdb->posts} p
198 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key = %s
199 WHERE p.post_type = 'docs' AND p.post_status = 'publish'
200 ORDER BY views DESC
201 LIMIT 5",
202 self::VIEWS_META
203 )
204 );
205
206 $popular = [];
207 foreach ( (array) $popular_rows as $row ) {
208 if ( (int) $row->views <= 0 ) {
209 continue;
210 }
211 $popular[] = [
212 'id' => (int) $row->ID,
213 'title' => $row->post_title,
214 'link' => get_permalink( $row->ID ),
215 'edit' => get_edit_post_link( $row->ID, 'raw' ),
216 'views' => (int) $row->views,
217 ];
218 }
219
220 // ---- Most helpful docs (positive - negative) ----------------------
221 $helpful_rows = $wpdb->get_results(
222 "SELECT p.ID, p.post_title,
223 COALESCE( MAX( IF( pm.meta_key = 'positive', pm.meta_value, 0 ) ), 0 ) AS positive,
224 COALESCE( MAX( IF( pm.meta_key = 'negative', pm.meta_value, 0 ) ), 0 ) AS negative
225 FROM {$wpdb->posts} p
226 INNER JOIN {$wpdb->postmeta} pm ON pm.post_id = p.ID AND pm.meta_key IN ( 'positive', 'negative' )
227 WHERE p.post_type = 'docs' AND p.post_status = 'publish'
228 GROUP BY p.ID, p.post_title
229 HAVING ( CAST( positive AS SIGNED ) - CAST( negative AS SIGNED ) ) > 0
230 ORDER BY ( CAST( positive AS SIGNED ) - CAST( negative AS SIGNED ) ) DESC
231 LIMIT 5"
232 );
233
234 $most_helpful = [];
235 foreach ( (array) $helpful_rows as $row ) {
236 $p = (int) $row->positive;
237 $n = (int) $row->negative;
238 if ( 0 === $p && 0 === $n ) {
239 continue;
240 }
241 $total = $p + $n;
242 $most_helpful[] = [
243 'id' => (int) $row->ID,
244 'title' => $row->post_title,
245 'link' => get_permalink( $row->ID ),
246 'edit' => get_edit_post_link( $row->ID, 'raw' ),
247 'positive' => $p,
248 'negative' => $n,
249 'percentage' => $total ? (int) round( ( $p / $total ) * 100 ) : 0,
250 ];
251 }
252
253 // ---- Recent docs --------------------------------------------------
254 $recent_posts = get_posts(
255 [
256 'post_type' => 'docs',
257 'post_status' => [ 'publish', 'draft', 'pending', 'private' ],
258 'posts_per_page' => 5,
259 'orderby' => 'modified',
260 'order' => 'DESC',
261 ]
262 );
263
264 $recent = [];
265 foreach ( $recent_posts as $post ) {
266 $recent[] = [
267 'id' => (int) $post->ID,
268 'title' => $post->post_title ? $post->post_title : __( '(no title)', 'wedocs' ),
269 'status' => $post->post_status,
270 'edit' => get_edit_post_link( $post->ID, 'raw' ),
271 'link' => get_permalink( $post->ID ),
272 'modified' => get_the_modified_date( 'M j, Y', $post ),
273 'views' => (int) get_post_meta( $post->ID, self::VIEWS_META, true ),
274 ];
275 }
276
277 // ---- Contributors -------------------------------------------------
278 $contributors = (int) $wpdb->get_var(
279 "SELECT COUNT( DISTINCT post_author ) FROM {$wpdb->posts}
280 WHERE post_type = 'docs' AND post_status = 'publish'"
281 );
282
283 $data = [
284 'totals' => [
285 'docs' => $parents,
286 'articles' => $articles,
287 'published' => $published,
288 'drafts' => $drafts,
289 'views' => $total_views,
290 'positive' => $positive,
291 'negative' => $negative,
292 'helpful_rate' => ( $positive + $negative ) ? (int) round( ( $positive / ( $positive + $negative ) ) * 100 ) : 0,
293 'contributors' => $contributors,
294 ],
295 'popular' => $popular,
296 'most_helpful' => $most_helpful,
297 'recent' => $recent,
298 'pro_active' => function_exists( 'wedocs_is_pro_active' ) ? (bool) wedocs_is_pro_active() : false,
299 ];
300
301 return new WP_REST_Response( apply_filters( 'wedocs_dashboard_stats', $data ), 200 );
302 }
303 }
304