Plugin Directory

Changeset 3448182


Ignore:
Timestamp:
01/27/2026 08:54:20 PM (6 weeks ago)
Author:
samwda
Message:

Update to version 2.2 from GitHub

Location:
sam-reading-time
Files:
22 added
12 edited
1 copied

Legend:

Unmodified
Added
Removed
  • sam-reading-time/assets/banner-1544x500-fa_IR.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/assets/banner-1544x500.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/assets/banner-772×250-fa_IR.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/assets/banner-772×250.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/assets/icon-128×128.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/assets/icon-256×256.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/assets/screenshot-1.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/assets/screenshot-2.png

    • Property svn:mime-type changed from application/octet-stream to image/png
  • sam-reading-time/tags/2.2/readme.txt

    r3412374 r3448182  
    66Tested up to: 6.9
    77Requires PHP: 7.2
    8 Stable tag: 2.1
     8Stable tag: 2.2
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    6868== Changelog ==
    6969
     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
    7078= 2.0 =
    7179* 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  
    44 * Plugin URI:  https://github.com/samwda/srt/
    55 * Description: A lightweight WordPress plugin to display the estimated reading time of posts and pages using the [sam_reading_time] shortcode.
    6  * Version:     2.1
     6 * Version:     2.2
    77 * Author:      SAM Web Design Agency
    88 * Author URI:  https://samwda.ir
     
    3232     */
    3333    public function __construct() {
     34        // Load translations
     35        add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) );
     36
    3437        // Register the shortcode
    3538        add_shortcode( 'sam_reading_time', array( $this, 'display_reading_time_shortcode' ) );
     
    4548        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_styles' ) );
    4649
    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' );
    5669    }
    5770
    5871    /**
    5972     * Enqueues the plugin's CSS file for the frontend.
    60      * (Removed: No external CSS file is enqueued anymore.)
     73     * (Kept empty intentionally.)
    6174     */
    6275    public function enqueue_plugin_styles() {
    63         // No external CSS file is enqueued.
     76        // No external CSS file is enqueued by default.
    6477    }
    6578
    6679    /**
    6780     * Enqueues the plugin's admin CSS for the settings page using admin_head and inline <style>.
     81     * MINIMAL, RED-THEMED POLISH ONLY.
    6882     *
    6983     * @param string $hook The current admin page.
     
    7387            return;
    7488        }
    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() {
    7791            ?>
    7892            <style>
     93            /* Minimal admin panel polish — red theme (#d00) */
    7994            .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;
    80123              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;
    100125              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
    110133            .sam-settings-container input[type="checkbox"] {
    111134              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
    115141            .sam-settings-container .description {
    116               color: #d00;
     142              color: #666;
    117143              font-size: 13px;
    118               margin-top: 2px;
    119               margin-bottom: 14px;
    120             }
     144              margin-top: 4px;
     145              margin-bottom: 12px;
     146            }
     147
    121148            .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;
    127150              border-left: 4px solid #d00;
    128             }
     151              border-radius: 6px;
     152              padding: 12px;
     153              margin-top: 14px;
     154            }
     155
    129156            .sam-settings-container code, .sam-settings-container pre {
    130157              background: #fff;
    131158              color: #d00;
    132               border: 1px solid #d00;
     159              border: 1px solid #f1d3d3;
    133160              border-radius: 4px;
    134               padding: 2px 8px;
    135               font-size: 14px;
     161              padding: 6px 8px;
     162              font-size: 13px;
    136163              font-family: "Fira Mono", "Consolas", "Menlo", monospace;
    137164            }
    138             .sam-settings-container pre {
    139               padding: 8px;
    140               margin-top: 6px;
    141             }
     165
    142166            .sam-settings-container .button-primary {
    143167              background: #d00;
     
    146170              font-weight: 700;
    147171              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
    153177            .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; }
    157183            }
    158184            </style>
    159185            <?php
    160         });
     186        } );
    161187    }
    162188
     
    176202
    177203        // 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.
    179204        $content = preg_replace( '/\s+/u', ' ', $content ); // Use /u for Unicode support
    180205        $content = trim( $content ); // Remove leading and trailing spaces.
    181206
    182207        // 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;
    189211    }
    190212
     
    212234        $enable_debug_output     = get_option( 'sam_reading_time_enable_debug_output', false );
    213235
    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';
    217237
    218238        global $post;
     
    220240        $content_to_count = '';
    221241
    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() ) ) {
    225243            $post_id = get_the_ID();
    226         } elseif ( is_a( $post, 'WP_Post' ) ) { // Fallback to global $post object
     244        } elseif ( is_a( $post, 'WP_Post' ) ) {
    227245            $post_id = $post->ID;
    228246        }
    229247
    230         // If post ID is still not found, return a debug message or empty string.
    231248        if ( ! $post_id ) {
    232249            if ( $enable_debug_output ) {
     
    237254        }
    238255
    239         // Get content based on type
    240256        if ( 'excerpt' === $content_type ) {
    241257            $content_to_count = get_the_excerpt( $post_id );
    242258        } 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.
    246259            $content_to_count = get_post_field( 'post_content', $post_id );
    247260        }
    248261
    249         // If no content to count, return empty string.
    250262        if ( empty( $content_to_count ) ) {
    251263            if ( $enable_debug_output ) {
    252                 /* translators: %1$s: The Post ID. */
    253264                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>';
    254265            }
     
    256267        }
    257268
    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
    262271        if ( $word_count === 0 ) {
    263272            if ( $enable_debug_output ) {
    264                 /* translators: %1$s: The Post ID. */
    265273                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>';
    266274            }
     
    268276        }
    269277
    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 );
    272279        $raw_reading_time = $word_count / $valid_words_per_minute;
    273280
    274281        $formatted_reading_time = '';
    275282
    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.
    278283        if ( $raw_reading_time < 1 ) {
    279284            if ( $hide_if_less_than_a_minute ) {
    280                 return ''; // Hide output if setting is enabled
     285                return '';
    281286            }
    282287            $formatted_reading_time = $less_than_a_minute_format;
    283288        } 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 );
    288290            if ( $display_time_value === 1 ) {
    289291                $formatted_reading_time = sprintf( $singular_format, $display_time_value );
     
    293295        }
    294296
    295         // Add prefix and suffix.
    296297        $final_output = $prefix_text . $formatted_reading_time . $suffix_text;
    297298
    298         // Add debug output if enabled
    299299        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' );
    306304        $class_attr = 'class="' . esc_attr( implode( ' ', $classes ) ) . '"';
    307305
    308         // Return the formatted reading time wrapped in the chosen HTML tag.
    309306        return '<' . esc_attr( $wrapper_tag ) . ' ' . $class_attr . '>' . $final_output . '</' . esc_attr( $wrapper_tag ) . '>';
    310307    }
     
    316313    public function add_admin_menu() {
    317314        add_submenu_page(
    318             'edit.php', // Parent slug for Posts menu
    319             esc_html__( 'Sam Reading Time Settings', 'sam-reading-time' ), // Page title
    320             esc_html__( 'Sam Reading Time', 'sam-reading-time' ),       // Menu title
    321             'manage_options',                                                // Capability required to access
    322             'sam-reading-time',                                              // Menu slug
    323             array( $this, 'options_page_html' )                              // Callback function to display page HTML
     315            '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' )
    324321        );
    325322    }
     
    329326     */
    330327    public function initialize_settings() {
    331         // Register a settings section.
    332328        add_settings_section(
    333329            'sam_reading_time_plugin_section',
     
    337333        );
    338334
    339         // Register field for Words Per Minute (WPM).
    340335        add_settings_field(
    341336            'sam_reading_time_words_per_minute',
     
    352347                'sanitize_callback' => array( $this, 'sanitize_words_per_minute' ),
    353348                '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
    359353        add_settings_field(
    360354            'sam_reading_time_singular_format',
     
    370364                'type'              => 'string',
    371365                'sanitize_callback' => 'sanitize_text_field',
    372                 /* translators: %1$s: The number of minutes. */
    373366                'default'           => esc_html__( '%1$s minute read', 'sam-reading-time' ),
    374367                'show_in_rest'      => false,
     
    376369        );
    377370
    378         // Register field for Plural Format.
    379371        add_settings_field(
    380372            'sam_reading_time_plural_format',
     
    390382                'type'              => 'string',
    391383                'sanitize_callback' => 'sanitize_text_field',
    392                 /* translators: %1$s: The number of minutes. */
    393384                'default'           => esc_html__( '%1$s minutes read', 'sam-reading-time' ),
    394385                'show_in_rest'      => false,
     
    396387        );
    397388
    398         // Register field for "Less than a minute" format.
    399389        add_settings_field(
    400390            'sam_reading_time_less_than_a_minute_format',
     
    415405        );
    416406
    417         // Register field for Hide if Less Than A Minute.
    418407        add_settings_field(
    419408            'sam_reading_time_hide_if_less_than_a_minute',
     
    428417            array(
    429418                'type'              => 'boolean',
    430                 'sanitize_callback' => 'rest_sanitize_boolean', // Use WordPress's boolean sanitizer
     419                'sanitize_callback' => 'rest_sanitize_boolean',
    431420                'default'           => false,
    432421                'show_in_rest'      => false,
     
    434423        );
    435424
    436         // Register field for Prefix Text.
    437425        add_settings_field(
    438426            'sam_reading_time_prefix_text',
     
    453441        );
    454442
    455         // Register field for Suffix Text.
    456443        add_settings_field(
    457444            'sam_reading_time_suffix_text',
     
    472459        );
    473460
    474         // Register field for Wrapper HTML Tag.
    475461        add_settings_field(
    476462            'sam_reading_time_wrapper_tag',
     
    491477        );
    492478
    493         // Register field for Enable Debug Output.
    494479        add_settings_field(
    495480            'sam_reading_time_enable_debug_output',
     
    510495        );
    511496
    512         // Register field for Enable Schema.org timeRequired.
    513497        add_settings_field(
    514498            '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' ),
    517501            'sam-reading-time',
    518502            'sam_reading_time_plugin_section'
     
    542526    public function words_per_minute_callback() {
    543527        $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.
    545528        echo '<input type="number" name="sam_reading_time_words_per_minute" value="' . absint( $wpm ) . '" min="1" class="regular-text" />';
    546529        echo '<p class="description">' . esc_html__( 'Average number of words a person reads per minute.', 'sam-reading-time' ) . '</p>';
     
    564547        $format = get_option( 'sam_reading_time_singular_format', esc_html__( '%1$s minute read', 'sam-reading-time' ) );
    565548        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.
    567549        echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "1 minute read"', 'sam-reading-time' ) . '</p>';
    568550    }
     
    574556        $format = get_option( 'sam_reading_time_plural_format', esc_html__( '%1$s minutes read', 'sam-reading-time' ) );
    575557        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.
    577558        echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "2 minutes read"', 'sam-reading-time' ) . '</p>';
    578559    }
     
    627608            <option value="em" <?php selected( $tag, 'em' ); ?>>em</option>
    628609        </select>
    629         <p class="description"><?php esc_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>
    630611        <?php
    631612    }
     
    639620    public function sanitize_wrapper_tag( $input ) {
    640621        $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.
    643622        return in_array( $input, $allowed_tags, true ) ? sanitize_key( $input ) : 'span';
    644623    }
     
    657636     */
    658637    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>';
    662641    }
    663642
     
    666645     */
    667646    public function options_page_html() {
    668         // Check user capabilities.
    669647        if ( ! current_user_can( 'manage_options' ) ) {
    670648            return;
     
    677655                <form action="options.php" method="post">
    678656                    <?php
    679                     // Output security fields for the registered setting.
    680657                    settings_fields( 'sam_reading_time' );
    681                     // Output settings sections and fields.
    682658                    do_settings_sections( 'sam-reading-time' );
    683                     // Output save changes button.
    684659                    submit_button( esc_html__( 'Save Changes', 'sam-reading-time' ) );
    685660                    ?>
     
    698673                    <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>.
    699674                    <?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>
    701675                    <pre><code>.reading-time {
    702676    font-weight: bold;
    703677    color: #007bff;
    704678    font-size: 0.95em;
    705     margin-right: 10px; /* Adjust for RTL if needed in your theme */
     679    margin-right: 10px;
    706680    padding: 5px 10px;
    707681    background-color: #f0f8ff;
    708682    border-radius: 5px;
    709     display: inline-block;
    710683}</code></pre>
    711684                </div>
     
    719692     */
    720693    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' ) );
    727700            }
    728701        }
     
    732705     * Adds the reading time column to the posts and pages list.
    733706     */
    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' );
    736709        return $columns;
    737710    }
     
    740713     * Displays the reading time in the custom column for posts and pages.
    741714     */
    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' ) : '';
    745719        }
    746720    }
     
    749723     * Makes the reading time column sortable.
    750724     */
    751     public function make_reading_time_column_sortable($columns) {
     725    public function make_reading_time_column_sortable( $columns ) {
    752726        $columns['reading_time'] = 'reading_time';
    753727        return $columns;
     
    755729
    756730    /**
    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' );
    773741        }
    774742    }
     
    777745     * Removes shortcodes, images, videos, and HTML tags for accurate reading time calculation.
    778746     */
    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 );
    786754    }
    787755
    788756    /**
    789757     * 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 );
    797806    }
    798807
     
    801810     */
    802811    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() ) {
    806817            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(
    810827                "@context" => "https://schema.org",
    811828                "@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>';
    814831        }
    815832    }
     
    818835     * Retrieves the translated content for compatibility with Polylang and WPML.
    819836     */
    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' ) ) {
    822839            $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 );
    838855        return $post ? $post->post_content : '';
    839856    }
  • sam-reading-time/trunk/readme.txt

    r3412374 r3448182  
    66Tested up to: 6.9
    77Requires PHP: 7.2
    8 Stable tag: 2.1
     8Stable tag: 2.2
    99License: GPLv2 or later
    1010License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    6868== Changelog ==
    6969
     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
    7078= 2.0 =
    7179* Added admin post list column to display reading time for all post types.
  • sam-reading-time/trunk/sam-reading-time.php

    r3412374 r3448182  
    44 * Plugin URI:  https://github.com/samwda/srt/
    55 * Description: A lightweight WordPress plugin to display the estimated reading time of posts and pages using the [sam_reading_time] shortcode.
    6  * Version:     2.1
     6 * Version:     2.2
    77 * Author:      SAM Web Design Agency
    88 * Author URI:  https://samwda.ir
     
    3232     */
    3333    public function __construct() {
     34        // Load translations
     35        add_action( 'plugins_loaded', array( $this, 'load_textdomain' ) );
     36
    3437        // Register the shortcode
    3538        add_shortcode( 'sam_reading_time', array( $this, 'display_reading_time_shortcode' ) );
     
    4548        add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_styles' ) );
    4649
    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' );
    5669    }
    5770
    5871    /**
    5972     * Enqueues the plugin's CSS file for the frontend.
    60      * (Removed: No external CSS file is enqueued anymore.)
     73     * (Kept empty intentionally.)
    6174     */
    6275    public function enqueue_plugin_styles() {
    63         // No external CSS file is enqueued.
     76        // No external CSS file is enqueued by default.
    6477    }
    6578
    6679    /**
    6780     * Enqueues the plugin's admin CSS for the settings page using admin_head and inline <style>.
     81     * MINIMAL, RED-THEMED POLISH ONLY.
    6882     *
    6983     * @param string $hook The current admin page.
     
    7387            return;
    7488        }
    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() {
    7791            ?>
    7892            <style>
     93            /* Minimal admin panel polish — red theme (#d00) */
    7994            .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;
    80123              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;
    100125              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
    110133            .sam-settings-container input[type="checkbox"] {
    111134              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
    115141            .sam-settings-container .description {
    116               color: #d00;
     142              color: #666;
    117143              font-size: 13px;
    118               margin-top: 2px;
    119               margin-bottom: 14px;
    120             }
     144              margin-top: 4px;
     145              margin-bottom: 12px;
     146            }
     147
    121148            .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;
    127150              border-left: 4px solid #d00;
    128             }
     151              border-radius: 6px;
     152              padding: 12px;
     153              margin-top: 14px;
     154            }
     155
    129156            .sam-settings-container code, .sam-settings-container pre {
    130157              background: #fff;
    131158              color: #d00;
    132               border: 1px solid #d00;
     159              border: 1px solid #f1d3d3;
    133160              border-radius: 4px;
    134               padding: 2px 8px;
    135               font-size: 14px;
     161              padding: 6px 8px;
     162              font-size: 13px;
    136163              font-family: "Fira Mono", "Consolas", "Menlo", monospace;
    137164            }
    138             .sam-settings-container pre {
    139               padding: 8px;
    140               margin-top: 6px;
    141             }
     165
    142166            .sam-settings-container .button-primary {
    143167              background: #d00;
     
    146170              font-weight: 700;
    147171              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
    153177            .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; }
    157183            }
    158184            </style>
    159185            <?php
    160         });
     186        } );
    161187    }
    162188
     
    176202
    177203        // 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.
    179204        $content = preg_replace( '/\s+/u', ' ', $content ); // Use /u for Unicode support
    180205        $content = trim( $content ); // Remove leading and trailing spaces.
    181206
    182207        // 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;
    189211    }
    190212
     
    212234        $enable_debug_output     = get_option( 'sam_reading_time_enable_debug_output', false );
    213235
    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';
    217237
    218238        global $post;
     
    220240        $content_to_count = '';
    221241
    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() ) ) {
    225243            $post_id = get_the_ID();
    226         } elseif ( is_a( $post, 'WP_Post' ) ) { // Fallback to global $post object
     244        } elseif ( is_a( $post, 'WP_Post' ) ) {
    227245            $post_id = $post->ID;
    228246        }
    229247
    230         // If post ID is still not found, return a debug message or empty string.
    231248        if ( ! $post_id ) {
    232249            if ( $enable_debug_output ) {
     
    237254        }
    238255
    239         // Get content based on type
    240256        if ( 'excerpt' === $content_type ) {
    241257            $content_to_count = get_the_excerpt( $post_id );
    242258        } 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.
    246259            $content_to_count = get_post_field( 'post_content', $post_id );
    247260        }
    248261
    249         // If no content to count, return empty string.
    250262        if ( empty( $content_to_count ) ) {
    251263            if ( $enable_debug_output ) {
    252                 /* translators: %1$s: The Post ID. */
    253264                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>';
    254265            }
     
    256267        }
    257268
    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
    262271        if ( $word_count === 0 ) {
    263272            if ( $enable_debug_output ) {
    264                 /* translators: %1$s: The Post ID. */
    265273                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>';
    266274            }
     
    268276        }
    269277
    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 );
    272279        $raw_reading_time = $word_count / $valid_words_per_minute;
    273280
    274281        $formatted_reading_time = '';
    275282
    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.
    278283        if ( $raw_reading_time < 1 ) {
    279284            if ( $hide_if_less_than_a_minute ) {
    280                 return ''; // Hide output if setting is enabled
     285                return '';
    281286            }
    282287            $formatted_reading_time = $less_than_a_minute_format;
    283288        } 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 );
    288290            if ( $display_time_value === 1 ) {
    289291                $formatted_reading_time = sprintf( $singular_format, $display_time_value );
     
    293295        }
    294296
    295         // Add prefix and suffix.
    296297        $final_output = $prefix_text . $formatted_reading_time . $suffix_text;
    297298
    298         // Add debug output if enabled
    299299        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' );
    306304        $class_attr = 'class="' . esc_attr( implode( ' ', $classes ) ) . '"';
    307305
    308         // Return the formatted reading time wrapped in the chosen HTML tag.
    309306        return '<' . esc_attr( $wrapper_tag ) . ' ' . $class_attr . '>' . $final_output . '</' . esc_attr( $wrapper_tag ) . '>';
    310307    }
     
    316313    public function add_admin_menu() {
    317314        add_submenu_page(
    318             'edit.php', // Parent slug for Posts menu
    319             esc_html__( 'Sam Reading Time Settings', 'sam-reading-time' ), // Page title
    320             esc_html__( 'Sam Reading Time', 'sam-reading-time' ),       // Menu title
    321             'manage_options',                                                // Capability required to access
    322             'sam-reading-time',                                              // Menu slug
    323             array( $this, 'options_page_html' )                              // Callback function to display page HTML
     315            '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' )
    324321        );
    325322    }
     
    329326     */
    330327    public function initialize_settings() {
    331         // Register a settings section.
    332328        add_settings_section(
    333329            'sam_reading_time_plugin_section',
     
    337333        );
    338334
    339         // Register field for Words Per Minute (WPM).
    340335        add_settings_field(
    341336            'sam_reading_time_words_per_minute',
     
    352347                'sanitize_callback' => array( $this, 'sanitize_words_per_minute' ),
    353348                '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
    359353        add_settings_field(
    360354            'sam_reading_time_singular_format',
     
    370364                'type'              => 'string',
    371365                'sanitize_callback' => 'sanitize_text_field',
    372                 /* translators: %1$s: The number of minutes. */
    373366                'default'           => esc_html__( '%1$s minute read', 'sam-reading-time' ),
    374367                'show_in_rest'      => false,
     
    376369        );
    377370
    378         // Register field for Plural Format.
    379371        add_settings_field(
    380372            'sam_reading_time_plural_format',
     
    390382                'type'              => 'string',
    391383                'sanitize_callback' => 'sanitize_text_field',
    392                 /* translators: %1$s: The number of minutes. */
    393384                'default'           => esc_html__( '%1$s minutes read', 'sam-reading-time' ),
    394385                'show_in_rest'      => false,
     
    396387        );
    397388
    398         // Register field for "Less than a minute" format.
    399389        add_settings_field(
    400390            'sam_reading_time_less_than_a_minute_format',
     
    415405        );
    416406
    417         // Register field for Hide if Less Than A Minute.
    418407        add_settings_field(
    419408            'sam_reading_time_hide_if_less_than_a_minute',
     
    428417            array(
    429418                'type'              => 'boolean',
    430                 'sanitize_callback' => 'rest_sanitize_boolean', // Use WordPress's boolean sanitizer
     419                'sanitize_callback' => 'rest_sanitize_boolean',
    431420                'default'           => false,
    432421                'show_in_rest'      => false,
     
    434423        );
    435424
    436         // Register field for Prefix Text.
    437425        add_settings_field(
    438426            'sam_reading_time_prefix_text',
     
    453441        );
    454442
    455         // Register field for Suffix Text.
    456443        add_settings_field(
    457444            'sam_reading_time_suffix_text',
     
    472459        );
    473460
    474         // Register field for Wrapper HTML Tag.
    475461        add_settings_field(
    476462            'sam_reading_time_wrapper_tag',
     
    491477        );
    492478
    493         // Register field for Enable Debug Output.
    494479        add_settings_field(
    495480            'sam_reading_time_enable_debug_output',
     
    510495        );
    511496
    512         // Register field for Enable Schema.org timeRequired.
    513497        add_settings_field(
    514498            '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' ),
    517501            'sam-reading-time',
    518502            'sam_reading_time_plugin_section'
     
    542526    public function words_per_minute_callback() {
    543527        $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.
    545528        echo '<input type="number" name="sam_reading_time_words_per_minute" value="' . absint( $wpm ) . '" min="1" class="regular-text" />';
    546529        echo '<p class="description">' . esc_html__( 'Average number of words a person reads per minute.', 'sam-reading-time' ) . '</p>';
     
    564547        $format = get_option( 'sam_reading_time_singular_format', esc_html__( '%1$s minute read', 'sam-reading-time' ) );
    565548        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.
    567549        echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "1 minute read"', 'sam-reading-time' ) . '</p>';
    568550    }
     
    574556        $format = get_option( 'sam_reading_time_plural_format', esc_html__( '%1$s minutes read', 'sam-reading-time' ) );
    575557        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.
    577558        echo '<p class="description">' . esc_html__( 'Use %s for the reading time. Example: "2 minutes read"', 'sam-reading-time' ) . '</p>';
    578559    }
     
    627608            <option value="em" <?php selected( $tag, 'em' ); ?>>em</option>
    628609        </select>
    629         <p class="description"><?php esc_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>
    630611        <?php
    631612    }
     
    639620    public function sanitize_wrapper_tag( $input ) {
    640621        $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.
    643622        return in_array( $input, $allowed_tags, true ) ? sanitize_key( $input ) : 'span';
    644623    }
     
    657636     */
    658637    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>';
    662641    }
    663642
     
    666645     */
    667646    public function options_page_html() {
    668         // Check user capabilities.
    669647        if ( ! current_user_can( 'manage_options' ) ) {
    670648            return;
     
    677655                <form action="options.php" method="post">
    678656                    <?php
    679                     // Output security fields for the registered setting.
    680657                    settings_fields( 'sam_reading_time' );
    681                     // Output settings sections and fields.
    682658                    do_settings_sections( 'sam-reading-time' );
    683                     // Output save changes button.
    684659                    submit_button( esc_html__( 'Save Changes', 'sam-reading-time' ) );
    685660                    ?>
     
    698673                    <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>.
    699674                    <?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>
    701675                    <pre><code>.reading-time {
    702676    font-weight: bold;
    703677    color: #007bff;
    704678    font-size: 0.95em;
    705     margin-right: 10px; /* Adjust for RTL if needed in your theme */
     679    margin-right: 10px;
    706680    padding: 5px 10px;
    707681    background-color: #f0f8ff;
    708682    border-radius: 5px;
    709     display: inline-block;
    710683}</code></pre>
    711684                </div>
     
    719692     */
    720693    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' ) );
    727700            }
    728701        }
     
    732705     * Adds the reading time column to the posts and pages list.
    733706     */
    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' );
    736709        return $columns;
    737710    }
     
    740713     * Displays the reading time in the custom column for posts and pages.
    741714     */
    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' ) : '';
    745719        }
    746720    }
     
    749723     * Makes the reading time column sortable.
    750724     */
    751     public function make_reading_time_column_sortable($columns) {
     725    public function make_reading_time_column_sortable( $columns ) {
    752726        $columns['reading_time'] = 'reading_time';
    753727        return $columns;
     
    755729
    756730    /**
    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' );
    773741        }
    774742    }
     
    777745     * Removes shortcodes, images, videos, and HTML tags for accurate reading time calculation.
    778746     */
    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 );
    786754    }
    787755
    788756    /**
    789757     * 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 );
    797806    }
    798807
     
    801810     */
    802811    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() ) {
    806817            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(
    810827                "@context" => "https://schema.org",
    811828                "@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>';
    814831        }
    815832    }
     
    818835     * Retrieves the translated content for compatibility with Polylang and WPML.
    819836     */
    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' ) ) {
    822839            $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 );
    838855        return $post ? $post->post_content : '';
    839856    }
Note: See TracChangeset for help on using the changeset viewer.