true, 'foreground_color' => '#ff0000', 'background_color' => '#f1f1f1', 'position' => 'top', 'thickness' => 8, 'post_types' => array( 'post', 'page' ), 'display_mode' => 'bar', // View counter chip defaults. 'read_count_enabled' => false, 'read_count_locations' => array( 'post' ), 'read_count_position' => 'before-content', 'read_count_prefix' => __( 'Views:', 'advanced-reading-progress-view-counter' ), 'read_count_suffix' => '', 'read_count_lock_hours' => 24, 'read_count_wpm' => 200, 'read_count_font_size' => 14, 'read_count_margin' => array( 8, 8, 8, 8 ), 'read_count_padding' => array( 10, 12, 10, 12 ), 'read_count_bg_color' => '#0f766e', 'read_count_text_color' => '#ffffff', ); } /** * Allowed bar positions. * * @return array */ function arpvc_allowed_positions() { return array( 'top', 'bottom', 'top-right', 'top-left', 'bottom-right', 'bottom-left', ); } /** * Allowed read count positions. * * @return array */ function arpvc_allowed_read_count_positions() { return array( 'before-title', 'after-title', 'before-content', 'after-content', 'shortcode-only', ); } /** * Allowed post types (public). * * @return array */ function arpvc_allowed_post_types() { static $allowed = null; if ( null === $allowed ) { $objects = get_post_types( array( 'public' => true, ), 'objects' ); $allowed = array_keys( $objects ); } return $allowed; } /** * Sanitize a hex color or return the provided fallback. * * @param string|null $color Raw color value. * @param string|null $fallback Fallback color. * * @return string|null */ function arpvc_sanitize_color_value( $color, $fallback = null ) { $maybe_color = sanitize_hex_color( $color ); if ( $maybe_color ) { return $maybe_color; } return $fallback; } /** * Sanitize position or return fallback. * * @param string|null $position Raw position value. * @param string|null $fallback Fallback position. * * @return string|null */ function arpvc_sanitize_position( $position, $fallback = null ) { return in_array( $position, arpvc_allowed_positions(), true ) ? $position : $fallback; } /** * Sanitize thickness (px) or return fallback. * * @param int|string|null $thickness Raw thickness value. * @param int|null $fallback Fallback thickness. * * @return int|null */ function arpvc_sanitize_thickness( $thickness, $fallback = null ) { $maybe_thickness = absint( $thickness ); if ( $maybe_thickness > 0 ) { return $maybe_thickness; } return $fallback; } /** * Sanitize selected post types list. * * @param array|string|null $post_types Raw post types. * @param array|null $fallback Fallback list. * * @return array|null */ function arpvc_sanitize_post_types( $post_types, $fallback = null ) { $allowed = arpvc_allowed_post_types(); $list = array(); if ( is_string( $post_types ) ) { $post_types = array( $post_types ); } if ( is_array( $post_types ) ) { foreach ( $post_types as $type ) { if ( in_array( $type, $allowed, true ) ) { $list[] = $type; } } } $list = array_values( array_unique( $list ) ); if ( ! empty( $list ) ) { return $list; } return $fallback; } /** * Sanitize read count positions list. * * @param array|string|null $locations Raw locations. * @param array|null $fallback Fallback list. * * @return array|null */ function arpvc_sanitize_read_count_locations( $locations, $fallback = null ) { $allowed = array( 'post', 'page', 'home', 'archive' ); $list = array(); if ( is_string( $locations ) ) { $locations = array( $locations ); } if ( is_array( $locations ) ) { foreach ( $locations as $loc ) { if ( in_array( $loc, $allowed, true ) ) { $list[] = $loc; } } } $list = array_values( array_unique( $list ) ); if ( ! empty( $list ) ) { return $list; } return $fallback; } /** * Sanitize read count position. * * @param string|null $position Raw position. * @param string|null $fallback Fallback. * * @return string|null */ function arpvc_sanitize_read_count_position( $position, $fallback = null ) { return in_array( $position, arpvc_allowed_read_count_positions(), true ) ? $position : $fallback; } /** * Sanitize the view lock window in hours. * * @param int|string|null $hours Raw value. * @param int|null $fallback Fallback. * * @return int|null */ function arpvc_sanitize_read_count_lock_hours( $hours, $fallback = null ) { if ( null === $hours || '' === $hours ) { return $fallback; } return max( 0, min( 720, absint( $hours ) ) ); } /** * Sanitize integer list (e.g., margin/padding). * * @param array|string|null $values Raw values. * @param array|null $fallback Fallback. * * @return array|null */ function arpvc_sanitize_int_quad( $values, $fallback = null ) { if ( is_string( $values ) ) { $values = array_map( 'trim', explode( ',', $values ) ); } if ( is_array( $values ) ) { $out = array(); foreach ( array_slice( $values, 0, 4 ) as $val ) { $out[] = max( 0, absint( $val ) ); } $limit = 4; $current_count = count( $out ); while ( $current_count < $limit ) { $out[] = 0; } return $out; } return $fallback; } /** * Sanitize display mode. * * @param string|null $mode Raw mode. * @param string|null $fallback Fallback. * * @return string|null */ function arpvc_sanitize_display_mode( $mode, $fallback = null ) { $allowed = array( 'bar', 'radial' ); return in_array( $mode, $allowed, true ) ? $mode : $fallback; } /** * Normalize settings with sanitization and optional fallbacks. * * @param array $settings Raw settings (may be partial). * @param bool $with_fallback Whether to use defaults when a value is missing/invalid. * * @return array */ function arpvc_normalize_settings( array $settings, $with_fallback = true ) { $defaults = arpvc_default_settings(); $fallbacks = $with_fallback ? $defaults : array_fill_keys( array_keys( $defaults ), null ); return array( 'progress_enabled' => (bool) ( $settings['progress_enabled'] ?? $fallbacks['progress_enabled'] ), 'foreground_color' => arpvc_sanitize_color_value( $settings['foreground_color'] ?? null, $fallbacks['foreground_color'] ), 'background_color' => arpvc_sanitize_color_value( $settings['background_color'] ?? null, $fallbacks['background_color'] ), 'position' => arpvc_sanitize_position( $settings['position'] ?? null, $fallbacks['position'] ), 'thickness' => arpvc_sanitize_thickness( $settings['thickness'] ?? null, $fallbacks['thickness'] ), 'post_types' => arpvc_sanitize_post_types( $settings['post_types'] ?? null, $fallbacks['post_types'] ), 'display_mode' => arpvc_sanitize_display_mode( $settings['display_mode'] ?? null, $fallbacks['display_mode'] ), 'read_count_enabled' => (bool) ( $settings['read_count_enabled'] ?? $fallbacks['read_count_enabled'] ), 'read_count_locations' => arpvc_sanitize_read_count_locations( $settings['read_count_locations'] ?? null, $fallbacks['read_count_locations'] ), 'read_count_position' => arpvc_sanitize_read_count_position( $settings['read_count_position'] ?? null, $fallbacks['read_count_position'] ), 'read_count_prefix' => sanitize_text_field( $settings['read_count_prefix'] ?? $fallbacks['read_count_prefix'] ), 'read_count_suffix' => sanitize_text_field( $settings['read_count_suffix'] ?? $fallbacks['read_count_suffix'] ), 'read_count_lock_hours' => arpvc_sanitize_read_count_lock_hours( $settings['read_count_lock_hours'] ?? null, $fallbacks['read_count_lock_hours'] ), 'read_count_wpm' => max( 50, min( 1000, absint( $settings['read_count_wpm'] ?? $fallbacks['read_count_wpm'] ) ) ), 'read_count_font_size' => absint( $settings['read_count_font_size'] ?? $fallbacks['read_count_font_size'] ), 'read_count_margin' => arpvc_sanitize_int_quad( $settings['read_count_margin'] ?? null, $fallbacks['read_count_margin'] ), 'read_count_padding' => arpvc_sanitize_int_quad( $settings['read_count_padding'] ?? null, $fallbacks['read_count_padding'] ), 'read_count_bg_color' => arpvc_sanitize_color_value( $settings['read_count_bg_color'] ?? null, $fallbacks['read_count_bg_color'] ), 'read_count_text_color' => arpvc_sanitize_color_value( $settings['read_count_text_color'] ?? null, $fallbacks['read_count_text_color'] ), ); } /** * Setting keys convenience helper. * * @return array */ function arpvc_setting_keys() { return array_keys( arpvc_default_settings() ); } /** * Retrieve saved settings merged with defaults and sanitized. * * @return array */ function arpvc_get_settings() { static $cached = null; if ( null !== $cached ) { return $cached; } $saved = get_option( 'arpvc_settings', null ); // Migrate legacy option name once, but keep reading it as a fallback. if ( null === $saved ) { $legacy = get_option( 'prb_settings', array() ); if ( ! empty( $legacy ) ) { $saved = $legacy; update_option( 'arpvc_settings', $legacy ); } else { $saved = array(); } } $cached = arpvc_normalize_settings( $saved ); return $cached; } /** * Capability check for protected settings REST endpoints. * * @param WP_REST_Request $request Request. * * @return bool */ function arpvc_rest_can_manage_settings( WP_REST_Request $request ) { unset( $request ); return current_user_can( 'manage_options' ); } /** * Register REST API routes for reading/updating settings. */ function arpvc_register_rest_routes() { $namespaces = array( 'arpvc/v1', 'prb/v1' ); // Register legacy namespace for backward compatibility. foreach ( $namespaces as $namespace ) { register_rest_route( $namespace, '/settings', array( array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'arpvc_rest_get_settings', 'permission_callback' => 'arpvc_rest_can_manage_settings', ), array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'arpvc_rest_update_settings', 'permission_callback' => 'arpvc_rest_can_manage_settings', 'args' => array( 'progress_enabled' => array( 'type' => 'boolean', 'required' => false, ), 'foreground_color' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_hex_color', ), 'background_color' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_hex_color', ), 'position' => array( 'type' => 'string', 'required' => false, 'enum' => arpvc_allowed_positions(), ), 'thickness' => array( 'type' => 'integer', 'required' => false, 'minimum' => 1, 'maximum' => 300, 'sanitize_callback' => 'absint', ), 'post_types' => array( 'type' => 'array', 'required' => false, 'items' => array( 'type' => 'string', ), ), 'display_mode' => array( 'type' => 'string', 'required' => false, 'enum' => array( 'bar', 'radial' ), ), 'read_count_enabled' => array( 'type' => 'boolean', 'required' => false, ), 'read_count_locations' => array( 'type' => 'array', 'required' => false, 'items' => array( 'type' => 'string', ), ), 'read_count_position' => array( 'type' => 'string', 'required' => false, 'enum' => arpvc_allowed_read_count_positions(), ), 'read_count_prefix' => array( 'type' => 'string', 'required' => false, ), 'read_count_suffix' => array( 'type' => 'string', 'required' => false, ), 'read_count_lock_hours' => array( 'type' => 'integer', 'required' => false, 'minimum' => 0, 'maximum' => 720, ), 'read_count_wpm' => array( 'type' => 'integer', 'required' => false, 'minimum' => 50, 'maximum' => 1000, ), 'read_count_font_size' => array( 'type' => 'integer', 'required' => false, 'minimum' => 8, 'maximum' => 96, ), 'read_count_margin' => array( 'type' => 'array', 'required' => false, 'items' => array( 'type' => 'integer', ), ), 'read_count_padding' => array( 'type' => 'array', 'required' => false, 'items' => array( 'type' => 'integer', ), ), 'read_count_bg_color' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_hex_color', ), 'read_count_text_color' => array( 'type' => 'string', 'required' => false, 'sanitize_callback' => 'sanitize_hex_color', ), ), ), ) ); } } add_action( 'rest_api_init', 'arpvc_register_rest_routes' ); /** * REST callback: fetch settings. */ function arpvc_rest_get_settings() { return arpvc_get_settings(); } /** * REST callback: update settings. * * @param WP_REST_Request $request Request. * * @return array|WP_Error */ function arpvc_rest_update_settings( WP_REST_Request $request ) { $current = arpvc_get_settings(); $merged = $current; foreach ( arpvc_setting_keys() as $key ) { if ( $request->has_param( $key ) ) { $merged[ $key ] = $request->get_param( $key ); } } $sanitized_for_validation = arpvc_normalize_settings( $merged, false ); if ( ! empty( $sanitized_for_validation['progress_enabled'] ) ) { if ( ! $sanitized_for_validation['foreground_color'] || ! $sanitized_for_validation['background_color'] ) { return new WP_Error( 'arpvc_invalid_color', __( 'Please provide valid hex colors.', 'advanced-reading-progress-view-counter' ), array( 'status' => 400 ) ); } if ( ! $sanitized_for_validation['position'] ) { return new WP_Error( 'arpvc_invalid_position', __( 'Position must be top or bottom.', 'advanced-reading-progress-view-counter' ), array( 'status' => 400 ) ); } if ( ! $sanitized_for_validation['thickness'] ) { return new WP_Error( 'arpvc_invalid_thickness', __( 'Thickness must be a positive integer.', 'advanced-reading-progress-view-counter' ), array( 'status' => 400 ) ); } if ( empty( $sanitized_for_validation['post_types'] ) ) { return new WP_Error( 'arpvc_invalid_post_types', __( 'Please choose at least one valid post type.', 'advanced-reading-progress-view-counter' ), array( 'status' => 400 ) ); } if ( ! $sanitized_for_validation['display_mode'] ) { return new WP_Error( 'arpvc_invalid_display_mode', __( 'Display mode must be bar or radial.', 'advanced-reading-progress-view-counter' ), array( 'status' => 400 ) ); } } // Basic validation for read count fields. if ( isset( $sanitized_for_validation['read_count_font_size'] ) && $sanitized_for_validation['read_count_font_size'] > 96 ) { return new WP_Error( 'arpvc_invalid_font_size', __( 'Font size must be 96px or below.', 'advanced-reading-progress-view-counter' ), array( 'status' => 400 ) ); } $settings = arpvc_normalize_settings( $merged ); update_option( 'arpvc_settings', $settings ); return $settings; } /** * Enqueues scripts and styles for the frontend. * * @return void */ function arpvc_enqueue_frontend_assets() { $settings = arpvc_get_settings(); $enabled = ! empty( $settings['progress_enabled'] ); $foreground = $settings['foreground_color']; $background = $settings['background_color']; $position = $settings['position']; $thickness = $settings['thickness']; $post_types = $settings['post_types']; $display = $settings['display_mode']; $on_target_page = is_singular( $post_types ) || is_front_page() || is_home() || ( function_exists( 'is_shop' ) && is_shop() ); if ( ! $enabled || ! $on_target_page ) { return; } // Block-level overrides removed; global settings apply everywhere. wp_enqueue_style( 'arpvc-style', plugins_url( 'build/css/main.css', __FILE__ ), array(), file_exists( __DIR__ . '/build/css/main.css' ) ? filemtime( __DIR__ . '/build/css/main.css' ) : null ); $custom_css = '#arpvc-progress-bar {'; $custom_css .= 'background-color:' . esc_attr( $background ) . '!important;'; $custom_css .= 'height:' . absint( $thickness ) . 'px!important;'; $custom_css .= ( 'bottom' === $position ) ? 'top:auto;bottom:0;' : 'top:0;bottom:auto;'; $custom_css .= '}'; $custom_css .= '#arpvc-progress-fill { background-color:' . esc_attr( $foreground ) . '!important; height:100%!important; }'; wp_add_inline_style( 'arpvc-style', $custom_css ); wp_enqueue_script( 'arpvc-scroll-js', plugins_url( 'build/js/scroll-tracker.min.js', __FILE__ ), array(), file_exists( __DIR__ . '/build/js/scroll-tracker.min.js' ) ? filemtime( __DIR__ . '/build/js/scroll-tracker.min.js' ) : time(), true ); wp_localize_script( 'arpvc-scroll-js', 'ARPVC_SETTINGS', array( 'foreground' => $foreground, 'background' => $background, 'position' => $position, 'thickness' => absint( $thickness ), 'display' => $display, 'postTypes' => $post_types, ) ); } add_action( 'wp_enqueue_scripts', 'arpvc_enqueue_frontend_assets' ); /** * Register admin page. */ function arpvc_register_admin_page() { add_menu_page( __( 'Advanced Reading Progress & View Counter', 'advanced-reading-progress-view-counter' ), __( 'Reading Progress & Views', 'advanced-reading-progress-view-counter' ), 'manage_options', 'arpvc-settings', 'arpvc_render_settings_page', 'dashicons-editor-alignleft', 65 ); } add_action( 'admin_menu', 'arpvc_register_admin_page' ); /** * Enqueue assets for admin React app. * * @param string $hook Current page hook. */ function arpvc_admin_enqueue_assets( $hook ) { if ( 'toplevel_page_arpvc-settings' !== $hook ) { return; } $post_type_objects = get_post_types( array( 'public' => true, ), 'objects' ); $post_type_choices = array(); foreach ( $post_type_objects as $slug => $object ) { $post_type_choices[] = array( 'slug' => $slug, 'label' => $object->labels->singular_name ?? $slug, ); } wp_enqueue_script( 'wp-api-fetch' ); wp_enqueue_script( 'arpvc-admin-js', plugins_url( 'build/js/arpvc-admin.min.js', __FILE__ ), array( 'wp-element', 'wp-components', 'wp-api-fetch', 'wp-i18n' ), file_exists( __DIR__ . '/build/js/arpvc-admin.min.js' ) ? filemtime( __DIR__ . '/build/js/arpvc-admin.min.js' ) : time(), true ); wp_set_script_translations( 'arpvc-admin-js', 'advanced-reading-progress-view-counter', plugin_dir_path( __FILE__ ) . 'languages' ); wp_enqueue_style( 'arpvc-admin-css', plugins_url( 'build/css/admin.css', __FILE__ ), array( 'wp-components' ), file_exists( __DIR__ . '/build/css/admin.css' ) ? filemtime( __DIR__ . '/build/css/admin.css' ) : null ); wp_localize_script( 'arpvc-admin-js', 'arpvcAdmin', array( 'restUrl' => esc_url_raw( rest_url( 'arpvc/v1/settings' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'defaults' => arpvc_get_settings(), 'postTypes' => $post_type_choices, ) ); } add_action( 'admin_enqueue_scripts', 'arpvc_admin_enqueue_assets' ); /** * Render root element for React admin. */ function arpvc_render_settings_page() { echo '