Plugin Directory

Changeset 3481657


Ignore:
Timestamp:
03/13/2026 04:38:25 AM (3 weeks ago)
Author:
reinventwp
Message:

Deploying version 2.6.7 - = 2.6.7 =

Location:
natural-text-to-speech/trunk
Files:
11 added
12 deleted
14 edited

Legend:

Unmodified
Added
Removed
  • natural-text-to-speech/trunk/components/analytics/analytics.php

    r3398611 r3481657  
    4545            $res,
    4646            400
     47        );
     48    }
     49
     50    if ( 'insight' === $type && 'completion_session' === $key ) {
     51        $session_id               = sanitize_text_field( (string) $request->get_param( 'session_id' ) );
     52        $completed_sentence_index = (int) $request->get_param( 'completed_sentence_index' );
     53        $total_sentences          = (int) $request->get_param( 'total_sentences' );
     54        $post_id                  = $request->get_param( 'post_id' );
     55
     56        if ( '' === $session_id || $total_sentences < 1 ) {
     57            return new WP_REST_Response(
     58                array(
     59                    'status'  => false,
     60                    'message' => 'session_id and total_sentences are required for completion_session.',
     61                ),
     62                400
     63            );
     64        }
     65
     66        $data = Natuteto_Analytics::set_completion_session_progress(
     67            $session_id,
     68            $completed_sentence_index,
     69            $total_sentences,
     70            null !== $post_id ? (int) $post_id : null
     71        );
     72
     73        return new WP_REST_Response(
     74            array(
     75                'status' => true,
     76                'data'   => $data,
     77                'meta'   => array(
     78                    'type' => $type,
     79                    'key'  => $key,
     80                ),
     81            ),
     82            200
    4783        );
    4884    }
     
    87123    // Special case: completion_rate.
    88124    if ( 'completion_rate' === $keys ) {
    89         // Fetch play clicks and completion count within date range.
    90         $play_click       = Natuteto_Analytics::get( 'button', 'play', $after, $before )['play'];
    91         $completion_count = Natuteto_Analytics::get( 'insight', 'completion_count', $after, $before )['completion_count'];
    92 
    93         $completion_rate = array();
    94 
    95         // Convert play_click to an associative array for easy lookup by date.
    96         $play_click_map = array();
    97         foreach ( $play_click as $item ) {
    98             $play_click_map[ $item['date'] ] = $item['count'];
    99         }
    100 
    101         // Calculate completion rate.
    102         foreach ( $completion_count as $item ) {
    103             $date             = $item['date'];
    104             $completion_count = $item['count'];
    105             $play_count       = isset( $play_click_map[ $date ] ) ? $play_click_map[ $date ] : 0;
    106 
    107             // Avoid division by zero.
    108             $rate = $play_count > 0 ? ( $completion_count / $play_count ) * 100 : 0;
     125        $completion_sessions = Natuteto_Analytics::get( 'insight', 'completion_session', $after, $before );
     126        $completion_sessions = $completion_sessions['completion_session'] ?? array();
     127        $completion_rate     = array();
     128
     129        foreach ( $completion_sessions as $entry ) {
     130            $date     = $entry['date'] ?? '';
     131            $sessions = $entry['sessions'] ?? array();
     132            $sum_rate = 0;
     133            $count    = 0;
     134
     135            if ( empty( $date ) || ! is_array( $sessions ) ) {
     136                continue;
     137            }
     138
     139            foreach ( $sessions as $session ) {
     140                $total_sentences = isset( $session['total_sentences'] ) ? (int) $session['total_sentences'] : 0;
     141                $indexes         = $session['completed_sentence_indexes'] ?? array();
     142
     143                if ( $total_sentences < 1 || ! is_array( $indexes ) ) {
     144                    continue;
     145                }
     146
     147                $indexes = array_values(
     148                    array_unique(
     149                        array_filter(
     150                            array_map( 'intval', $indexes ),
     151                            function ( $index ) use ( $total_sentences ) {
     152                                return $index >= 0 && $index < $total_sentences;
     153                            }
     154                        )
     155                    )
     156                );
     157
     158                $completed_count = count( $indexes );
     159                if ( $completed_count < 1 ) {
     160                    continue;
     161                }
     162
     163                $sum_rate += ( $completed_count / $total_sentences ) * 100;
     164                ++$count;
     165            }
    109166
    110167            $completion_rate[] = array(
    111168                'date'  => $date,
    112                 'count' => round( $rate, 2 ),
     169                'count' => $count > 0 ? round( $sum_rate / $count, 2 ) : 0,
    113170            );
    114171        }
     
    165222function natuteto_analytics_reset( WP_REST_Request $request ) {
    166223    $type = strtolower( $request->get_param( 'type' ) ?? '' );
     224    $keys = strtolower( trim( (string) ( $request->get_param( 'keys' ) ?? '' ) ) );
    167225
    168226    $res = natuteto_validate_analytics( $type );
     
    175233    }
    176234
    177     $data = Natuteto_Analytics::reset( $type );
     235    $reset_keys = array();
     236    if ( '' !== $keys ) {
     237        $reset_keys = array_values(
     238            array_filter(
     239                array_map( 'trim', explode( ',', $keys ) )
     240            )
     241        );
     242
     243        foreach ( $reset_keys as $key ) {
     244            $key_validation = natuteto_validate_analytics( $type, $key );
     245            if ( ! $key_validation['status'] ) {
     246                return new WP_REST_Response(
     247                    $key_validation,
     248                    400
     249                );
     250            }
     251        }
     252    }
     253
     254    if ( empty( $reset_keys ) ) {
     255        Natuteto_Analytics::reset( $type );
     256    } else {
     257        foreach ( $reset_keys as $key ) {
     258            Natuteto_Analytics::reset( $type, $key );
     259        }
     260    }
    178261
    179262    $output = array(
    180263        'status' => true,
    181         'data'   => $data,
     264        'data'   => array(
     265            'type' => $type,
     266            'keys' => $reset_keys,
     267        ),
    182268    );
    183269
  • natural-text-to-speech/trunk/components/analytics/helper.php

    r3406103 r3481657  
    4545            'player_initialized',
    4646            'completion_count',
     47            'completion_session',
    4748        );
    4849
  • natural-text-to-speech/trunk/components/config.php

    r3480845 r3481657  
    3131        $data['credentials'] = $security_tools->decrypt( $data['credentials'] );
    3232    }
    33 
    34     $data['podcasts'] = natuteto_normalize_podcast_settings( $data );
    3533
    3634    return $data;
     
    112110}
    113111
    114 /**
    115  * Normalize the podcasts settings array, falling back to legacy single-feed keys.
    116  *
    117  * @param array $data Raw plugin config.
    118  * @return array
    119  */
    120 function natuteto_normalize_podcast_settings( $data ) {
    121     if ( isset( $data['podcasts'] ) && is_array( $data['podcasts'] ) && ! empty( $data['podcasts'] ) ) {
    122         return array_values(
    123             array_map(
    124                 function ( $feed_index, $feed ) {
    125                     return natuteto_sanitize_podcast_feed_settings( $feed, $feed_index );
    126                 },
    127                 array_keys( $data['podcasts'] ),
    128                 $data['podcasts']
    129             )
    130         );
    131     }
    132 
    133     return array(
    134         natuteto_sanitize_podcast_feed_settings(
    135             array(
    136                 'enabled'         => ! empty( $data['podcast_enabled'] ),
    137                 'feed_slug'       => $data['podcast_feed_slug'] ?? 'podcast',
    138                 'title'           => $data['podcast_title'] ?? '',
    139                 'description'     => $data['podcast_description'] ?? '',
    140                 'author'          => $data['podcast_author'] ?? '',
    141                 'owner_name'      => $data['podcast_owner_name'] ?? '',
    142                 'owner_email'     => $data['podcast_owner_email'] ?? '',
    143                 'copyright'       => $data['podcast_copyright'] ?? '',
    144                 'image_url'       => $data['podcast_image_url'] ?? '',
    145                 'language'        => $data['podcast_language'] ?? 'en-US',
    146                 'explicit'        => $data['podcast_explicit'] ?? 'no',
    147                 'post_types'      => $data['podcast_post_types'] ?? array( 'post' ),
    148                 'category_slugs'  => $data['podcast_category_slugs'] ?? array(),
    149                 'generation_mode' => $data['podcast_generation_mode'] ?? 'publish',
    150                 'scan_frequency'  => $data['podcast_scan_frequency'] ?? 'hourly',
    151             )
    152         ),
    153     );
    154 }
    155 
    156112
    157113/**
     
    216172 */
    217173function natuteto_rest_save_settings( $request ) {
    218     $settings = $request->get_param( 'settings' );
     174    $settings         = $request->get_param( 'settings' );
     175    $replace_existing = rest_sanitize_boolean( $request->get_param( 'replace_existing' ) );
    219176
    220177    if ( empty( $settings ) ) {
     
    245202    }
    246203
    247     $config_before = natuteto_get_plugin_config();
    248 
    249     // Override old config with new settings.
    250     $new_config = array_merge( $config_before, $validated['settings'] );
     204    if ( $replace_existing ) {
     205        $new_config = $validated['settings'];
     206    } else {
     207        $config_before = natuteto_get_plugin_config();
     208
     209        // Override old config with new settings.
     210        $new_config = array_merge( $config_before, $validated['settings'] );
     211    }
    251212
    252213    natuteto_set_plugin_config( $new_config );
     
    360321        'auto_scroll',
    361322        'pronunciation',
     323        'user_can_download_audio',
     324        'audio_schema_markup',
    362325        'disable_word_highlight',
    363326        'disable_sentence_highlight',
     
    368331        'analytics_button',
    369332        'analytics_insight',
     333        'analytics_player_visibility',
     334        'analytics_total_listening_time',
     335        'analytics_completions',
     336        'analytics_completion_rate',
    370337        'analytics_api',
    371338
     
    377344        'analytics_button',
    378345        'analytics_insight',
     346        'analytics_player_visibility',
     347        'analytics_total_listening_time',
     348        'analytics_completions',
     349        'analytics_completion_rate',
    379350        'analytics_api',
    380351    );
  • natural-text-to-speech/trunk/components/implement-plugin.php

    r3423673 r3481657  
    153153
    154154/**
     155 * Determine whether a post is eligible for audio schema markup output.
     156 *
     157 * @param WP_Post|null $post   Current post object.
     158 * @param array|null   $config Plugin config.
     159 * @return bool
     160 */
     161function natuteto_should_output_audio_schema_markup( $post = null, $config = null ) {
     162    $post = $post instanceof WP_Post ? $post : get_post();
     163
     164    if ( ! $post instanceof WP_Post ) {
     165        return false;
     166    }
     167
     168    if ( post_password_required( $post ) ) {
     169        return false;
     170    }
     171
     172    $config = is_array( $config ) ? $config : natuteto_get_plugin_config( array( 'credentials' ) );
     173
     174    if ( empty( $config['audio_schema_markup'] ) ) {
     175        return false;
     176    }
     177
     178    if ( empty( $config['audio_source'] ) || 'browser' === $config['audio_source'] ) {
     179        return false;
     180    }
     181
     182    $post_categories = wp_get_post_terms(
     183        $post->ID,
     184        'category',
     185        array( 'fields' => 'slugs' )
     186    );
     187
     188    if ( ! empty( $config['exclude_from_post_categories'] ) && array_intersect( (array) $config['exclude_from_post_categories'], (array) $post_categories ) ) {
     189        return false;
     190    }
     191
     192    $auto_embed_types = isset( $config['auto_add_for_post_types'] ) ? (array) $config['auto_add_for_post_types'] : array();
     193    if ( in_array( $post->post_type, $auto_embed_types, true ) ) {
     194        return true;
     195    }
     196
     197    return has_shortcode( $post->post_content, NATUTETO_SHORTCODE );
     198}
     199
     200/**
     201 * Output JSON-LD AudioObject markup for eligible singular content.
     202 *
     203 * @return void
     204 */
     205function natuteto_output_audio_schema_markup() {
     206    if ( is_admin() || ! is_singular() ) {
     207        return;
     208    }
     209
     210    $post = get_post();
     211    if ( ! $post instanceof WP_Post ) {
     212        return;
     213    }
     214
     215    $config = natuteto_get_plugin_config( array( 'credentials' ) );
     216    if ( ! natuteto_should_output_audio_schema_markup( $post, $config ) ) {
     217        return;
     218    }
     219
     220    $description = trim( wp_strip_all_tags( get_the_excerpt( $post ) ) );
     221    if ( '' === $description ) {
     222        $description = wp_trim_words( wp_strip_all_tags( $post->post_content ), 40, '...' );
     223    }
     224
     225    $image_url = get_the_post_thumbnail_url( $post, 'full' );
     226    $schema    = array(
     227        '@context'            => 'https://schema.org',
     228        '@type'               => 'AudioObject',
     229        'name'                => get_the_title( $post ),
     230        'description'         => $description,
     231        'url'                 => get_permalink( $post ),
     232        'contentUrl'          => function_exists( 'natuteto_get_schema_audio_url' ) ? natuteto_get_schema_audio_url( $post->ID ) : '',
     233        'encodingFormat'      => 'audio/mpeg',
     234        'inLanguage'          => str_replace( '_', '-', get_locale() ),
     235        'datePublished'       => get_post_time( 'c', true, $post ),
     236        'dateModified'        => get_post_modified_time( 'c', true, $post ),
     237        'isAccessibleForFree' => true,
     238        'associatedArticle'   => array(
     239            '@type'    => 'Article',
     240            'url'      => get_permalink( $post ),
     241            'headline' => get_the_title( $post ),
     242        ),
     243    );
     244
     245    if ( $image_url ) {
     246        $schema['thumbnailUrl'] = $image_url;
     247    }
     248
     249    $schema = array_filter(
     250        $schema,
     251        function ( $value ) {
     252            if ( is_array( $value ) ) {
     253                return ! empty( $value );
     254            }
     255
     256            return null !== $value && '' !== $value;
     257        }
     258    );
     259
     260    echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . '</script>';
     261}
     262add_action( 'wp_head', 'natuteto_output_audio_schema_markup' );
     263
     264/**
    155265 * Normalize a "dirty" JSON string so it uses straight quotes and has no trailing commas.
    156266 *
  • natural-text-to-speech/trunk/components/rest/podcast/bootstrap.php

    r3480845 r3481657  
    4242 */
    4343function natuteto_get_support_backend() {
    44     $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
    45 
    46     if ( is_string( $site_host ) && in_array( strtolower( $site_host ), array( 'localhost', '127.0.0.1' ), true ) ) {
    47         return 'http://localhost:3005';
    48     }
    49 
    50     return apply_filters( 'natuteto_support_backend', NATUTETO_REINVENT_BACKEND );
     44    return apply_filters( 'natuteto_support_backend', natuteto_get_reinvent_backend() );
    5145}
    5246
  • natural-text-to-speech/trunk/components/rest/route.php

    r3480845 r3481657  
    5353        );
    5454
     55        register_rest_route(
     56            NATUTETO_REST_API,
     57            '/download-audio',
     58            array(
     59                'methods'             => 'POST',
     60                'callback'            => 'natuteto_rest_download_audio',
     61                'permission_callback' => '__return_true',
     62            )
     63        );
     64
     65        register_rest_route(
     66            NATUTETO_REST_API,
     67            '/schema-audio/(?P<post_id>\d+)',
     68            array(
     69                'methods'             => 'GET',
     70                'callback'            => 'natuteto_rest_schema_audio',
     71                'permission_callback' => '__return_true',
     72            )
     73        );
     74
    5575        // For pronunciation?.
    5676        register_rest_route(
     
    135155            '/analytics',
    136156            array(
    137                 'methods'             => 'GET',
     157                'methods'             => 'GET, POST',
    138158                'callback'            => 'natuteto_analytics_counter',
    139159                'permission_callback' => '__return_true',
  • natural-text-to-speech/trunk/components/rest/tts-init.php

    r3471437 r3481657  
    2323        'credentials_valid',
    2424        'custom_abbreviation_code_example',
     25        'auto_add_for_post_types',
    2526        'storage',
    2627        'storage_config',
    2728        'storage_cache_expiry_months',
     29        'audio_schema_markup',
    2830        'analytics_data_retention',
     31        'analytics_insight',
     32        'analytics_player_visibility',
     33        'analytics_api',
    2934        'exclude_from_post_categories',
     35        'podcasts',
    3036    );
    3137
  • natural-text-to-speech/trunk/components/rest/tts-providers/reinventwp.php

    r3405531 r3481657  
    2323function natuteto_generate_audio_reinventwp( $text, $credentials, $audio_config = array() ) {
    2424    try {
    25         $api_url    = NATUTETO_REINVENT_BACKEND . '/api/reinvent/tts-free/make';
     25        $api_url    = untrailingslashit( natuteto_get_reinvent_backend() ) . '/api/reinvent/tts-free/make';
    2626        $user_email = get_option( NATUTETO_WEB_EMAIL, 'example@gmail.com' );
    2727
  • natural-text-to-speech/trunk/components/rest/tts.php

    r3471437 r3481657  
    1616if ( ! defined( 'ABSPATH' ) ) {
    1717    exit;
     18}
     19
     20/**
     21 * Resolve a local stored audio file path from this site's file proxy URL.
     22 *
     23 * @param string $url Candidate audio file URL.
     24 * @return string|null Absolute path when the URL maps to local audio storage.
     25 */
     26function natuteto_get_local_audio_path_from_url( $url ) {
     27    $url = is_string( $url ) ? trim( $url ) : '';
     28    if ( '' === $url ) {
     29        return null;
     30    }
     31
     32    $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
     33    $url_host  = wp_parse_url( $url, PHP_URL_HOST );
     34    if ( ! is_string( $site_host ) || ! is_string( $url_host ) || strtolower( $site_host ) !== strtolower( $url_host ) ) {
     35        return null;
     36    }
     37
     38    $query = wp_parse_url( $url, PHP_URL_QUERY );
     39    if ( ! is_string( $query ) || '' === $query ) {
     40        return null;
     41    }
     42
     43    parse_str( $query, $params );
     44    $base = sanitize_key( (string) ( $params['base'] ?? '' ) );
     45    $path = isset( $params['path'] ) ? wp_unslash( (string) $params['path'] ) : '';
     46
     47    if ( 'audio' !== $base || '' === $path ) {
     48        return null;
     49    }
     50
     51    $base_dir   = untrailingslashit( WP_CONTENT_DIR ) . '/' . trim( NATUTETO_AUDIO_STORAGE, '/' ) . '/';
     52    $relative   = ltrim( str_replace( array( '../', '..\\' ), '', $path ), '/\\' );
     53    $absolute   = wp_normalize_path( $base_dir . $relative );
     54    $normalized = wp_normalize_path( $base_dir );
     55
     56    if ( 0 !== strpos( $absolute, $normalized ) || ! file_exists( $absolute ) ) {
     57        return null;
     58    }
     59
     60    return $absolute;
    1861}
    1962
     
    248291        )
    249292    );
     293}
     294
     295/**
     296 * Proxy public merged audio download requests to the support backend.
     297 *
     298 * @param WP_REST_Request $request The request object.
     299 * @return WP_Error|void
     300 */
     301function natuteto_rest_download_audio( WP_REST_Request $request ) {
     302    $config = natuteto_get_plugin_config( array( 'credentials' ) );
     303
     304    if ( empty( $config['user_can_download_audio'] ) ) {
     305        return new WP_Error(
     306            'download_disabled',
     307            'Public audio download is disabled for this site.',
     308            array( 'status' => 403 )
     309        );
     310    }
     311
     312    $params = $request->get_json_params();
     313    $urls   = isset( $params['urls'] ) && is_array( $params['urls'] ) ? $params['urls'] : array();
     314
     315    if ( empty( $urls ) ) {
     316        return new WP_Error(
     317            'missing_urls',
     318            'Please provide a non-empty urls array.',
     319            array( 'status' => 400 )
     320        );
     321    }
     322
     323    $merged = natuteto_merge_audio_urls( $urls, 'merged.mp3' );
     324    if ( is_wp_error( $merged ) ) {
     325        return $merged;
     326    }
     327
     328    if ( ob_get_length() ) {
     329        ob_end_clean();
     330    }
     331
     332    header( 'Content-Type: ' . $merged['content_type'] );
     333    header( 'Content-Disposition: ' . $merged['content_disposition'] );
     334    header( 'Content-Length: ' . strlen( $merged['body'] ) );
     335    status_header( 200 );
     336
     337    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Streaming raw MP3 bytes from the merge service.
     338    echo $merged['body'];
     339    exit;
     340}
     341
     342/**
     343 * Merge a list of audio URLs into one MP3 through the Reinvent backend.
     344 *
     345 * @param array  $urls          Public audio URLs to merge.
     346 * @param string $download_name Suggested output filename.
     347 * @return array|WP_Error
     348 */
     349function natuteto_merge_audio_urls( $urls, $download_name = 'merged.mp3' ) {
     350    $sanitized_urls = array();
     351    $local_paths    = array();
     352
     353    foreach ( $urls as $url ) {
     354        $url = esc_url_raw( is_string( $url ) ? $url : '' );
     355
     356        if ( empty( $url ) || ! wp_http_validate_url( $url ) ) {
     357            return new WP_Error(
     358                'invalid_url',
     359                'Each audio URL must be a valid public URL.',
     360                array( 'status' => 400 )
     361            );
     362        }
     363
     364        $sanitized_urls[] = $url;
     365        $local_paths[]    = natuteto_get_local_audio_path_from_url( $url );
     366    }
     367
     368    $timeout = max( 30, min( 600, count( $sanitized_urls ) * 8 ) );
     369    $api_url = untrailingslashit( natuteto_get_reinvent_backend() ) . '/api/reinvent/audio/merge-audio';
     370
     371    $response = wp_remote_post(
     372        $api_url,
     373        array(
     374            'timeout' => $timeout,
     375            'headers' => array(
     376                'Content-Type' => 'application/json',
     377            ),
     378            'body'    => wp_json_encode(
     379                array(
     380                    'urls'       => $sanitized_urls,
     381                    'localPaths' => $local_paths,
     382                )
     383            ),
     384        )
     385    );
     386
     387    if ( is_wp_error( $response ) ) {
     388        return new WP_Error(
     389            'merge_failed',
     390            'Failed to merge audio. ' . $response->get_error_message(),
     391            array( 'status' => 500 )
     392        );
     393    }
     394
     395    $status_code = wp_remote_retrieve_response_code( $response );
     396    $body        = wp_remote_retrieve_body( $response );
     397    $decoded     = json_decode( $body, true );
     398
     399    if ( $status_code < 200 || $status_code >= 300 || empty( $body ) ) {
     400        $message = is_array( $decoded ) && ! empty( $decoded['error'] )
     401            ? sanitize_text_field( (string) $decoded['error'] )
     402            : 'Failed to merge audio.';
     403
     404        return new WP_Error(
     405            'merge_failed',
     406            $message,
     407            array( 'status' => 500 )
     408        );
     409    }
     410
     411    $content_type        = wp_remote_retrieve_header( $response, 'content-type' );
     412    $content_disposition = wp_remote_retrieve_header( $response, 'content-disposition' );
     413
     414    return array(
     415        'body'                => $body,
     416        'content_type'        => $content_type ? $content_type : 'audio/mpeg',
     417        'content_disposition' => $content_disposition ? $content_disposition : 'attachment; filename="' . sanitize_file_name( $download_name ) . '"',
     418    );
     419}
     420
     421/**
     422 * Build the stable public URL used by AudioObject schema markup.
     423 *
     424 * @param int $post_id Post ID.
     425 * @return string
     426 */
     427function natuteto_get_schema_audio_url( $post_id ) {
     428    return rest_url( NATUTETO_REST_API . '/schema-audio/' . absint( $post_id ) );
     429}
     430
     431/**
     432 * REST API endpoint used by AudioObject schema markup to expose a stable article MP3 URL.
     433 *
     434 * @param WP_REST_Request $request The request object.
     435 * @return WP_Error|void
     436 */
     437function natuteto_rest_schema_audio( WP_REST_Request $request ) {
     438    $post_id = absint( $request->get_param( 'post_id' ) );
     439
     440    if ( $post_id <= 0 ) {
     441        return new WP_Error( 'invalid_post_id', 'Invalid post ID.', array( 'status' => 400 ) );
     442    }
     443
     444    $file_info = natuteto_generate_schema_audio_file( $post_id );
     445    if ( is_wp_error( $file_info ) ) {
     446        return $file_info;
     447    }
     448
     449    $file_request = new WP_REST_Request( 'GET', '/' . NATUTETO_REST_API . '/file' );
     450    $file_request->set_param( 'base', 'audio' );
     451    $file_request->set_param( 'path', $file_info['relative_path'] );
     452
     453    return natuteto_proxy_file( $file_request );
     454}
     455
     456/**
     457 * Generate or reuse a merged per-post MP3 file for schema markup.
     458 *
     459 * @param int $post_id Post ID.
     460 * @return array|WP_Error
     461 */
     462function natuteto_generate_schema_audio_file( $post_id ) {
     463    global $wp_filesystem;
     464
     465    $config = natuteto_get_plugin_config();
     466    $post   = get_post( $post_id );
     467
     468    if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) {
     469        return new WP_Error( 'post_not_available', 'Schema audio is only available for published posts.', array( 'status' => 404 ) );
     470    }
     471
     472    if ( empty( $config['audio_schema_markup'] ) ) {
     473        return new WP_Error( 'schema_audio_disabled', 'Audio schema markup is disabled for this site.', array( 'status' => 403 ) );
     474    }
     475
     476    if ( function_exists( 'natuteto_should_output_audio_schema_markup' ) && ! natuteto_should_output_audio_schema_markup( $post, $config ) ) {
     477        return new WP_Error( 'schema_audio_unavailable', 'This post is not eligible for audio schema markup.', array( 'status' => 404 ) );
     478    }
     479
     480    $audio_source = isset( $config['audio_source'] ) ? sanitize_text_field( $config['audio_source'] ) : 'reinventwp_free';
     481    if ( 'browser' === $audio_source ) {
     482        return new WP_Error( 'schema_audio_requires_cloud', 'Audio schema markup requires a cloud audio source.', array( 'status' => 400 ) );
     483    }
     484
     485    $audio_config = isset( $config['audio_config'][ $audio_source ] ) && is_array( $config['audio_config'][ $audio_source ] )
     486        ? $config['audio_config'][ $audio_source ]
     487        : array();
     488
     489    $text = natuteto_get_schema_audio_text( $post );
     490    if ( '' === $text ) {
     491        return new WP_Error( 'schema_audio_empty', 'No readable text found for this post.', array( 'status' => 400 ) );
     492    }
     493
     494    $file_info = natuteto_get_schema_audio_file_info( $post, $audio_source, $audio_config, $text );
     495    if ( file_exists( $file_info['full_path'] ) ) {
     496        return $file_info;
     497    }
     498
     499    $sentences = natuteto_split_text_for_schema_audio( $text );
     500    if ( empty( $sentences ) ) {
     501        return new WP_Error( 'schema_audio_empty', 'No readable sentences found for this post.', array( 'status' => 400 ) );
     502    }
     503
     504    if ( ! $wp_filesystem ) {
     505        require_once ABSPATH . '/wp-admin/includes/file.php';
     506        WP_Filesystem();
     507    }
     508
     509    if ( ! file_exists( $file_info['dir'] ) ) {
     510        wp_mkdir_p( $file_info['dir'] );
     511    }
     512
     513    $urls = array();
     514    foreach ( $sentences as $sentence ) {
     515        $audio_request = new WP_REST_Request( 'POST', '/' . NATUTETO_REST_API . '/tts-make' );
     516        $audio_request->set_header( 'content-type', 'application/json' );
     517        $audio_request->set_body(
     518            wp_json_encode(
     519                array(
     520                    'text'              => $sentence,
     521                    'customAudioSource' => $audio_source,
     522                    'customConfig'      => $audio_config,
     523                    'postId'            => $post->ID,
     524                    'preset'            => 'default',
     525                    'useCache'          => true,
     526                    'postExport'        => true,
     527                )
     528            )
     529        );
     530
     531        $audio_response = natuteto_rest_make_audio( $audio_request );
     532        if ( is_wp_error( $audio_response ) ) {
     533            return $audio_response;
     534        }
     535
     536        $audio_data = $audio_response instanceof WP_REST_Response ? $audio_response->get_data() : $audio_response;
     537        $audio_url  = is_array( $audio_data ) && ! empty( $audio_data['data'] ) ? esc_url_raw( (string) $audio_data['data'] ) : '';
     538
     539        if ( '' === $audio_url ) {
     540            return new WP_Error( 'schema_audio_generation_failed', 'Failed to build sentence audio for schema markup.', array( 'status' => 500 ) );
     541        }
     542
     543        $urls[] = $audio_url;
     544    }
     545
     546    $merged = natuteto_merge_audio_urls( $urls, $file_info['filename'] );
     547    if ( is_wp_error( $merged ) ) {
     548        return $merged;
     549    }
     550
     551    $tmp_file = $file_info['full_path'] . '.tmp-' . wp_generate_password( 8, false );
     552    if ( false === $wp_filesystem->put_contents( $tmp_file, $merged['body'], FS_CHMOD_FILE ) ) {
     553        if ( $wp_filesystem->exists( $tmp_file ) ) {
     554            $wp_filesystem->delete( $tmp_file );
     555        }
     556
     557        return new WP_Error( 'schema_audio_write_failed', 'Failed to write merged schema audio to disk.', array( 'status' => 500 ) );
     558    }
     559
     560    if ( ! $wp_filesystem->move( $tmp_file, $file_info['full_path'], true ) ) {
     561        if ( ! copy( $tmp_file, $file_info['full_path'] ) ) {
     562            if ( $wp_filesystem->exists( $tmp_file ) ) {
     563                $wp_filesystem->delete( $tmp_file );
     564            }
     565
     566            return new WP_Error( 'schema_audio_write_failed', 'Failed to store merged schema audio.', array( 'status' => 500 ) );
     567        }
     568
     569        if ( $wp_filesystem->exists( $tmp_file ) ) {
     570            $wp_filesystem->delete( $tmp_file );
     571        }
     572    }
     573
     574    return $file_info;
     575}
     576
     577/**
     578 * Build the merged schema-audio cache file metadata.
     579 *
     580 * @param WP_Post $post         Current post.
     581 * @param string  $audio_source Current cloud audio source.
     582 * @param array   $audio_config Current audio config.
     583 * @param string  $text         Plain text that will be converted to audio.
     584 * @return array
     585 */
     586function natuteto_get_schema_audio_file_info( WP_Post $post, $audio_source, $audio_config, $text ) {
     587    $base_dir          = untrailingslashit( WP_CONTENT_DIR ) . '/' . trim( NATUTETO_AUDIO_STORAGE, '/' ) . '/';
     588    $post_folder       = natuteto_get_post_folder_name( $post->ID );
     589    $config_json       = natuteto_stable_json_encode( $audio_config );
     590    $config_hash_short = 'schema_' . substr( hash( 'sha256', $config_json ? $config_json : '{}' ), 0, 10 );
     591    $content_hash      = substr( hash( 'sha256', $text ), 0, 12 );
     592    $title_slug        = sanitize_title( get_the_title( $post ) );
     593    $audio_source      = sanitize_file_name( $audio_source );
     594    $filename          = sanitize_file_name( ( $title_slug ? $title_slug : 'post-' . $post->ID ) . '-' . $content_hash . '.mp3' );
     595    $relative_path     = 'schema/' . $post_folder . '/' . $audio_source . '/' . $config_hash_short . '/' . $filename;
     596    $segments          = array( 'schema', $post_folder, $audio_source, $config_hash_short, $filename );
     597    $parts             = array_map( 'rawurlencode', $segments );
     598
     599    return array(
     600        'dir'           => $base_dir . 'schema/' . $post_folder . '/' . $audio_source . '/' . $config_hash_short . '/',
     601        'filename'      => $filename,
     602        'full_path'     => $base_dir . $relative_path,
     603        'relative_path' => $relative_path,
     604        'url'           => natuteto_proxy_file_get_url( 'audio' ) . implode( '/', $parts ),
     605    );
     606}
     607
     608/**
     609 * Build the plain-text article body used for schema audio generation.
     610 *
     611 * @param WP_Post $post Current post.
     612 * @return string
     613 */
     614function natuteto_get_schema_audio_text( WP_Post $post ) {
     615    // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound
     616    $content = apply_filters( 'the_content', $post->post_content );
     617    $content = preg_replace( '/<div[^>]*class=(["\'])[^"\']*natural-tts[^"\']*\\1[^>]*>.*?<\\/div>/is', ' ', $content );
     618    $content = html_entity_decode( $content, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
     619    $content = wp_strip_all_tags( $content, true );
     620    $content = preg_replace( '/\s+/u', ' ', $content );
     621    $content = trim( is_string( $content ) ? $content : '' );
     622    $title   = trim( get_the_title( $post ) );
     623
     624    if ( '' === $title ) {
     625        return $content;
     626    }
     627
     628    return trim( $title . '. ' . $content );
     629}
     630
     631/**
     632 * Split article text into manageable TTS chunks for schema-audio generation.
     633 *
     634 * @param string $text Plain article text.
     635 * @return array
     636 */
     637function natuteto_split_text_for_schema_audio( $text ) {
     638    $text = trim( preg_replace( '/\s+/u', ' ', (string) $text ) );
     639    if ( '' === $text ) {
     640        return array();
     641    }
     642
     643    $sentences = preg_split( '/(?<=[\.\!\?。!?])\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
     644    if ( ! is_array( $sentences ) || empty( $sentences ) ) {
     645        $sentences = array( $text );
     646    }
     647
     648    $chunks = array();
     649    foreach ( $sentences as $sentence ) {
     650        $chunks = array_merge( $chunks, natuteto_chunk_schema_audio_text( $sentence ) );
     651    }
     652
     653    return array_values(
     654        array_filter(
     655            array_map( 'trim', $chunks )
     656        )
     657    );
     658}
     659
     660/**
     661 * Return a UTF-8 aware string length when mbstring is available.
     662 *
     663 * @param string $text Text to measure.
     664 * @return int
     665 */
     666function natuteto_schema_audio_strlen( $text ) {
     667    if ( function_exists( 'mb_strlen' ) ) {
     668        return mb_strlen( $text, 'UTF-8' );
     669    }
     670
     671    return strlen( $text );
     672}
     673
     674/**
     675 * Chunk oversized schema-audio text blocks to stay under provider limits.
     676 *
     677 * @param string $text      Text block.
     678 * @param int    $max_chars Maximum characters per chunk.
     679 * @return array
     680 */
     681function natuteto_chunk_schema_audio_text( $text, $max_chars = 3500 ) {
     682    $text = trim( (string) $text );
     683    if ( '' === $text ) {
     684        return array();
     685    }
     686
     687    if ( natuteto_schema_audio_strlen( $text ) <= $max_chars ) {
     688        return array( $text );
     689    }
     690
     691    $words   = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
     692    $chunks  = array();
     693    $current = '';
     694
     695    foreach ( $words as $word ) {
     696        $candidate = '' === $current ? $word : $current . ' ' . $word;
     697        if ( natuteto_schema_audio_strlen( $candidate ) > $max_chars && '' !== $current ) {
     698            $chunks[] = $current;
     699            $current  = $word;
     700            continue;
     701        }
     702
     703        $current = $candidate;
     704    }
     705
     706    if ( '' !== $current ) {
     707        $chunks[] = $current;
     708    }
     709
     710    return $chunks;
    250711}
    251712
  • natural-text-to-speech/trunk/components/rest/utils.php

    r3480845 r3481657  
    88if ( ! defined( 'ABSPATH' ) ) {
    99    exit;
     10}
     11
     12/**
     13 * Determine the ReinventWP services backend base URL.
     14 *
     15 * Local WordPress sites should talk to the local services instance instead of
     16 * the production API host.
     17 *
     18 * @return string
     19 */
     20function natuteto_get_reinvent_backend() {
     21    $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
     22
     23    if ( is_string( $site_host ) && in_array( strtolower( $site_host ), array( 'localhost', '127.0.0.1', '::1' ), true ) ) {
     24        return 'http://localhost:3005';
     25    }
     26
     27    return apply_filters( 'natuteto_reinvent_backend', NATUTETO_REINVENT_BACKEND );
    1028}
    1129
     
    3351
    3452    // External API base.
    35     $target_base = NATUTETO_REINVENT_BACKEND . '/api/reinvent/transcribe';
     53    $target_base = untrailingslashit( natuteto_get_reinvent_backend() ) . '/api/reinvent/transcribe';
    3654
    3755    // Forward all query params.
     
    11431161function natuteto_get_quota_reinventwp() {
    11441162    try {
    1145         $api_url    = NATUTETO_REINVENT_BACKEND . '/api/reinvent/tts-free/quota';
     1163        $api_url    = untrailingslashit( natuteto_get_reinvent_backend() ) . '/api/reinvent/tts-free/quota';
    11461164        $user_email = get_option( NATUTETO_WEB_EMAIL, 'example@gmail.com' );
    11471165
  • natural-text-to-speech/trunk/components/tools/class-natuteto-analytics.php

    r3416928 r3481657  
    2121
    2222    /**
     23     * Resolve whether a given analytics type/key is enabled by config.
     24     *
     25     * @param array  $config Plugin config.
     26     * @param string $type Analytics type.
     27     * @param string $key Analytics key.
     28     * @return bool
     29     */
     30    protected static function is_enabled_for_key( $config, $type, $key ) {
     31        if ( 'insight' !== $type ) {
     32            $master_enabled = $config[ 'analytics_' . $type ] ?? true;
     33            if ( ! $master_enabled ) {
     34                return false;
     35            }
     36            return true;
     37        }
     38
     39        $key_map = array(
     40            'player_initialized' => 'analytics_player_visibility',
     41            'play_time'          => 'analytics_total_listening_time',
     42            'completion_count'   => 'analytics_completions',
     43            'completion_session' => 'analytics_completion_rate',
     44        );
     45
     46        if ( isset( $key_map[ $key ] ) ) {
     47            return $config[ $key_map[ $key ] ] ?? true;
     48        }
     49
     50        return $config['analytics_insight'] ?? true;
     51    }
     52
     53    /**
    2354     * Normalize option type to always include plugin prefix.
    2455     *
     
    6192
    6293        // Check if analytics is enabled for this type.
    63         $enable = $config[ 'analytics_' . $clean_type ] ?? true;
    64         if ( ! $enable ) {
     94        if ( ! self::is_enabled_for_key( $config, $clean_type, $key ) ) {
    6595            return null;
    6696        }
     
    123153
    124154    /**
     155     * Upsert session-based completion progress for the current day.
     156     *
     157     * Data shape per date:
     158     * [
     159     *   'date' => '13-03-2026',
     160     *   'sessions' => [
     161     *     'session-id' => [
     162     *       'total_sentences' => 10,
     163     *       'completed_sentence_indexes' => [4, 5],
     164     *       'updated_at' => 1710288000,
     165     *     ],
     166     *   ],
     167     * ]
     168     *
     169     * @param string   $session_id                Client-generated playback session id.
     170     * @param int      $completed_sentence_index  Zero-based completed sentence index.
     171     * @param int      $total_sentences           Total sentences in the playback session.
     172     * @param int|null $post_id                   Optional post id for debugging/reporting.
     173     *
     174     * @return array|null Updated session record or null if analytics is disabled.
     175     */
     176    public static function set_completion_session_progress( $session_id, $completed_sentence_index, $total_sentences, $post_id = null ) {
     177        $config = natuteto_get_plugin_config();
     178        if ( ! self::is_enabled_for_key( $config, 'insight', 'completion_session' ) ) {
     179            return null;
     180        }
     181
     182        $session_id               = preg_replace( '/[^a-zA-Z0-9_-]/', '', (string) $session_id );
     183        $completed_sentence_index = (int) $completed_sentence_index;
     184        $total_sentences          = (int) $total_sentences;
     185        $post_id                  = null !== $post_id ? (int) $post_id : null;
     186
     187        if ( empty( $session_id ) || $total_sentences < 1 ) {
     188            return null;
     189        }
     190
     191        if ( $completed_sentence_index < 0 || $completed_sentence_index >= $total_sentences ) {
     192            return null;
     193        }
     194
     195        $opt_type = self::normalize_type( 'insight' );
     196        $data     = get_option( $opt_type, array() );
     197
     198        if ( ! isset( $data['completion_session'] ) || ! is_array( $data['completion_session'] ) ) {
     199            $data['completion_session'] = array();
     200        }
     201
     202        if ( function_exists( 'current_time' ) && function_exists( 'date_i18n' ) ) {
     203            $today     = date_i18n( 'd-m-Y', current_time( 'timestamp' ) );
     204            $timestamp = current_time( 'timestamp' );
     205        } else {
     206            $today     = gmdate( 'd-m-Y' );
     207            $timestamp = time();
     208        }
     209
     210        $last_index = count( $data['completion_session'] ) - 1;
     211
     212        if ( -1 === $last_index || ! isset( $data['completion_session'][ $last_index ]['date'] ) || $data['completion_session'][ $last_index ]['date'] !== $today ) {
     213            $data['completion_session'][] = array(
     214                'date'     => $today,
     215                'sessions' => array(),
     216            );
     217            $last_index                   = count( $data['completion_session'] ) - 1;
     218        }
     219
     220        if ( ! isset( $data['completion_session'][ $last_index ]['sessions'] ) || ! is_array( $data['completion_session'][ $last_index ]['sessions'] ) ) {
     221            $data['completion_session'][ $last_index ]['sessions'] = array();
     222        }
     223
     224        if ( ! isset( $data['completion_session'][ $last_index ]['sessions'][ $session_id ] ) || ! is_array( $data['completion_session'][ $last_index ]['sessions'][ $session_id ] ) ) {
     225            $data['completion_session'][ $last_index ]['sessions'][ $session_id ] = array(
     226                'total_sentences'            => $total_sentences,
     227                'completed_sentence_indexes' => array(),
     228                'updated_at'                 => $timestamp,
     229            );
     230        }
     231
     232        $session = $data['completion_session'][ $last_index ]['sessions'][ $session_id ];
     233
     234        $session['total_sentences'] = $total_sentences;
     235        $session['updated_at']      = $timestamp;
     236
     237        if ( null !== $post_id ) {
     238            $session['post_id'] = $post_id;
     239        }
     240
     241        if ( ! isset( $session['completed_sentence_indexes'] ) || ! is_array( $session['completed_sentence_indexes'] ) ) {
     242            $session['completed_sentence_indexes'] = array();
     243        }
     244
     245        $session['completed_sentence_indexes'][] = $completed_sentence_index;
     246        $session['completed_sentence_indexes']   = array_values(
     247            array_unique(
     248                array_map(
     249                    'intval',
     250                    $session['completed_sentence_indexes']
     251                )
     252            )
     253        );
     254        sort( $session['completed_sentence_indexes'], SORT_NUMERIC );
     255
     256        $data['completion_session'][ $last_index ]['sessions'][ $session_id ] = $session;
     257
     258        $max_days = $config['analytics_data_retention'] ?? 365;
     259        if ( count( $data['completion_session'] ) > $max_days ) {
     260            $data['completion_session'] = array_slice( $data['completion_session'], -$max_days );
     261        }
     262
     263        update_option( $opt_type, $data );
     264
     265        return $session;
     266    }
     267
     268    /**
    125269     * Retrieve analytics data for a given type, optionally filtering by keys and date range.
    126270     *
  • natural-text-to-speech/trunk/constants.php

    r3478726 r3481657  
    107107const NATUTETO_DEFAULT_CONFIG = array(
    108108    // Embedding.
    109     'auto_add_for_post_types'     => array( 'post' ),
    110     'exclude_elements'            => array( 'pre', 'code' ),
    111     'exclude_texts'               => array(),
     109    'auto_add_for_post_types'        => array( 'post' ),
     110    'exclude_elements'               => array( 'pre', 'code' ),
     111    'exclude_texts'                  => array(),
    112112
    113113    // Runtime.
    114     'double_click_gesture'        => true,
    115     'auto_scroll'                 => true,
    116     'pronunciation'               => false,
    117     'disable_sentence_highlight'  => false,
    118     'disable_word_highlight'      => false,
     114    'double_click_gesture'           => true,
     115    'auto_scroll'                    => true,
     116    'pronunciation'                  => false,
     117    'user_can_download_audio'        => false,
     118    'audio_schema_markup'            => false,
     119    'disable_sentence_highlight'     => false,
     120    'disable_word_highlight'         => false,
    119121
    120122    // Audio source and integration.
    121     'storage'                     => 'local',
    122     'storage_config'              => array(),
    123     'storage_cache_expiry_months' => 1,
    124     'tts_rate_limit'              => 60,
    125 
    126     'audio_source'                => 'reinventwp_free',
    127     'audio_config'                => array(
     123    'storage'                        => 'local',
     124    'storage_config'                 => array(),
     125    'storage_cache_expiry_months'    => 1,
     126    'tts_rate_limit'                 => 60,
     127    'analytics_data_retention'       => 90,
     128    'analytics_button'               => true,
     129    'analytics_insight'              => true,
     130    'analytics_player_visibility'    => true,
     131    'analytics_total_listening_time' => true,
     132    'analytics_completions'          => true,
     133    'analytics_completion_rate'      => true,
     134    'analytics_api'                  => true,
     135
     136    'audio_source'                   => 'reinventwp_free',
     137    'audio_config'                   => array(
    128138        'browser'         => array(
    129139            'lang' => 'en-US',
     
    135145    ),
    136146
    137     'audio_config_multi_lang'     => array(),
     147    'audio_config_multi_lang'        => array(),
    138148
    139149    // Look and customization.
    140     'auto_detect_theme_color'     => true,
    141     'auto_detect_font_size'       => true,
    142     'font_size'                   => 16,
    143     'class_sentence'              => 'highlight-sentence',
    144     'class_word'                  => 'highlight-spoken',
     150    'auto_detect_theme_color'        => true,
     151    'auto_detect_font_size'          => true,
     152    'font_size'                      => 16,
     153    'class_sentence'                 => 'highlight-sentence',
     154    'class_word'                     => 'highlight-spoken',
    145155
    146156    // Podcast.
    147     'podcast_enabled'             => false,
    148     'podcast_feed_slug'           => 'podcast',
    149     'podcast_title'               => '',
    150     'podcast_description'         => '',
    151     'podcast_author'              => '',
    152     'podcast_owner_name'          => '',
    153     'podcast_owner_email'         => '',
    154     'podcast_copyright'           => '',
    155     'podcast_image_url'           => '',
    156     'podcast_language'            => 'en-US',
    157     'podcast_explicit'            => 'no',
    158     'podcast_post_types'          => array( 'post' ),
    159     'podcast_category_slugs'      => array(),
    160     'podcast_generation_mode'     => 'publish',
    161     'podcast_scan_frequency'      => 'hourly',
    162     'podcasts'                    => array(
     157    'podcast_enabled'                => false,
     158    'podcast_feed_slug'              => 'podcast',
     159    'podcast_title'                  => '',
     160    'podcast_description'            => '',
     161    'podcast_author'                 => '',
     162    'podcast_owner_name'             => '',
     163    'podcast_owner_email'            => '',
     164    'podcast_copyright'              => '',
     165    'podcast_image_url'              => '',
     166    'podcast_language'               => 'en-US',
     167    'podcast_explicit'               => 'no',
     168    'podcast_post_types'             => array( 'post' ),
     169    'podcast_category_slugs'         => array(),
     170    'podcast_generation_mode'        => 'publish',
     171    'podcast_scan_frequency'         => 'hourly',
     172    'podcasts'                       => array(
    163173        array(
    164174            'enabled'         => false,
  • natural-text-to-speech/trunk/natural-text-to-speech.php

    r3481123 r3481657  
    44 * Plugin URI:  https://wordpress.org/plugins/natural-text-to-speech
    55 * Description: Read aloud your posts using natural, human-like voices and highlights sentences and words as they are spoken. Available in Free and Pro versions! Start now with 20,000 free characters / month.
    6  * Version:     2.6.6
     6 * Version:     2.6.7
    77 * Author:      Reinvent WP
    88 * Author URI:  https://reinventwp.com
     
    2020 * serious issues with the options, so no if ( ! defined() ).}}
    2121 */
    22 define( 'NATUTETO_VERSION', '2.6.6' );
     22define( 'NATUTETO_VERSION', '2.6.7' );
    2323
    2424if ( ! defined( 'NATUTETO_PLUGIN_DIR' ) ) {
     
    158158 */
    159159function natuteto_has_enabled_podcast_feeds() {
    160     $config = get_option( NATUTETO_KV_STORAGE, NATUTETO_DEFAULT_CONFIG );
    161     $config = is_array( $config ) ? $config : array();
    162     $feeds  = natuteto_normalize_podcast_settings( $config );
     160    $config = natuteto_get_plugin_config();
     161    $feeds  = $config['podcasts'] ?? array();
    163162
    164163    foreach ( $feeds as $feed ) {
  • natural-text-to-speech/trunk/readme.txt

    r3481123 r3481657  
    44Requires at least: 5.0
    55Tested up to: 6.9
    6 Stable tag: 2.6.6
    7 Version: 2.6.6
     6Stable tag: 2.6.7
     7Version: 2.6.7
    88Requires PHP: 7.4
    99License: GPLv2 or later
     
    416416  * Added Post to Podcast, a feature that automatically turns selected WordPress posts into podcast episodes and generates an RSS feed ready for Spotify, Apple Podcasts, and other podcast platforms.
    417417  * What you see is what you get (WYSIWYG) Plugin Setting Page realtime preview of the post that TTS will read
     418  * Public Downloadable MP3
     419  * Bulk MP3 Generation
     420  * Advanced Analytics: Completion Rate Metrics
    418421  * Any suggestion? [fill this form](https://reinventwp.com/feedback)
    419422
     
    481484== Changelog ==
    482485
     486= 2.6.7 =
     487* Advanced Analytics: Completion Rate Metrics
     488* Public Downloadable MP3
     489* Bulk MP3 Generation
     490* Improve Plugin Setting UI/UX
     491
    483492= 2.6.6 =
    484493* Improve UI
Note: See TracChangeset for help on using the changeset viewer.