Changeset 3448182
- Timestamp:
- 01/27/2026 08:54:20 PM (6 weeks ago)
- Location:
- sam-reading-time
- Files:
-
- 22 added
- 12 edited
- 1 copied
-
assets/banner-1544x500-fa_IR.png (modified) (1 prop) (previous)
-
assets/banner-1544x500.png (modified) (1 prop) (previous)
-
assets/banner-772×250-fa_IR.png (modified) (1 prop) (previous)
-
assets/banner-772×250.png (modified) (1 prop) (previous)
-
assets/icon-128×128.png (modified) (1 prop) (previous)
-
assets/icon-256×256.png (modified) (1 prop) (previous)
-
assets/screenshot-1.png (modified) (1 prop) (previous)
-
assets/screenshot-2.png (modified) (1 prop) (previous)
-
tags/2.2 (copied) (copied from sam-reading-time/trunk)
-
tags/2.2/LICENSE.txt (added)
-
tags/2.2/README.md (added)
-
tags/2.2/assets (added)
-
tags/2.2/assets/banner-1544x500-fa_IR.png (added)
-
tags/2.2/assets/banner-1544x500.png (added)
-
tags/2.2/assets/banner-772×250-fa_IR.png (added)
-
tags/2.2/assets/banner-772×250.png (added)
-
tags/2.2/assets/icon-128×128.png (added)
-
tags/2.2/assets/icon-256×256.png (added)
-
tags/2.2/assets/screenshot-1.png (added)
-
tags/2.2/assets/screenshot-2.png (added)
-
tags/2.2/readme.txt (modified) (2 diffs)
-
tags/2.2/sam-reading-time.php (modified) (44 diffs)
-
trunk/LICENSE.txt (added)
-
trunk/README.md (added)
-
trunk/assets (added)
-
trunk/assets/banner-1544x500-fa_IR.png (added)
-
trunk/assets/banner-1544x500.png (added)
-
trunk/assets/banner-772×250-fa_IR.png (added)
-
trunk/assets/banner-772×250.png (added)
-
trunk/assets/icon-128×128.png (added)
-
trunk/assets/icon-256×256.png (added)
-
trunk/assets/screenshot-1.png (added)
-
trunk/assets/screenshot-2.png (added)
-
trunk/readme.txt (modified) (2 diffs)
-
trunk/sam-reading-time.php (modified) (44 diffs)
Legend:
- Unmodified
- Added
- Removed
-
sam-reading-time/assets/banner-1544x500-fa_IR.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/assets/banner-1544x500.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/assets/banner-772×250-fa_IR.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/assets/banner-772×250.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/assets/icon-128×128.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/assets/icon-256×256.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/assets/screenshot-1.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/assets/screenshot-2.png
-
Property
svn:mime-type
changed from
application/octet-streamtoimage/png
-
Property
svn:mime-type
changed from
-
sam-reading-time/tags/2.2/readme.txt
r3412374 r3448182 6 6 Tested up to: 6.9 7 7 Requires PHP: 7.2 8 Stable tag: 2. 18 Stable tag: 2.2 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 68 68 == Changelog == 69 69 70 = 2.2 = 71 * Improved performance by storing reading time as post meta instead of calculating on every request. 72 * Faster and more reliable sorting of reading time column in admin post lists. 73 * Added proper text domain loading (sam-reading-time) for full translation support. 74 * More stable and accurate Schema.org timeRequired JSON-LD output. 75 * Minor UI refinements in admin settings page with a cleaner, more minimal red theme. 76 * Better handling of translated content for Polylang and WPML. 77 70 78 = 2.0 = 71 79 * Added admin post list column to display reading time for all post types. -
sam-reading-time/tags/2.2/sam-reading-time.php
r3412374 r3448182 4 4 * Plugin URI: https://github.com/samwda/srt/ 5 5 * Description: A lightweight WordPress plugin to display the estimated reading time of posts and pages using the [sam_reading_time] shortcode. 6 * Version: 2. 16 * Version: 2.2 7 7 * Author: SAM Web Design Agency 8 8 * Author URI: https://samwda.ir … … 32 32 */ 33 33 public function __construct() { 34 // Load translations 35 add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) ); 36 34 37 // Register the shortcode 35 38 add_shortcode( 'sam_reading_time', array( $this, 'display_reading_time_shortcode' ) ); … … 45 48 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_styles' ) ); 46 49 47 add_filter('manage_posts_columns', array($this, 'add_reading_time_column')); 48 add_filter('manage_pages_columns', array($this, 'add_reading_time_column')); 49 add_action('manage_posts_custom_column', array($this, 'show_reading_time_column'), 10, 2); 50 add_action('manage_pages_custom_column', array($this, 'show_reading_time_column'), 10, 2); 51 add_action('init', array($this, 'add_cpt_reading_time_column_support')); 52 add_filter('manage_edit-post_sortable_columns', array($this, 'make_reading_time_column_sortable')); 53 add_filter('manage_edit-page_sortable_columns', array($this, 'make_reading_time_column_sortable')); 54 add_action('pre_get_posts', array($this, 'reading_time_orderby_query')); 55 add_action('wp_head', array($this, 'add_time_required_jsonld')); 50 add_filter( 'manage_posts_columns', array( $this, 'add_reading_time_column' ) ); 51 add_filter( 'manage_pages_columns', array( $this, 'add_reading_time_column' ) ); 52 add_action( 'manage_posts_custom_column', array( $this, 'show_reading_time_column' ), 10, 2 ); 53 add_action( 'manage_pages_custom_column', array( $this, 'show_reading_time_column' ), 10, 2 ); 54 add_action( 'init', array( $this, 'add_cpt_reading_time_column_support' ) ); 55 add_filter( 'manage_edit-post_sortable_columns', array( $this, 'make_reading_time_column_sortable' ) ); 56 add_filter( 'manage_edit-page_sortable_columns', array( $this, 'make_reading_time_column_sortable' ) ); 57 add_action( 'pre_get_posts', array( $this, 'reading_time_orderby_query' ) ); 58 add_action( 'wp_head', array( $this, 'add_time_required_jsonld' ) ); 59 60 // Update reading time meta when posts are saved 61 add_action( 'save_post', array( $this, 'update_reading_time_meta' ), 10, 2 ); 62 } 63 64 /** 65 * Load plugin textdomain for translations. 66 */ 67 public function load_textdomain() { 68 load_plugin_textdomain( 'sam-reading-time', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); 56 69 } 57 70 58 71 /** 59 72 * Enqueues the plugin's CSS file for the frontend. 60 * ( Removed: No external CSS file is enqueued anymore.)73 * (Kept empty intentionally.) 61 74 */ 62 75 public function enqueue_plugin_styles() { 63 // No external CSS file is enqueued .76 // No external CSS file is enqueued by default. 64 77 } 65 78 66 79 /** 67 80 * Enqueues the plugin's admin CSS for the settings page using admin_head and inline <style>. 81 * MINIMAL, RED-THEMED POLISH ONLY. 68 82 * 69 83 * @param string $hook The current admin page. … … 73 87 return; 74 88 } 75 // Add inline CSS to admin_head (no external files or wp_add_inline_style)76 add_action( 'admin_head', function() {89 // Add minimal inline CSS to admin_head 90 add_action( 'admin_head', function() { 77 91 ?> 78 92 <style> 93 /* Minimal admin panel polish — red theme (#d00) */ 79 94 .sam-settings-container { 95 background: #ffffff; 96 border-radius: 8px; 97 border: 1px solid #f5dcdc; 98 padding: 28px; 99 max-width: 760px; 100 margin: 28px auto; 101 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 102 color: #222; 103 } 104 105 .sam-settings-container h1 { 106 color: #d00; 107 font-size: 20px; 108 margin-bottom: 10px; 109 font-weight: 700; 110 } 111 112 .sam-settings-container p { 113 color: #333; 114 line-height: 1.6; 115 margin-bottom: 10px; 116 } 117 118 .sam-settings-container input[type="text"], 119 .sam-settings-container input[type="number"], 120 .sam-settings-container select, 121 .sam-settings-container textarea { 122 border: 1px solid #f0d6d6; 80 123 background: #fff; 81 border-radius: 12px; 82 border: 1px solid #e5e5e5; 83 box-shadow: 0 2px 12px rgba(220,0,0,0.07); 84 padding: 36px 28px 28px 28px; 85 max-width: 700px; 86 margin: 32px auto; 87 } 88 .sam-settings-container h1, .sam-settings-container h2, .sam-settings-container h3 { 89 color: #d00; 90 font-weight: 700; 91 margin-bottom: 16px; 92 } 93 .sam-settings-container form { 94 margin-bottom: 24px; 95 } 96 .sam-settings-container input, .sam-settings-container select, .sam-settings-container textarea { 97 border: 1.5px solid #d00; 98 background: #fff; 99 color: #d00; 124 color: #111; 100 125 border-radius: 6px; 101 padding: 8px 14px; 102 font-size: 15px; 103 margin-bottom: 10px; 104 transition: border 0.2s; 105 } 106 .sam-settings-container input:focus, .sam-settings-container select:focus, .sam-settings-container textarea:focus { 107 border-color: #a00; 108 outline: none; 109 } 126 padding: 8px 10px; 127 font-size: 14px; 128 margin-bottom: 8px; 129 width: 100%; 130 box-sizing: border-box; 131 } 132 110 133 .sam-settings-container input[type="checkbox"] { 111 134 accent-color: #d00; 112 width: 18px; 113 height: 18px; 114 } 135 width: 16px; 136 height: 16px; 137 vertical-align: middle; 138 margin-right: 6px; 139 } 140 115 141 .sam-settings-container .description { 116 color: # d00;142 color: #666; 117 143 font-size: 13px; 118 margin-top: 2px; 119 margin-bottom: 14px; 120 } 144 margin-top: 4px; 145 margin-bottom: 12px; 146 } 147 121 148 .sam-settings-container .usage-instructions { 122 background: #fff; 123 border-radius: 8px; 124 box-shadow: 0 1px 6px rgba(220,0,0,0.05); 125 padding: 18px 14px; 126 margin-top: 18px; 149 background: #fff7f7; 127 150 border-left: 4px solid #d00; 128 } 151 border-radius: 6px; 152 padding: 12px; 153 margin-top: 14px; 154 } 155 129 156 .sam-settings-container code, .sam-settings-container pre { 130 157 background: #fff; 131 158 color: #d00; 132 border: 1px solid # d00;159 border: 1px solid #f1d3d3; 133 160 border-radius: 4px; 134 padding: 2px 8px;135 font-size: 1 4px;161 padding: 6px 8px; 162 font-size: 13px; 136 163 font-family: "Fira Mono", "Consolas", "Menlo", monospace; 137 164 } 138 .sam-settings-container pre { 139 padding: 8px; 140 margin-top: 6px; 141 } 165 142 166 .sam-settings-container .button-primary { 143 167 background: #d00; … … 146 170 font-weight: 700; 147 171 border-radius: 6px; 148 box-shadow: 0 1px 4px rgba(220,0,0,0.07);149 padding: 10px 24px;150 font-size: 16px;151 transition: background 0.2s, color 0.2s;152 } 172 padding: 9px 18px; 173 font-size: 14px; 174 cursor: pointer; 175 } 176 153 177 .sam-settings-container .button-primary:hover { 154 background: #fff; 155 color: #d00; 156 border: 1.5px solid #d00; 178 background: #b30000; 179 } 180 181 @media (max-width: 600px) { 182 .sam-settings-container { padding: 18px; margin: 18px 12px; } 157 183 } 158 184 </style> 159 185 <?php 160 } );186 } ); 161 187 } 162 188 … … 176 202 177 203 // 3. Replace special characters (like newlines, tabs) and multiple spaces with a single space. 178 // This step is essential for preparing the text for accurate word separation.179 204 $content = preg_replace( '/\s+/u', ' ', $content ); // Use /u for Unicode support 180 205 $content = trim( $content ); // Remove leading and trailing spaces. 181 206 182 207 // 4. Split words based on whitespace and count them. 183 // Use preg_split with PREG_SPLIT_NO_EMPTY flag to remove empty strings after splitting 184 // and 'u' flag for Unicode support in Persian texts. 185 $words = preg_split('/\s+/u', $content, -1, PREG_SPLIT_NO_EMPTY); 186 187 // Count the number of elements in the words array. 188 return count($words); 208 $words = preg_split( '/\s+/u', $content, -1, PREG_SPLIT_NO_EMPTY ); 209 210 return is_array( $words ) ? count( $words ) : 0; 189 211 } 190 212 … … 212 234 $enable_debug_output = get_option( 'sam_reading_time_enable_debug_output', false ); 213 235 214 // The 'type' attribute for 'excerpt' is still supported for flexibility, 215 // but it's not advertised in the usage instructions. 216 $content_type = isset( $atts['type'] ) && in_array( $atts['type'], array( 'content', 'excerpt' ) ) ? $atts['type'] : 'content'; 236 $content_type = isset( $atts['type'] ) && in_array( $atts['type'], array( 'content', 'excerpt' ), true ) ? $atts['type'] : 'content'; 217 237 218 238 global $post; … … 220 240 $content_to_count = ''; 221 241 222 // Attempt to get post ID from standard WordPress function. 223 // This is the most reliable way when inside the loop or for singular posts. 224 if ( is_singular() || ( function_exists('get_the_ID') && get_the_ID() ) ) { 242 if ( is_singular() || ( function_exists( 'get_the_ID' ) && get_the_ID() ) ) { 225 243 $post_id = get_the_ID(); 226 } elseif ( is_a( $post, 'WP_Post' ) ) { // Fallback to global $post object244 } elseif ( is_a( $post, 'WP_Post' ) ) { 227 245 $post_id = $post->ID; 228 246 } 229 247 230 // If post ID is still not found, return a debug message or empty string.231 248 if ( ! $post_id ) { 232 249 if ( $enable_debug_output ) { … … 237 254 } 238 255 239 // Get content based on type240 256 if ( 'excerpt' === $content_type ) { 241 257 $content_to_count = get_the_excerpt( $post_id ); 242 258 } else { 243 // Using get_post_field directly for content is generally safe.244 // apply_filters('the_content', ...) could be used if you need other plugins' content filters to run,245 // but for word count, raw content is often preferred to avoid counting shortcode output etc.246 259 $content_to_count = get_post_field( 'post_content', $post_id ); 247 260 } 248 261 249 // If no content to count, return empty string.250 262 if ( empty( $content_to_count ) ) { 251 263 if ( $enable_debug_output ) { 252 /* translators: %1$s: The Post ID. */253 264 return '<span style="color: orange; direction:ltr; text-align:left; display:block; padding: 5px; border: 1px dashed orange;">' . sprintf( esc_html__( 'Sam Reading Time Debug: No content found for Post ID %1$s.', 'sam-reading-time' ), absint( $post_id ) ) . '</span>'; 254 265 } … … 256 267 } 257 268 258 // Count words in the cleaned content. 259 $word_count = $this->count_words( $content_to_count ); 260 261 // If word count is 0 (after cleaning), display nothing. 269 $word_count = $this->count_words( $this->clean_content_for_reading_time( $content_to_count ) ); 270 262 271 if ( $word_count === 0 ) { 263 272 if ( $enable_debug_output ) { 264 /* translators: %1$s: The Post ID. */265 273 return '<span style="color: orange; direction:ltr; text-align:left; display:block; padding: 5px; border: 1px dashed orange;">' . sprintf( esc_html__( 'Sam Reading Time Debug: Word count is 0 for Post ID %1$s.', 'sam-reading-time' ), absint( $post_id ) ) . '</span>'; 266 274 } … … 268 276 } 269 277 270 // Calculate raw reading time (can be decimal). 271 $valid_words_per_minute = max( 1, (int) $words_per_minute ); // Ensure WPM is at least 1. 278 $valid_words_per_minute = max( 1, (int) $words_per_minute ); 272 279 $raw_reading_time = $word_count / $valid_words_per_minute; 273 280 274 281 $formatted_reading_time = ''; 275 282 276 // Logic for "Less than a minute" format.277 // If raw reading time is less than 1 minute, use the "less than a minute" format.278 283 if ( $raw_reading_time < 1 ) { 279 284 if ( $hide_if_less_than_a_minute ) { 280 return ''; // Hide output if setting is enabled285 return ''; 281 286 } 282 287 $formatted_reading_time = $less_than_a_minute_format; 283 288 } else { 284 // For posts that are 1 minute or more, always round up to the nearest whole minute. 285 $display_time_value = ceil( $raw_reading_time ); 286 287 // Apply singular or plural format based on the rounded minute value. 289 $display_time_value = (int) ceil( $raw_reading_time ); 288 290 if ( $display_time_value === 1 ) { 289 291 $formatted_reading_time = sprintf( $singular_format, $display_time_value ); … … 293 295 } 294 296 295 // Add prefix and suffix.296 297 $final_output = $prefix_text . $formatted_reading_time . $suffix_text; 297 298 298 // Add debug output if enabled299 299 if ( $enable_debug_output ) { 300 /* translators: %1$s: The word count. %2$s: The raw reading time. */ 301 $final_output .= ' <span style="font-size:0.8em; opacity:0.7; direction:ltr; text-align:left; background-color: #f0f0f0; padding: 2px 5px; border-radius: 3px;">(' . sprintf( esc_html__( 'Words: %1$s, Raw Time: %2$s', 'sam-reading-time' ), absint( $word_count ), number_format($raw_reading_time, 2) ) . ')</span>'; 302 } 303 304 // Prepare CSS classes. 305 $classes = array( 'reading-time' ); // Default class 300 $final_output .= ' <span style="font-size:0.8em; opacity:0.7; direction:ltr; text-align:left; background-color: #f0f0f0; padding: 2px 5px; border-radius: 3px;">(' . sprintf( esc_html__( 'Words: %1$s, Raw Time: %2$s', 'sam-reading-time' ), absint( $word_count ), number_format( $raw_reading_time, 2 ) ) . ')</span>'; 301 } 302 303 $classes = array( 'reading-time' ); 306 304 $class_attr = 'class="' . esc_attr( implode( ' ', $classes ) ) . '"'; 307 305 308 // Return the formatted reading time wrapped in the chosen HTML tag.309 306 return '<' . esc_attr( $wrapper_tag ) . ' ' . $class_attr . '>' . $final_output . '</' . esc_attr( $wrapper_tag ) . '>'; 310 307 } … … 316 313 public function add_admin_menu() { 317 314 add_submenu_page( 318 'edit.php', // Parent slug for Posts menu319 esc_html__( 'Sam Reading Time Settings', 'sam-reading-time' ), // Page title320 esc_html__( 'Sam Reading Time', 'sam-reading-time' ), // Menu title321 'manage_options', // Capability required to access322 'sam-reading-time', // Menu slug323 array( $this, 'options_page_html' ) // Callback function to display page HTML315 'edit.php', 316 esc_html__( 'Sam Reading Time Settings', 'sam-reading-time' ), 317 esc_html__( 'Sam Reading Time', 'sam-reading-time' ), 318 'manage_options', 319 'sam-reading-time', 320 array( $this, 'options_page_html' ) 324 321 ); 325 322 } … … 329 326 */ 330 327 public function initialize_settings() { 331 // Register a settings section.332 328 add_settings_section( 333 329 'sam_reading_time_plugin_section', … … 337 333 ); 338 334 339 // Register field for Words Per Minute (WPM).340 335 add_settings_field( 341 336 'sam_reading_time_words_per_minute', … … 352 347 'sanitize_callback' => array( $this, 'sanitize_words_per_minute' ), 353 348 'default' => 200, 354 'show_in_rest' => false, // Not exposed via REST API 355 ) 356 ); 357 358 // Register field for Singular Format. 349 'show_in_rest' => false, 350 ) 351 ); 352 359 353 add_settings_field( 360 354 'sam_reading_time_singular_format', … … 370 364 'type' => 'string', 371 365 'sanitize_callback' => 'sanitize_text_field', 372 /* translators: %1$s: The number of minutes. */373 366 'default' => esc_html__( '%1$s minute read', 'sam-reading-time' ), 374 367 'show_in_rest' => false, … … 376 369 ); 377 370 378 // Register field for Plural Format.379 371 add_settings_field( 380 372 'sam_reading_time_plural_format', … … 390 382 'type' => 'string', 391 383 'sanitize_callback' => 'sanitize_text_field', 392 /* translators: %1$s: The number of minutes. */393 384 'default' => esc_html__( '%1$s minutes read', 'sam-reading-time' ), 394 385 'show_in_rest' => false, … … 396 387 ); 397 388 398 // Register field for "Less than a minute" format.399 389 add_settings_field( 400 390 'sam_reading_time_less_than_a_minute_format', … … 415 405 ); 416 406 417 // Register field for Hide if Less Than A Minute.418 407 add_settings_field( 419 408 'sam_reading_time_hide_if_less_than_a_minute', … … 428 417 array( 429 418 'type' => 'boolean', 430 'sanitize_callback' => 'rest_sanitize_boolean', // Use WordPress's boolean sanitizer419 'sanitize_callback' => 'rest_sanitize_boolean', 431 420 'default' => false, 432 421 'show_in_rest' => false, … … 434 423 ); 435 424 436 // Register field for Prefix Text.437 425 add_settings_field( 438 426 'sam_reading_time_prefix_text', … … 453 441 ); 454 442 455 // Register field for Suffix Text.456 443 add_settings_field( 457 444 'sam_reading_time_suffix_text', … … 472 459 ); 473 460 474 // Register field for Wrapper HTML Tag.475 461 add_settings_field( 476 462 'sam_reading_time_wrapper_tag', … … 491 477 ); 492 478 493 // Register field for Enable Debug Output.494 479 add_settings_field( 495 480 'sam_reading_time_enable_debug_output', … … 510 495 ); 511 496 512 // Register field for Enable Schema.org timeRequired.513 497 add_settings_field( 514 498 'sam_reading_time_enable_schema_time_required', 515 esc_html__( 'Enable Schema.org timeRequired', 'sam-reading-time'),516 array( $this, 'enable_schema_time_required_callback'),499 esc_html__( 'Enable Schema.org timeRequired', 'sam-reading-time' ), 500 array( $this, 'enable_schema_time_required_callback' ), 517 501 'sam-reading-time', 518 502 'sam_reading_time_plugin_section' … … 542 526 public function words_per_minute_callback() { 543 527 $wpm = get_option( 'sam_reading_time_words_per_minute', 200 ); 544 // Using absint for output to ensure it's a positive integer, though sanitize_words_per_minute handles input.545 528 echo '<input type="number" name="sam_reading_time_words_per_minute" value="' . absint( $wpm ) . '" min="1" class="regular-text" />'; 546 529 echo '<p class="description">' . esc_html__( 'Average number of words a person reads per minute.', 'sam-reading-time' ) . '</p>'; … … 564 547 $format = get_option( 'sam_reading_time_singular_format', esc_html__( '%1$s minute read', 'sam-reading-time' ) ); 565 548 echo '<input type="text" name="sam_reading_time_singular_format" value="' . esc_attr( $format ) . '" class="regular-text" />'; 566 // Using literal text for example to avoid placeholder issues with sprintf.567 549 echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "1 minute read"', 'sam-reading-time' ) . '</p>'; 568 550 } … … 574 556 $format = get_option( 'sam_reading_time_plural_format', esc_html__( '%1$s minutes read', 'sam-reading-time' ) ); 575 557 echo '<input type="text" name="sam_reading_time_plural_format" value="' . esc_attr( $format ) . '" class="regular-text" />'; 576 // Using literal text for example to avoid placeholder issues with sprintf.577 558 echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "2 minutes read"', 'sam-reading-time' ) . '</p>'; 578 559 } … … 627 608 <option value="em" <?php selected( $tag, 'em' ); ?>>em</option> 628 609 </select> 629 <p class="description"><?php e sc_html_e( 'Choose the HTML tag to wrap the reading time output. This affects its display behavior.', 'sam-reading-time' ) . '<br>' . esc_html__( 'For inline display, use "span". For block display, use "div" or "p".', 'sam-reading-time' ); ?></p>610 <p class="description"><?php echo esc_html__( 'Choose the HTML tag to wrap the reading time output. This affects its display behavior.', 'sam-reading-time' ) . '<br>' . esc_html__( 'For inline display, use "span". For block display, use "div" or "p".', 'sam-reading-time' ); ?></p> 630 611 <?php 631 612 } … … 639 620 public function sanitize_wrapper_tag( $input ) { 640 621 $allowed_tags = array( 'span', 'div', 'p', 'strong', 'em' ); 641 // Use sanitize_key for stricter validation if only specific, fixed strings are allowed.642 // For HTML tags, array check is sufficient.643 622 return in_array( $input, $allowed_tags, true ) ? sanitize_key( $input ) : 'span'; 644 623 } … … 657 636 */ 658 637 public function enable_schema_time_required_callback() { 659 $enable = get_option( 'sam_reading_time_enable_schema_time_required', true);660 echo '<input type="checkbox" name="sam_reading_time_enable_schema_time_required" value="1" ' . checked( 1, $enable, false) . ' />';661 echo '<p class="description">' . esc_html__( 'Enable Schema.org timeRequired JSON-LD markup for Google Rich Snippets. Note: Markup will only be output if the [sam_reading_time] shortcode is used in the post content.', 'sam-reading-time') . '</p>';638 $enable = get_option( 'sam_reading_time_enable_schema_time_required', true ); 639 echo '<input type="checkbox" name="sam_reading_time_enable_schema_time_required" value="1" ' . checked( 1, $enable, false ) . ' />'; 640 echo '<p class="description">' . esc_html__( 'Enable Schema.org timeRequired JSON-LD markup for Google Rich Snippets. Note: Markup will only be output if the [sam_reading_time] shortcode is used in the post content.', 'sam-reading-time' ) . '</p>'; 662 641 } 663 642 … … 666 645 */ 667 646 public function options_page_html() { 668 // Check user capabilities.669 647 if ( ! current_user_can( 'manage_options' ) ) { 670 648 return; … … 677 655 <form action="options.php" method="post"> 678 656 <?php 679 // Output security fields for the registered setting.680 657 settings_fields( 'sam_reading_time' ); 681 // Output settings sections and fields.682 658 do_settings_sections( 'sam-reading-time' ); 683 // Output save changes button.684 659 submit_button( esc_html__( 'Save Changes', 'sam-reading-time' ) ); 685 660 ?> … … 698 673 <p><?php esc_html_e( 'The output of the shortcode is wrapped in an HTML tag with the default class ', 'sam-reading-time' ); ?><code>.reading-time</code>. 699 674 <?php esc_html_e( 'For custom styling, please use the WordPress Customizer (Appearance > Customize > Additional CSS).', 'sam-reading-time' ); ?></p> 700 <p><?php esc_html_e( 'Example CSS for the ', 'sam-reading-time' ); ?><code>.reading-time</code> <?php esc_html_e( 'class:', 'sam-reading-time' ); ?></p>701 675 <pre><code>.reading-time { 702 676 font-weight: bold; 703 677 color: #007bff; 704 678 font-size: 0.95em; 705 margin-right: 10px; /* Adjust for RTL if needed in your theme */679 margin-right: 10px; 706 680 padding: 5px 10px; 707 681 background-color: #f0f8ff; 708 682 border-radius: 5px; 709 display: inline-block;710 683 }</code></pre> 711 684 </div> … … 719 692 */ 720 693 public function add_cpt_reading_time_column_support() { 721 $post_types = get_post_types( ['public' => true], 'names');722 foreach ( $post_types as $type) {723 if ( !in_array($type, ['post', 'page'])) {724 add_filter( "manage_{$type}_columns", array($this, 'add_reading_time_column'));725 add_action( "manage_{$type}_custom_column", array($this, 'show_reading_time_column'), 10, 2);726 add_filter( "manage_edit-{$type}_sortable_columns", array($this, 'make_reading_time_column_sortable'));694 $post_types = get_post_types( [ 'public' => true ], 'names' ); 695 foreach ( $post_types as $type ) { 696 if ( ! in_array( $type, array( 'post', 'page' ), true ) ) { 697 add_filter( "manage_{$type}_columns", array( $this, 'add_reading_time_column' ) ); 698 add_action( "manage_{$type}_custom_column", array( $this, 'show_reading_time_column' ), 10, 2 ); 699 add_filter( "manage_edit-{$type}_sortable_columns", array( $this, 'make_reading_time_column_sortable' ) ); 727 700 } 728 701 } … … 732 705 * Adds the reading time column to the posts and pages list. 733 706 */ 734 public function add_reading_time_column( $columns) {735 $columns['reading_time'] = __( 'Reading Time', 'sam-reading-time');707 public function add_reading_time_column( $columns ) { 708 $columns['reading_time'] = __( 'Reading Time', 'sam-reading-time' ); 736 709 return $columns; 737 710 } … … 740 713 * Displays the reading time in the custom column for posts and pages. 741 714 */ 742 public function show_reading_time_column($column, $post_id) { 743 if ($column === 'reading_time') { 744 echo esc_html($this->get_reading_time($post_id)); 715 public function show_reading_time_column( $column, $post_id ) { 716 if ( $column === 'reading_time' ) { 717 $minutes = (int) $this->get_reading_time( $post_id ); // integer 718 echo $minutes ? esc_html( $minutes . ' min' ) : ''; 745 719 } 746 720 } … … 749 723 * Makes the reading time column sortable. 750 724 */ 751 public function make_reading_time_column_sortable( $columns) {725 public function make_reading_time_column_sortable( $columns ) { 752 726 $columns['reading_time'] = 'reading_time'; 753 727 return $columns; … … 755 729 756 730 /** 757 * Modifies the query to sort by reading time. 758 */ 759 public function reading_time_orderby_query($query) { 760 if (!is_admin() || !$query->is_main_query()) return; 761 $orderby = $query->get('orderby'); 762 if ($orderby == 'reading_time') { 763 $query->set('orderby', null); 764 $query->set('posts_per_page', $query->get('posts_per_page')); 765 add_filter('posts_clauses', function($clauses, $wp_query) { 766 global $wpdb; 767 if (isset($wp_query->query['orderby']) && $wp_query->query['orderby'] === null) { 768 $clauses['fields'] .= ", (LENGTH({$wpdb->posts}.post_content) - LENGTH(REPLACE({$wpdb->posts}.post_content, ' ', ''))) AS reading_time_words"; 769 $clauses['orderby'] = "reading_time_words ASC"; 770 } 771 return $clauses; 772 }, 10, 2); 731 * Modifies the query to sort by reading time (stored in postmeta). 732 */ 733 public function reading_time_orderby_query( $query ) { 734 if ( ! is_admin() || ! $query->is_main_query() ) { 735 return; 736 } 737 $orderby = $query->get( 'orderby' ); 738 if ( 'reading_time' === $orderby ) { 739 $query->set( 'meta_key', 'sam_reading_time_minutes' ); 740 $query->set( 'orderby', 'meta_value_num' ); 773 741 } 774 742 } … … 777 745 * Removes shortcodes, images, videos, and HTML tags for accurate reading time calculation. 778 746 */ 779 private function clean_content_for_reading_time( $content) {780 $content = strip_shortcodes( $content);781 $content = preg_replace( '/<pre.*?<\/pre>|<code.*?<\/code>/is', '', $content);782 $content = preg_replace( '/<img[^>]+>|<video.*?<\/video>/is', '', $content);783 $content = wp_strip_all_tags( $content);784 $content = preg_replace( '/\s+/u', ' ', $content);785 return trim( $content);747 private function clean_content_for_reading_time( $content ) { 748 $content = strip_shortcodes( $content ); 749 $content = preg_replace( '/<pre.*?<\/pre>|<code.*?<\/code>/is', '', $content ); 750 $content = preg_replace( '/<img[^>]+>|<video.*?<\/video>/is', '', $content ); 751 $content = wp_strip_all_tags( $content ); 752 $content = preg_replace( '/\s+/u', ' ', $content ); 753 return trim( $content ); 786 754 } 787 755 788 756 /** 789 757 * Retrieves the reading time from post meta or calculates it if not present. 790 */ 791 public function get_reading_time($post_id) { 792 $content = $this->get_translated_content($post_id); 793 $word_count = $this->count_words($this->clean_content_for_reading_time($content)); 794 $wpm = get_option('sam_reading_time_words_per_minute', 200); 795 $reading_time = ceil($word_count / max(1, (int)$wpm)); 796 return $reading_time ? $reading_time . ' min' : ''; 758 * 759 * IMPORTANT: This now RETURNS an integer number of minutes (0 if none). 760 * 761 * @param int $post_id 762 * @return int Minutes (integer) 763 */ 764 public function get_reading_time( $post_id ) { 765 $meta = get_post_meta( $post_id, 'sam_reading_time_minutes', true ); 766 if ( $meta !== '' && $meta !== false ) { 767 return (int) $meta; 768 } 769 770 $content = $this->get_translated_content( $post_id ); 771 $word_count = $this->count_words( $this->clean_content_for_reading_time( $content ) ); 772 $wpm = get_option( 'sam_reading_time_words_per_minute', 200 ); 773 $minutes = $word_count ? (int) ceil( $word_count / max( 1, (int) $wpm ) ) : 0; 774 775 update_post_meta( $post_id, 'sam_reading_time_minutes', $minutes ); 776 777 return $minutes; 778 } 779 780 /** 781 * Updates the reading time post meta when a post is saved. 782 * 783 * @param int $post_id 784 * @param WP_Post $post 785 */ 786 public function update_reading_time_meta( $post_id, $post = null ) { 787 if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { 788 return; 789 } 790 791 if ( ! $post ) { 792 $post = get_post( $post_id ); 793 } 794 795 $post_type = get_post_type( $post_id ); 796 if ( ! $post_type || ! post_type_supports( $post_type, 'editor' ) ) { 797 return; 798 } 799 800 $content = $this->get_translated_content( $post_id ); 801 $word_count = $this->count_words( $this->clean_content_for_reading_time( $content ) ); 802 $wpm = get_option( 'sam_reading_time_words_per_minute', 200 ); 803 $minutes = $word_count ? (int) ceil( $word_count / max( 1, (int) $wpm ) ) : 0; 804 805 update_post_meta( $post_id, 'sam_reading_time_minutes', $minutes ); 797 806 } 798 807 … … 801 810 */ 802 811 public function add_time_required_jsonld() { 803 $enable = get_option('sam_reading_time_enable_schema_time_required', true); 804 if (!$enable || !$this->schema_should_output) return; 805 if (is_singular()) { 812 $enable = get_option( 'sam_reading_time_enable_schema_time_required', true ); 813 if ( ! $enable || ! $this->schema_should_output ) { 814 return; 815 } 816 if ( is_singular() ) { 806 817 global $post; 807 $reading_time = $this->get_reading_time($post->ID); 808 $iso_duration = 'PT' . intval($reading_time) . 'M'; 809 echo '<script type="application/ld+json">' . json_encode([ 818 if ( ! $post instanceof WP_Post ) { 819 return; 820 } 821 $minutes = (int) $this->get_reading_time( $post->ID ); 822 if ( $minutes <= 0 ) { 823 return; 824 } 825 $iso_duration = 'PT' . $minutes . 'M'; 826 echo '<script type="application/ld+json">' . json_encode( array( 810 827 "@context" => "https://schema.org", 811 828 "@type" => "Article", 812 "timeRequired" => $iso_duration 813 ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';829 "timeRequired" => $iso_duration, 830 ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . '</script>'; 814 831 } 815 832 } … … 818 835 * Retrieves the translated content for compatibility with Polylang and WPML. 819 836 */ 820 public function get_translated_content( $post_id) {821 if ( function_exists('pll_get_post')) {837 public function get_translated_content( $post_id ) { 838 if ( function_exists( 'pll_get_post' ) ) { 822 839 $lang = pll_current_language(); 823 $translated_id = pll_get_post( $post_id, $lang);824 if ( $translated_id) {825 $post = get_post( $translated_id);826 return $post ->post_content;827 } 828 } 829 if ( function_exists('icl_object_id')) {830 $lang = apply_filters( 'wpml_current_language', NULL);831 $translated_id = icl_object_id( $post_id, get_post_type($post_id), true, $lang);832 if ( $translated_id) {833 $post = get_post( $translated_id);834 return $post ->post_content;835 } 836 } 837 $post = get_post( $post_id);840 $translated_id = pll_get_post( $post_id, $lang ); 841 if ( $translated_id ) { 842 $post = get_post( $translated_id ); 843 return $post ? $post->post_content : ''; 844 } 845 } 846 if ( function_exists( 'icl_object_id' ) ) { 847 $lang = apply_filters( 'wpml_current_language', NULL ); 848 $translated_id = icl_object_id( $post_id, get_post_type( $post_id ), true, $lang ); 849 if ( $translated_id ) { 850 $post = get_post( $translated_id ); 851 return $post ? $post->post_content : ''; 852 } 853 } 854 $post = get_post( $post_id ); 838 855 return $post ? $post->post_content : ''; 839 856 } -
sam-reading-time/trunk/readme.txt
r3412374 r3448182 6 6 Tested up to: 6.9 7 7 Requires PHP: 7.2 8 Stable tag: 2. 18 Stable tag: 2.2 9 9 License: GPLv2 or later 10 10 License URI: https://www.gnu.org/licenses/gpl-2.0.html … … 68 68 == Changelog == 69 69 70 = 2.2 = 71 * Improved performance by storing reading time as post meta instead of calculating on every request. 72 * Faster and more reliable sorting of reading time column in admin post lists. 73 * Added proper text domain loading (sam-reading-time) for full translation support. 74 * More stable and accurate Schema.org timeRequired JSON-LD output. 75 * Minor UI refinements in admin settings page with a cleaner, more minimal red theme. 76 * Better handling of translated content for Polylang and WPML. 77 70 78 = 2.0 = 71 79 * Added admin post list column to display reading time for all post types. -
sam-reading-time/trunk/sam-reading-time.php
r3412374 r3448182 4 4 * Plugin URI: https://github.com/samwda/srt/ 5 5 * Description: A lightweight WordPress plugin to display the estimated reading time of posts and pages using the [sam_reading_time] shortcode. 6 * Version: 2. 16 * Version: 2.2 7 7 * Author: SAM Web Design Agency 8 8 * Author URI: https://samwda.ir … … 32 32 */ 33 33 public function __construct() { 34 // Load translations 35 add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) ); 36 34 37 // Register the shortcode 35 38 add_shortcode( 'sam_reading_time', array( $this, 'display_reading_time_shortcode' ) ); … … 45 48 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_styles' ) ); 46 49 47 add_filter('manage_posts_columns', array($this, 'add_reading_time_column')); 48 add_filter('manage_pages_columns', array($this, 'add_reading_time_column')); 49 add_action('manage_posts_custom_column', array($this, 'show_reading_time_column'), 10, 2); 50 add_action('manage_pages_custom_column', array($this, 'show_reading_time_column'), 10, 2); 51 add_action('init', array($this, 'add_cpt_reading_time_column_support')); 52 add_filter('manage_edit-post_sortable_columns', array($this, 'make_reading_time_column_sortable')); 53 add_filter('manage_edit-page_sortable_columns', array($this, 'make_reading_time_column_sortable')); 54 add_action('pre_get_posts', array($this, 'reading_time_orderby_query')); 55 add_action('wp_head', array($this, 'add_time_required_jsonld')); 50 add_filter( 'manage_posts_columns', array( $this, 'add_reading_time_column' ) ); 51 add_filter( 'manage_pages_columns', array( $this, 'add_reading_time_column' ) ); 52 add_action( 'manage_posts_custom_column', array( $this, 'show_reading_time_column' ), 10, 2 ); 53 add_action( 'manage_pages_custom_column', array( $this, 'show_reading_time_column' ), 10, 2 ); 54 add_action( 'init', array( $this, 'add_cpt_reading_time_column_support' ) ); 55 add_filter( 'manage_edit-post_sortable_columns', array( $this, 'make_reading_time_column_sortable' ) ); 56 add_filter( 'manage_edit-page_sortable_columns', array( $this, 'make_reading_time_column_sortable' ) ); 57 add_action( 'pre_get_posts', array( $this, 'reading_time_orderby_query' ) ); 58 add_action( 'wp_head', array( $this, 'add_time_required_jsonld' ) ); 59 60 // Update reading time meta when posts are saved 61 add_action( 'save_post', array( $this, 'update_reading_time_meta' ), 10, 2 ); 62 } 63 64 /** 65 * Load plugin textdomain for translations. 66 */ 67 public function load_textdomain() { 68 load_plugin_textdomain( 'sam-reading-time', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' ); 56 69 } 57 70 58 71 /** 59 72 * Enqueues the plugin's CSS file for the frontend. 60 * ( Removed: No external CSS file is enqueued anymore.)73 * (Kept empty intentionally.) 61 74 */ 62 75 public function enqueue_plugin_styles() { 63 // No external CSS file is enqueued .76 // No external CSS file is enqueued by default. 64 77 } 65 78 66 79 /** 67 80 * Enqueues the plugin's admin CSS for the settings page using admin_head and inline <style>. 81 * MINIMAL, RED-THEMED POLISH ONLY. 68 82 * 69 83 * @param string $hook The current admin page. … … 73 87 return; 74 88 } 75 // Add inline CSS to admin_head (no external files or wp_add_inline_style)76 add_action( 'admin_head', function() {89 // Add minimal inline CSS to admin_head 90 add_action( 'admin_head', function() { 77 91 ?> 78 92 <style> 93 /* Minimal admin panel polish — red theme (#d00) */ 79 94 .sam-settings-container { 95 background: #ffffff; 96 border-radius: 8px; 97 border: 1px solid #f5dcdc; 98 padding: 28px; 99 max-width: 760px; 100 margin: 28px auto; 101 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; 102 color: #222; 103 } 104 105 .sam-settings-container h1 { 106 color: #d00; 107 font-size: 20px; 108 margin-bottom: 10px; 109 font-weight: 700; 110 } 111 112 .sam-settings-container p { 113 color: #333; 114 line-height: 1.6; 115 margin-bottom: 10px; 116 } 117 118 .sam-settings-container input[type="text"], 119 .sam-settings-container input[type="number"], 120 .sam-settings-container select, 121 .sam-settings-container textarea { 122 border: 1px solid #f0d6d6; 80 123 background: #fff; 81 border-radius: 12px; 82 border: 1px solid #e5e5e5; 83 box-shadow: 0 2px 12px rgba(220,0,0,0.07); 84 padding: 36px 28px 28px 28px; 85 max-width: 700px; 86 margin: 32px auto; 87 } 88 .sam-settings-container h1, .sam-settings-container h2, .sam-settings-container h3 { 89 color: #d00; 90 font-weight: 700; 91 margin-bottom: 16px; 92 } 93 .sam-settings-container form { 94 margin-bottom: 24px; 95 } 96 .sam-settings-container input, .sam-settings-container select, .sam-settings-container textarea { 97 border: 1.5px solid #d00; 98 background: #fff; 99 color: #d00; 124 color: #111; 100 125 border-radius: 6px; 101 padding: 8px 14px; 102 font-size: 15px; 103 margin-bottom: 10px; 104 transition: border 0.2s; 105 } 106 .sam-settings-container input:focus, .sam-settings-container select:focus, .sam-settings-container textarea:focus { 107 border-color: #a00; 108 outline: none; 109 } 126 padding: 8px 10px; 127 font-size: 14px; 128 margin-bottom: 8px; 129 width: 100%; 130 box-sizing: border-box; 131 } 132 110 133 .sam-settings-container input[type="checkbox"] { 111 134 accent-color: #d00; 112 width: 18px; 113 height: 18px; 114 } 135 width: 16px; 136 height: 16px; 137 vertical-align: middle; 138 margin-right: 6px; 139 } 140 115 141 .sam-settings-container .description { 116 color: # d00;142 color: #666; 117 143 font-size: 13px; 118 margin-top: 2px; 119 margin-bottom: 14px; 120 } 144 margin-top: 4px; 145 margin-bottom: 12px; 146 } 147 121 148 .sam-settings-container .usage-instructions { 122 background: #fff; 123 border-radius: 8px; 124 box-shadow: 0 1px 6px rgba(220,0,0,0.05); 125 padding: 18px 14px; 126 margin-top: 18px; 149 background: #fff7f7; 127 150 border-left: 4px solid #d00; 128 } 151 border-radius: 6px; 152 padding: 12px; 153 margin-top: 14px; 154 } 155 129 156 .sam-settings-container code, .sam-settings-container pre { 130 157 background: #fff; 131 158 color: #d00; 132 border: 1px solid # d00;159 border: 1px solid #f1d3d3; 133 160 border-radius: 4px; 134 padding: 2px 8px;135 font-size: 1 4px;161 padding: 6px 8px; 162 font-size: 13px; 136 163 font-family: "Fira Mono", "Consolas", "Menlo", monospace; 137 164 } 138 .sam-settings-container pre { 139 padding: 8px; 140 margin-top: 6px; 141 } 165 142 166 .sam-settings-container .button-primary { 143 167 background: #d00; … … 146 170 font-weight: 700; 147 171 border-radius: 6px; 148 box-shadow: 0 1px 4px rgba(220,0,0,0.07);149 padding: 10px 24px;150 font-size: 16px;151 transition: background 0.2s, color 0.2s;152 } 172 padding: 9px 18px; 173 font-size: 14px; 174 cursor: pointer; 175 } 176 153 177 .sam-settings-container .button-primary:hover { 154 background: #fff; 155 color: #d00; 156 border: 1.5px solid #d00; 178 background: #b30000; 179 } 180 181 @media (max-width: 600px) { 182 .sam-settings-container { padding: 18px; margin: 18px 12px; } 157 183 } 158 184 </style> 159 185 <?php 160 } );186 } ); 161 187 } 162 188 … … 176 202 177 203 // 3. Replace special characters (like newlines, tabs) and multiple spaces with a single space. 178 // This step is essential for preparing the text for accurate word separation.179 204 $content = preg_replace( '/\s+/u', ' ', $content ); // Use /u for Unicode support 180 205 $content = trim( $content ); // Remove leading and trailing spaces. 181 206 182 207 // 4. Split words based on whitespace and count them. 183 // Use preg_split with PREG_SPLIT_NO_EMPTY flag to remove empty strings after splitting 184 // and 'u' flag for Unicode support in Persian texts. 185 $words = preg_split('/\s+/u', $content, -1, PREG_SPLIT_NO_EMPTY); 186 187 // Count the number of elements in the words array. 188 return count($words); 208 $words = preg_split( '/\s+/u', $content, -1, PREG_SPLIT_NO_EMPTY ); 209 210 return is_array( $words ) ? count( $words ) : 0; 189 211 } 190 212 … … 212 234 $enable_debug_output = get_option( 'sam_reading_time_enable_debug_output', false ); 213 235 214 // The 'type' attribute for 'excerpt' is still supported for flexibility, 215 // but it's not advertised in the usage instructions. 216 $content_type = isset( $atts['type'] ) && in_array( $atts['type'], array( 'content', 'excerpt' ) ) ? $atts['type'] : 'content'; 236 $content_type = isset( $atts['type'] ) && in_array( $atts['type'], array( 'content', 'excerpt' ), true ) ? $atts['type'] : 'content'; 217 237 218 238 global $post; … … 220 240 $content_to_count = ''; 221 241 222 // Attempt to get post ID from standard WordPress function. 223 // This is the most reliable way when inside the loop or for singular posts. 224 if ( is_singular() || ( function_exists('get_the_ID') && get_the_ID() ) ) { 242 if ( is_singular() || ( function_exists( 'get_the_ID' ) && get_the_ID() ) ) { 225 243 $post_id = get_the_ID(); 226 } elseif ( is_a( $post, 'WP_Post' ) ) { // Fallback to global $post object244 } elseif ( is_a( $post, 'WP_Post' ) ) { 227 245 $post_id = $post->ID; 228 246 } 229 247 230 // If post ID is still not found, return a debug message or empty string.231 248 if ( ! $post_id ) { 232 249 if ( $enable_debug_output ) { … … 237 254 } 238 255 239 // Get content based on type240 256 if ( 'excerpt' === $content_type ) { 241 257 $content_to_count = get_the_excerpt( $post_id ); 242 258 } else { 243 // Using get_post_field directly for content is generally safe.244 // apply_filters('the_content', ...) could be used if you need other plugins' content filters to run,245 // but for word count, raw content is often preferred to avoid counting shortcode output etc.246 259 $content_to_count = get_post_field( 'post_content', $post_id ); 247 260 } 248 261 249 // If no content to count, return empty string.250 262 if ( empty( $content_to_count ) ) { 251 263 if ( $enable_debug_output ) { 252 /* translators: %1$s: The Post ID. */253 264 return '<span style="color: orange; direction:ltr; text-align:left; display:block; padding: 5px; border: 1px dashed orange;">' . sprintf( esc_html__( 'Sam Reading Time Debug: No content found for Post ID %1$s.', 'sam-reading-time' ), absint( $post_id ) ) . '</span>'; 254 265 } … … 256 267 } 257 268 258 // Count words in the cleaned content. 259 $word_count = $this->count_words( $content_to_count ); 260 261 // If word count is 0 (after cleaning), display nothing. 269 $word_count = $this->count_words( $this->clean_content_for_reading_time( $content_to_count ) ); 270 262 271 if ( $word_count === 0 ) { 263 272 if ( $enable_debug_output ) { 264 /* translators: %1$s: The Post ID. */265 273 return '<span style="color: orange; direction:ltr; text-align:left; display:block; padding: 5px; border: 1px dashed orange;">' . sprintf( esc_html__( 'Sam Reading Time Debug: Word count is 0 for Post ID %1$s.', 'sam-reading-time' ), absint( $post_id ) ) . '</span>'; 266 274 } … … 268 276 } 269 277 270 // Calculate raw reading time (can be decimal). 271 $valid_words_per_minute = max( 1, (int) $words_per_minute ); // Ensure WPM is at least 1. 278 $valid_words_per_minute = max( 1, (int) $words_per_minute ); 272 279 $raw_reading_time = $word_count / $valid_words_per_minute; 273 280 274 281 $formatted_reading_time = ''; 275 282 276 // Logic for "Less than a minute" format.277 // If raw reading time is less than 1 minute, use the "less than a minute" format.278 283 if ( $raw_reading_time < 1 ) { 279 284 if ( $hide_if_less_than_a_minute ) { 280 return ''; // Hide output if setting is enabled285 return ''; 281 286 } 282 287 $formatted_reading_time = $less_than_a_minute_format; 283 288 } else { 284 // For posts that are 1 minute or more, always round up to the nearest whole minute. 285 $display_time_value = ceil( $raw_reading_time ); 286 287 // Apply singular or plural format based on the rounded minute value. 289 $display_time_value = (int) ceil( $raw_reading_time ); 288 290 if ( $display_time_value === 1 ) { 289 291 $formatted_reading_time = sprintf( $singular_format, $display_time_value ); … … 293 295 } 294 296 295 // Add prefix and suffix.296 297 $final_output = $prefix_text . $formatted_reading_time . $suffix_text; 297 298 298 // Add debug output if enabled299 299 if ( $enable_debug_output ) { 300 /* translators: %1$s: The word count. %2$s: The raw reading time. */ 301 $final_output .= ' <span style="font-size:0.8em; opacity:0.7; direction:ltr; text-align:left; background-color: #f0f0f0; padding: 2px 5px; border-radius: 3px;">(' . sprintf( esc_html__( 'Words: %1$s, Raw Time: %2$s', 'sam-reading-time' ), absint( $word_count ), number_format($raw_reading_time, 2) ) . ')</span>'; 302 } 303 304 // Prepare CSS classes. 305 $classes = array( 'reading-time' ); // Default class 300 $final_output .= ' <span style="font-size:0.8em; opacity:0.7; direction:ltr; text-align:left; background-color: #f0f0f0; padding: 2px 5px; border-radius: 3px;">(' . sprintf( esc_html__( 'Words: %1$s, Raw Time: %2$s', 'sam-reading-time' ), absint( $word_count ), number_format( $raw_reading_time, 2 ) ) . ')</span>'; 301 } 302 303 $classes = array( 'reading-time' ); 306 304 $class_attr = 'class="' . esc_attr( implode( ' ', $classes ) ) . '"'; 307 305 308 // Return the formatted reading time wrapped in the chosen HTML tag.309 306 return '<' . esc_attr( $wrapper_tag ) . ' ' . $class_attr . '>' . $final_output . '</' . esc_attr( $wrapper_tag ) . '>'; 310 307 } … … 316 313 public function add_admin_menu() { 317 314 add_submenu_page( 318 'edit.php', // Parent slug for Posts menu319 esc_html__( 'Sam Reading Time Settings', 'sam-reading-time' ), // Page title320 esc_html__( 'Sam Reading Time', 'sam-reading-time' ), // Menu title321 'manage_options', // Capability required to access322 'sam-reading-time', // Menu slug323 array( $this, 'options_page_html' ) // Callback function to display page HTML315 'edit.php', 316 esc_html__( 'Sam Reading Time Settings', 'sam-reading-time' ), 317 esc_html__( 'Sam Reading Time', 'sam-reading-time' ), 318 'manage_options', 319 'sam-reading-time', 320 array( $this, 'options_page_html' ) 324 321 ); 325 322 } … … 329 326 */ 330 327 public function initialize_settings() { 331 // Register a settings section.332 328 add_settings_section( 333 329 'sam_reading_time_plugin_section', … … 337 333 ); 338 334 339 // Register field for Words Per Minute (WPM).340 335 add_settings_field( 341 336 'sam_reading_time_words_per_minute', … … 352 347 'sanitize_callback' => array( $this, 'sanitize_words_per_minute' ), 353 348 'default' => 200, 354 'show_in_rest' => false, // Not exposed via REST API 355 ) 356 ); 357 358 // Register field for Singular Format. 349 'show_in_rest' => false, 350 ) 351 ); 352 359 353 add_settings_field( 360 354 'sam_reading_time_singular_format', … … 370 364 'type' => 'string', 371 365 'sanitize_callback' => 'sanitize_text_field', 372 /* translators: %1$s: The number of minutes. */373 366 'default' => esc_html__( '%1$s minute read', 'sam-reading-time' ), 374 367 'show_in_rest' => false, … … 376 369 ); 377 370 378 // Register field for Plural Format.379 371 add_settings_field( 380 372 'sam_reading_time_plural_format', … … 390 382 'type' => 'string', 391 383 'sanitize_callback' => 'sanitize_text_field', 392 /* translators: %1$s: The number of minutes. */393 384 'default' => esc_html__( '%1$s minutes read', 'sam-reading-time' ), 394 385 'show_in_rest' => false, … … 396 387 ); 397 388 398 // Register field for "Less than a minute" format.399 389 add_settings_field( 400 390 'sam_reading_time_less_than_a_minute_format', … … 415 405 ); 416 406 417 // Register field for Hide if Less Than A Minute.418 407 add_settings_field( 419 408 'sam_reading_time_hide_if_less_than_a_minute', … … 428 417 array( 429 418 'type' => 'boolean', 430 'sanitize_callback' => 'rest_sanitize_boolean', // Use WordPress's boolean sanitizer419 'sanitize_callback' => 'rest_sanitize_boolean', 431 420 'default' => false, 432 421 'show_in_rest' => false, … … 434 423 ); 435 424 436 // Register field for Prefix Text.437 425 add_settings_field( 438 426 'sam_reading_time_prefix_text', … … 453 441 ); 454 442 455 // Register field for Suffix Text.456 443 add_settings_field( 457 444 'sam_reading_time_suffix_text', … … 472 459 ); 473 460 474 // Register field for Wrapper HTML Tag.475 461 add_settings_field( 476 462 'sam_reading_time_wrapper_tag', … … 491 477 ); 492 478 493 // Register field for Enable Debug Output.494 479 add_settings_field( 495 480 'sam_reading_time_enable_debug_output', … … 510 495 ); 511 496 512 // Register field for Enable Schema.org timeRequired.513 497 add_settings_field( 514 498 'sam_reading_time_enable_schema_time_required', 515 esc_html__( 'Enable Schema.org timeRequired', 'sam-reading-time'),516 array( $this, 'enable_schema_time_required_callback'),499 esc_html__( 'Enable Schema.org timeRequired', 'sam-reading-time' ), 500 array( $this, 'enable_schema_time_required_callback' ), 517 501 'sam-reading-time', 518 502 'sam_reading_time_plugin_section' … … 542 526 public function words_per_minute_callback() { 543 527 $wpm = get_option( 'sam_reading_time_words_per_minute', 200 ); 544 // Using absint for output to ensure it's a positive integer, though sanitize_words_per_minute handles input.545 528 echo '<input type="number" name="sam_reading_time_words_per_minute" value="' . absint( $wpm ) . '" min="1" class="regular-text" />'; 546 529 echo '<p class="description">' . esc_html__( 'Average number of words a person reads per minute.', 'sam-reading-time' ) . '</p>'; … … 564 547 $format = get_option( 'sam_reading_time_singular_format', esc_html__( '%1$s minute read', 'sam-reading-time' ) ); 565 548 echo '<input type="text" name="sam_reading_time_singular_format" value="' . esc_attr( $format ) . '" class="regular-text" />'; 566 // Using literal text for example to avoid placeholder issues with sprintf.567 549 echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "1 minute read"', 'sam-reading-time' ) . '</p>'; 568 550 } … … 574 556 $format = get_option( 'sam_reading_time_plural_format', esc_html__( '%1$s minutes read', 'sam-reading-time' ) ); 575 557 echo '<input type="text" name="sam_reading_time_plural_format" value="' . esc_attr( $format ) . '" class="regular-text" />'; 576 // Using literal text for example to avoid placeholder issues with sprintf.577 558 echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "2 minutes read"', 'sam-reading-time' ) . '</p>'; 578 559 } … … 627 608 <option value="em" <?php selected( $tag, 'em' ); ?>>em</option> 628 609 </select> 629 <p class="description"><?php e sc_html_e( 'Choose the HTML tag to wrap the reading time output. This affects its display behavior.', 'sam-reading-time' ) . '<br>' . esc_html__( 'For inline display, use "span". For block display, use "div" or "p".', 'sam-reading-time' ); ?></p>610 <p class="description"><?php echo esc_html__( 'Choose the HTML tag to wrap the reading time output. This affects its display behavior.', 'sam-reading-time' ) . '<br>' . esc_html__( 'For inline display, use "span". For block display, use "div" or "p".', 'sam-reading-time' ); ?></p> 630 611 <?php 631 612 } … … 639 620 public function sanitize_wrapper_tag( $input ) { 640 621 $allowed_tags = array( 'span', 'div', 'p', 'strong', 'em' ); 641 // Use sanitize_key for stricter validation if only specific, fixed strings are allowed.642 // For HTML tags, array check is sufficient.643 622 return in_array( $input, $allowed_tags, true ) ? sanitize_key( $input ) : 'span'; 644 623 } … … 657 636 */ 658 637 public function enable_schema_time_required_callback() { 659 $enable = get_option( 'sam_reading_time_enable_schema_time_required', true);660 echo '<input type="checkbox" name="sam_reading_time_enable_schema_time_required" value="1" ' . checked( 1, $enable, false) . ' />';661 echo '<p class="description">' . esc_html__( 'Enable Schema.org timeRequired JSON-LD markup for Google Rich Snippets. Note: Markup will only be output if the [sam_reading_time] shortcode is used in the post content.', 'sam-reading-time') . '</p>';638 $enable = get_option( 'sam_reading_time_enable_schema_time_required', true ); 639 echo '<input type="checkbox" name="sam_reading_time_enable_schema_time_required" value="1" ' . checked( 1, $enable, false ) . ' />'; 640 echo '<p class="description">' . esc_html__( 'Enable Schema.org timeRequired JSON-LD markup for Google Rich Snippets. Note: Markup will only be output if the [sam_reading_time] shortcode is used in the post content.', 'sam-reading-time' ) . '</p>'; 662 641 } 663 642 … … 666 645 */ 667 646 public function options_page_html() { 668 // Check user capabilities.669 647 if ( ! current_user_can( 'manage_options' ) ) { 670 648 return; … … 677 655 <form action="options.php" method="post"> 678 656 <?php 679 // Output security fields for the registered setting.680 657 settings_fields( 'sam_reading_time' ); 681 // Output settings sections and fields.682 658 do_settings_sections( 'sam-reading-time' ); 683 // Output save changes button.684 659 submit_button( esc_html__( 'Save Changes', 'sam-reading-time' ) ); 685 660 ?> … … 698 673 <p><?php esc_html_e( 'The output of the shortcode is wrapped in an HTML tag with the default class ', 'sam-reading-time' ); ?><code>.reading-time</code>. 699 674 <?php esc_html_e( 'For custom styling, please use the WordPress Customizer (Appearance > Customize > Additional CSS).', 'sam-reading-time' ); ?></p> 700 <p><?php esc_html_e( 'Example CSS for the ', 'sam-reading-time' ); ?><code>.reading-time</code> <?php esc_html_e( 'class:', 'sam-reading-time' ); ?></p>701 675 <pre><code>.reading-time { 702 676 font-weight: bold; 703 677 color: #007bff; 704 678 font-size: 0.95em; 705 margin-right: 10px; /* Adjust for RTL if needed in your theme */679 margin-right: 10px; 706 680 padding: 5px 10px; 707 681 background-color: #f0f8ff; 708 682 border-radius: 5px; 709 display: inline-block;710 683 }</code></pre> 711 684 </div> … … 719 692 */ 720 693 public function add_cpt_reading_time_column_support() { 721 $post_types = get_post_types( ['public' => true], 'names');722 foreach ( $post_types as $type) {723 if ( !in_array($type, ['post', 'page'])) {724 add_filter( "manage_{$type}_columns", array($this, 'add_reading_time_column'));725 add_action( "manage_{$type}_custom_column", array($this, 'show_reading_time_column'), 10, 2);726 add_filter( "manage_edit-{$type}_sortable_columns", array($this, 'make_reading_time_column_sortable'));694 $post_types = get_post_types( [ 'public' => true ], 'names' ); 695 foreach ( $post_types as $type ) { 696 if ( ! in_array( $type, array( 'post', 'page' ), true ) ) { 697 add_filter( "manage_{$type}_columns", array( $this, 'add_reading_time_column' ) ); 698 add_action( "manage_{$type}_custom_column", array( $this, 'show_reading_time_column' ), 10, 2 ); 699 add_filter( "manage_edit-{$type}_sortable_columns", array( $this, 'make_reading_time_column_sortable' ) ); 727 700 } 728 701 } … … 732 705 * Adds the reading time column to the posts and pages list. 733 706 */ 734 public function add_reading_time_column( $columns) {735 $columns['reading_time'] = __( 'Reading Time', 'sam-reading-time');707 public function add_reading_time_column( $columns ) { 708 $columns['reading_time'] = __( 'Reading Time', 'sam-reading-time' ); 736 709 return $columns; 737 710 } … … 740 713 * Displays the reading time in the custom column for posts and pages. 741 714 */ 742 public function show_reading_time_column($column, $post_id) { 743 if ($column === 'reading_time') { 744 echo esc_html($this->get_reading_time($post_id)); 715 public function show_reading_time_column( $column, $post_id ) { 716 if ( $column === 'reading_time' ) { 717 $minutes = (int) $this->get_reading_time( $post_id ); // integer 718 echo $minutes ? esc_html( $minutes . ' min' ) : ''; 745 719 } 746 720 } … … 749 723 * Makes the reading time column sortable. 750 724 */ 751 public function make_reading_time_column_sortable( $columns) {725 public function make_reading_time_column_sortable( $columns ) { 752 726 $columns['reading_time'] = 'reading_time'; 753 727 return $columns; … … 755 729 756 730 /** 757 * Modifies the query to sort by reading time. 758 */ 759 public function reading_time_orderby_query($query) { 760 if (!is_admin() || !$query->is_main_query()) return; 761 $orderby = $query->get('orderby'); 762 if ($orderby == 'reading_time') { 763 $query->set('orderby', null); 764 $query->set('posts_per_page', $query->get('posts_per_page')); 765 add_filter('posts_clauses', function($clauses, $wp_query) { 766 global $wpdb; 767 if (isset($wp_query->query['orderby']) && $wp_query->query['orderby'] === null) { 768 $clauses['fields'] .= ", (LENGTH({$wpdb->posts}.post_content) - LENGTH(REPLACE({$wpdb->posts}.post_content, ' ', ''))) AS reading_time_words"; 769 $clauses['orderby'] = "reading_time_words ASC"; 770 } 771 return $clauses; 772 }, 10, 2); 731 * Modifies the query to sort by reading time (stored in postmeta). 732 */ 733 public function reading_time_orderby_query( $query ) { 734 if ( ! is_admin() || ! $query->is_main_query() ) { 735 return; 736 } 737 $orderby = $query->get( 'orderby' ); 738 if ( 'reading_time' === $orderby ) { 739 $query->set( 'meta_key', 'sam_reading_time_minutes' ); 740 $query->set( 'orderby', 'meta_value_num' ); 773 741 } 774 742 } … … 777 745 * Removes shortcodes, images, videos, and HTML tags for accurate reading time calculation. 778 746 */ 779 private function clean_content_for_reading_time( $content) {780 $content = strip_shortcodes( $content);781 $content = preg_replace( '/<pre.*?<\/pre>|<code.*?<\/code>/is', '', $content);782 $content = preg_replace( '/<img[^>]+>|<video.*?<\/video>/is', '', $content);783 $content = wp_strip_all_tags( $content);784 $content = preg_replace( '/\s+/u', ' ', $content);785 return trim( $content);747 private function clean_content_for_reading_time( $content ) { 748 $content = strip_shortcodes( $content ); 749 $content = preg_replace( '/<pre.*?<\/pre>|<code.*?<\/code>/is', '', $content ); 750 $content = preg_replace( '/<img[^>]+>|<video.*?<\/video>/is', '', $content ); 751 $content = wp_strip_all_tags( $content ); 752 $content = preg_replace( '/\s+/u', ' ', $content ); 753 return trim( $content ); 786 754 } 787 755 788 756 /** 789 757 * Retrieves the reading time from post meta or calculates it if not present. 790 */ 791 public function get_reading_time($post_id) { 792 $content = $this->get_translated_content($post_id); 793 $word_count = $this->count_words($this->clean_content_for_reading_time($content)); 794 $wpm = get_option('sam_reading_time_words_per_minute', 200); 795 $reading_time = ceil($word_count / max(1, (int)$wpm)); 796 return $reading_time ? $reading_time . ' min' : ''; 758 * 759 * IMPORTANT: This now RETURNS an integer number of minutes (0 if none). 760 * 761 * @param int $post_id 762 * @return int Minutes (integer) 763 */ 764 public function get_reading_time( $post_id ) { 765 $meta = get_post_meta( $post_id, 'sam_reading_time_minutes', true ); 766 if ( $meta !== '' && $meta !== false ) { 767 return (int) $meta; 768 } 769 770 $content = $this->get_translated_content( $post_id ); 771 $word_count = $this->count_words( $this->clean_content_for_reading_time( $content ) ); 772 $wpm = get_option( 'sam_reading_time_words_per_minute', 200 ); 773 $minutes = $word_count ? (int) ceil( $word_count / max( 1, (int) $wpm ) ) : 0; 774 775 update_post_meta( $post_id, 'sam_reading_time_minutes', $minutes ); 776 777 return $minutes; 778 } 779 780 /** 781 * Updates the reading time post meta when a post is saved. 782 * 783 * @param int $post_id 784 * @param WP_Post $post 785 */ 786 public function update_reading_time_meta( $post_id, $post = null ) { 787 if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) { 788 return; 789 } 790 791 if ( ! $post ) { 792 $post = get_post( $post_id ); 793 } 794 795 $post_type = get_post_type( $post_id ); 796 if ( ! $post_type || ! post_type_supports( $post_type, 'editor' ) ) { 797 return; 798 } 799 800 $content = $this->get_translated_content( $post_id ); 801 $word_count = $this->count_words( $this->clean_content_for_reading_time( $content ) ); 802 $wpm = get_option( 'sam_reading_time_words_per_minute', 200 ); 803 $minutes = $word_count ? (int) ceil( $word_count / max( 1, (int) $wpm ) ) : 0; 804 805 update_post_meta( $post_id, 'sam_reading_time_minutes', $minutes ); 797 806 } 798 807 … … 801 810 */ 802 811 public function add_time_required_jsonld() { 803 $enable = get_option('sam_reading_time_enable_schema_time_required', true); 804 if (!$enable || !$this->schema_should_output) return; 805 if (is_singular()) { 812 $enable = get_option( 'sam_reading_time_enable_schema_time_required', true ); 813 if ( ! $enable || ! $this->schema_should_output ) { 814 return; 815 } 816 if ( is_singular() ) { 806 817 global $post; 807 $reading_time = $this->get_reading_time($post->ID); 808 $iso_duration = 'PT' . intval($reading_time) . 'M'; 809 echo '<script type="application/ld+json">' . json_encode([ 818 if ( ! $post instanceof WP_Post ) { 819 return; 820 } 821 $minutes = (int) $this->get_reading_time( $post->ID ); 822 if ( $minutes <= 0 ) { 823 return; 824 } 825 $iso_duration = 'PT' . $minutes . 'M'; 826 echo '<script type="application/ld+json">' . json_encode( array( 810 827 "@context" => "https://schema.org", 811 828 "@type" => "Article", 812 "timeRequired" => $iso_duration 813 ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . '</script>';829 "timeRequired" => $iso_duration, 830 ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) . '</script>'; 814 831 } 815 832 } … … 818 835 * Retrieves the translated content for compatibility with Polylang and WPML. 819 836 */ 820 public function get_translated_content( $post_id) {821 if ( function_exists('pll_get_post')) {837 public function get_translated_content( $post_id ) { 838 if ( function_exists( 'pll_get_post' ) ) { 822 839 $lang = pll_current_language(); 823 $translated_id = pll_get_post( $post_id, $lang);824 if ( $translated_id) {825 $post = get_post( $translated_id);826 return $post ->post_content;827 } 828 } 829 if ( function_exists('icl_object_id')) {830 $lang = apply_filters( 'wpml_current_language', NULL);831 $translated_id = icl_object_id( $post_id, get_post_type($post_id), true, $lang);832 if ( $translated_id) {833 $post = get_post( $translated_id);834 return $post ->post_content;835 } 836 } 837 $post = get_post( $post_id);840 $translated_id = pll_get_post( $post_id, $lang ); 841 if ( $translated_id ) { 842 $post = get_post( $translated_id ); 843 return $post ? $post->post_content : ''; 844 } 845 } 846 if ( function_exists( 'icl_object_id' ) ) { 847 $lang = apply_filters( 'wpml_current_language', NULL ); 848 $translated_id = icl_object_id( $post_id, get_post_type( $post_id ), true, $lang ); 849 if ( $translated_id ) { 850 $post = get_post( $translated_id ); 851 return $post ? $post->post_content : ''; 852 } 853 } 854 $post = get_post( $post_id ); 838 855 return $post ? $post->post_content : ''; 839 856 }
Note: See TracChangeset
for help on using the changeset viewer.