diff --git a/.wp-env/0-sandbox.php b/.wp-env/0-sandbox.php index 8db8e37b0..91a68caea 100644 --- a/.wp-env/0-sandbox.php +++ b/.wp-env/0-sandbox.php @@ -1,9 +1,15 @@ 'Debugging for Developers', + 'topics' => array( 'development' ), + 'levels' => array( 'Intermediate' ), + ), + array( + 'title' => 'Debugging for Site Owners', + 'topics' => array( 'site-management' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'eCommerce with WooCommerce', + 'topics' => array( 'woocommerce', 'ecommerce' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'SEO Foundations', + 'topics' => array( 'seo' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'WordPress Playground', + 'topics' => array( 'playground' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'Content Creation', + 'topics' => array( 'content-creation' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'Using AI in your WordPress Dashboard', + 'topics' => array( 'ai', 'site-management' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'Managing your WordPress site with AI', + 'topics' => array( 'ai', 'site-management' ), + 'levels' => array( 'Intermediate' ), + ), + array( + 'title' => 'Contributor Onboarding', + 'topics' => array( 'contributing' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'WordPress Security Essentials', + 'topics' => array( 'security' ), + 'levels' => array( 'Beginner' ), + ), + array( + 'title' => 'Accessibility Testing in WordPress', + 'topics' => array( 'accessibility' ), + 'levels' => array( 'Beginner' ), + ), + ); + + foreach ( $kits as $kit_data ) { + $existing_query = new \WP_Query( + array( + 'post_type' => 'activity_kit', + 'post_status' => 'any', + 'title' => $kit_data['title'], + 'posts_per_page' => 1, + 'fields' => 'ids', + ) + ); + $existing = $existing_query->have_posts() ? get_post( $existing_query->posts[0] ) : null; + + if ( $existing && ! $force ) { + \WP_CLI::log( sprintf( 'Skipping "%s" — already exists (ID %d). Use --force to re-import.', $kit_data['title'], $existing->ID ) ); + continue; + } + + $post_id = wp_insert_post( + array( + 'post_title' => $kit_data['title'], + 'post_type' => 'activity_kit', + 'post_status' => 'publish', + 'post_author' => 1, + ), + true + ); + + if ( is_wp_error( $post_id ) ) { + \WP_CLI::warning( sprintf( 'Failed to create "%s": %s', $kit_data['title'], $post_id->get_error_message() ) ); + continue; + } + + if ( ! empty( $kit_data['topics'] ) ) { + wp_set_object_terms( $post_id, $kit_data['topics'], 'topic' ); + } + + if ( ! empty( $kit_data['levels'] ) ) { + wp_set_object_terms( $post_id, $kit_data['levels'], 'level' ); + } + + \WP_CLI::success( sprintf( 'Created "%s" (ID %d)', $kit_data['title'], $post_id ) ); + } + + \WP_CLI::log( 'Import complete.' ); + } +} + +\WP_CLI::add_command( 'activity-kit', __NAMESPACE__ . '\Activity_Kit_CLI' ); diff --git a/wp-content/plugins/wporg-learn/inc/activity-kit-rest.php b/wp-content/plugins/wporg-learn/inc/activity-kit-rest.php new file mode 100644 index 000000000..64623cb92 --- /dev/null +++ b/wp-content/plugins/wporg-learn/inc/activity-kit-rest.php @@ -0,0 +1,307 @@ + 'POST', + 'callback' => __NAMESPACE__ . '\handle_track', + 'permission_callback' => '__return_true', + 'args' => array( + 'post_id' => array( + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + ), + 'action' => array( + 'required' => true, + 'type' => 'string', + 'enum' => array( 'view', 'download' ), + ), + ), + ) + ); + + register_rest_route( + 'activity-kits/v1', + '/stats', + array( + 'methods' => 'GET', + 'callback' => __NAMESPACE__ . '\handle_stats', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => array( + 'metric' => array( + 'default' => 'both', + 'enum' => array( 'both', 'views', 'downloads' ), + ), + 'range' => array( + 'default' => 'all', + 'enum' => array( '7d', '30d', '90d', 'all', 'custom' ), + ), + 'kit' => array( + 'default' => '', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'date_from' => array( + 'default' => '', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + 'date_to' => array( + 'default' => '', + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ), + ), + ) + ); +} + +/** + * Handle POST /activity-kits/v1/track + * + * @param \WP_REST_Request $request + * @return \WP_REST_Response|\WP_Error + */ +function handle_track( $request ) { + $post_id = $request->get_param( 'post_id' ); + $action = $request->get_param( 'action' ); + + if ( 'activity_kit' !== get_post_type( $post_id ) ) { + return new \WP_Error( 'invalid_post', __( 'Invalid activity kit.', 'wporg-learn' ), array( 'status' => 404 ) ); + } + + // Downloads: rate-limit to one per IP per post per 24 hours. + // Views: count every page load (no rate limit). + if ( 'download' === $action ) { + $rate_key = 'ak_rate_dl_' . md5( $post_id . sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ?? '' ) ) ); + if ( get_transient( $rate_key ) ) { + return rest_ensure_response( + array( + 'tracked' => false, + 'reason' => 'rate_limited', + ) + ); + } + set_transient( $rate_key, 1, DAY_IN_SECONDS ); + } + + $meta_key = 'view' === $action ? '_view_count' : '_download_count'; + $current = (int) get_post_meta( $post_id, $meta_key, true ); + update_post_meta( $post_id, $meta_key, $current + 1 ); + + log_event( $post_id, $action ); + + return rest_ensure_response( array( 'tracked' => true ) ); +} + +/** + * Handle GET /activity-kits/v1/stats + * + * @param \WP_REST_Request $request + * @return \WP_REST_Response + */ +function handle_stats( $request ) { + $metric = $request->get_param( 'metric' ); + $range = $request->get_param( 'range' ); + $kit = $request->get_param( 'kit' ); + $date_from = $request->get_param( 'date_from' ); + $date_to = $request->get_param( 'date_to' ); + + $kits = get_posts( + array( + 'post_type' => 'activity_kit', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'orderby' => 'title', + 'order' => 'ASC', + ) + ); + + $results = array(); + + foreach ( $kits as $kit_post ) { + if ( $kit && $kit_post->post_name !== $kit ) { + continue; + } + + $data = array( + 'id' => $kit_post->ID, + 'title' => $kit_post->post_title, + 'slug' => $kit_post->post_name, + 'updated' => get_the_modified_date( 'Y-m-d', $kit_post->ID ), + ); + + if ( 'custom' === $range && $date_from && $date_to ) { + $data = array_merge( $data, get_stats_from_events_daterange( $kit_post->ID, $metric, $date_from, $date_to ) ); + } elseif ( 'all' === $range ) { + if ( 'both' === $metric || 'views' === $metric ) { + $data['views'] = (int) get_post_meta( $kit_post->ID, '_view_count', true ); + } + if ( 'both' === $metric || 'downloads' === $metric ) { + $data['downloads'] = (int) get_post_meta( $kit_post->ID, '_download_count', true ); + } + } else { + $data = array_merge( $data, get_stats_from_events( $kit_post->ID, $metric, $range ) ); + } + + $results[] = $data; + } + + return rest_ensure_response( $results ); +} + +/** + * Get stats from the events log table for a given kit, metric, and time range. + * + * @param int $post_id + * @param string $metric 'both', 'views', or 'downloads'. + * @param string $range '7d', '30d', or '90d'. + * @return array + */ +function get_stats_from_events( $post_id, $metric, $range ) { + global $wpdb; + + $table = $wpdb->prefix . 'activity_kit_events'; + $days = intval( str_replace( 'd', '', $range ) ); + $data = array(); + + if ( 'both' === $metric || 'views' === $metric ) { + $data['views'] = (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot use a placeholder. + "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d AND action = 'view' AND created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)", + $post_id, + $days + ) + ); + } + + if ( 'both' === $metric || 'downloads' === $metric ) { + $data['downloads'] = (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot use a placeholder. + "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d AND action = 'download' AND created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)", + $post_id, + $days + ) + ); + } + + return $data; +} + +/** + * Get stats from the events log table for a given kit between two specific dates. + * + * @param int $post_id + * @param string $metric 'both', 'views', or 'downloads'. + * @param string $date_from Start date in Y-m-d format. + * @param string $date_to End date in Y-m-d format (inclusive). + * @return array + */ +function get_stats_from_events_daterange( $post_id, $metric, $date_from, $date_to ) { + global $wpdb; + + $table = $wpdb->prefix . 'activity_kit_events'; + $data = array(); + + $from = sanitize_text_field( $date_from ) . ' 00:00:00'; + $to = sanitize_text_field( $date_to ) . ' 23:59:59'; + + if ( 'both' === $metric || 'views' === $metric ) { + $data['views'] = (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot use a placeholder. + "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d AND action = 'view' AND created_at BETWEEN %s AND %s", + $post_id, + $from, + $to + ) + ); + } + + if ( 'both' === $metric || 'downloads' === $metric ) { + $data['downloads'] = (int) $wpdb->get_var( + $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot use a placeholder. + "SELECT COUNT(*) FROM `{$table}` WHERE post_id = %d AND action = 'download' AND created_at BETWEEN %s AND %s", + $post_id, + $from, + $to + ) + ); + } + + return $data; +} + +/** + * Log a view or download event to the events table. + * + * @param int $post_id + * @param string $action 'view' or 'download'. + */ +function log_event( $post_id, $action ) { + global $wpdb; + + $table = $wpdb->prefix . 'activity_kit_events'; + + $wpdb->insert( + $table, + array( + 'post_id' => $post_id, + 'action' => $action, + 'created_at' => current_time( 'mysql', true ), + ), + array( '%d', '%s', '%s' ) + ); +} + +/** + * Create the events table if it does not already exist. + */ +function maybe_create_events_table() { + global $wpdb; + + $table = $wpdb->prefix . 'activity_kit_events'; + $version = get_option( 'activity_kit_events_db_version', '0' ); + + if ( '1.0' === $version ) { + return; + } + + $charset_collate = $wpdb->get_charset_collate(); + + $sql = "CREATE TABLE IF NOT EXISTS `{$table}` ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + post_id bigint(20) unsigned NOT NULL, + action varchar(20) NOT NULL, + created_at datetime NOT NULL, + PRIMARY KEY (id), + KEY post_id (post_id), + KEY created_at (created_at) + ) {$charset_collate};"; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + dbDelta( $sql ); + + update_option( 'activity_kit_events_db_version', '1.0' ); +} diff --git a/wp-content/plugins/wporg-learn/inc/activity-kit-settings.php b/wp-content/plugins/wporg-learn/inc/activity-kit-settings.php new file mode 100644 index 000000000..b1b1bbe93 --- /dev/null +++ b/wp-content/plugins/wporg-learn/inc/activity-kit-settings.php @@ -0,0 +1,88 @@ + 'string', + 'sanitize_callback' => 'esc_url_raw', + 'default' => '', + ) + ); +} + +/** + * Add a Settings submenu under the Activity Kits post-type menu. + */ +function add_settings_page(): void { + add_submenu_page( + 'edit.php?post_type=activity_kit', + __( 'Activity Kit Settings', 'wporg-learn' ), + __( 'Settings', 'wporg-learn' ), + 'manage_options', + 'activity-kit-settings', + __NAMESPACE__ . '\render_settings_page' + ); +} + +/** + * Render the settings page. + */ +function render_settings_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + ?> +
| + + | ++ ↓ + | ++ + | ++ + | ++ + | +
|---|---|---|---|---|
{ __(
@@ -28,7 +33,10 @@ const CourseCompletionMeta = () => {
+
+
+
+ ·
+
+ ·
+
+