Changeset 3481657
- Timestamp:
- 03/13/2026 04:38:25 AM (3 weeks ago)
- Location:
- natural-text-to-speech/trunk
- Files:
-
- 11 added
- 12 deleted
- 14 edited
-
components/analytics/analytics.php (modified) (4 diffs)
-
components/analytics/helper.php (modified) (1 diff)
-
components/config.php (modified) (7 diffs)
-
components/implement-plugin.php (modified) (1 diff)
-
components/rest/podcast/bootstrap.php (modified) (1 diff)
-
components/rest/route.php (modified) (2 diffs)
-
components/rest/tts-init.php (modified) (1 diff)
-
components/rest/tts-providers/reinventwp.php (modified) (1 diff)
-
components/rest/tts.php (modified) (2 diffs)
-
components/rest/utils.php (modified) (3 diffs)
-
components/tools/class-natuteto-analytics.php (modified) (3 diffs)
-
constants.php (modified) (2 diffs)
-
natural-text-to-speech.php (modified) (3 diffs)
-
public/js/ntts-admin-v2.6.6-main.js (deleted)
-
public/js/ntts-admin-v2.6.6-vendor.js (deleted)
-
public/js/ntts-admin-v2.6.7-main.js (added)
-
public/js/ntts-admin-v2.6.7-vendor.js (added)
-
public/js/ntts-public-v2.6.6-main.js (deleted)
-
public/js/ntts-public-v2.6.6-vendor.js (deleted)
-
public/js/ntts-public-v2.6.7-main.js (added)
-
public/js/ntts-public-v2.6.7-vendor.js (added)
-
public/js/nttsa-19009217.js (deleted)
-
public/js/nttsa-24396252.js (deleted)
-
public/js/nttsa-4b49ff18.js (deleted)
-
public/js/nttsa-4d279764.js (added)
-
public/js/nttsa-4eb3d3db.js (deleted)
-
public/js/nttsa-56c7638d.js (added)
-
public/js/nttsa-62c495e8.js (added)
-
public/js/nttsa-879058d6.js (deleted)
-
public/js/nttsa-8e51e405.js (deleted)
-
public/js/nttsa-b98d8b06.js (deleted)
-
public/js/nttsa-bfb339ea.js (added)
-
public/js/nttsa-d3d992a1.js (deleted)
-
public/js/nttsa-d6d8c914.js (added)
-
public/js/nttsa-e5c3a17b.js (added)
-
public/js/nttsa-e8849ac2.js (added)
-
readme.txt (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
natural-text-to-speech/trunk/components/analytics/analytics.php
r3398611 r3481657 45 45 $res, 46 46 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 47 83 ); 48 84 } … … 87 123 // Special case: completion_rate. 88 124 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 } 109 166 110 167 $completion_rate[] = array( 111 168 'date' => $date, 112 'count' => round( $rate, 2 ),169 'count' => $count > 0 ? round( $sum_rate / $count, 2 ) : 0, 113 170 ); 114 171 } … … 165 222 function natuteto_analytics_reset( WP_REST_Request $request ) { 166 223 $type = strtolower( $request->get_param( 'type' ) ?? '' ); 224 $keys = strtolower( trim( (string) ( $request->get_param( 'keys' ) ?? '' ) ) ); 167 225 168 226 $res = natuteto_validate_analytics( $type ); … … 175 233 } 176 234 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 } 178 261 179 262 $output = array( 180 263 'status' => true, 181 'data' => $data, 264 'data' => array( 265 'type' => $type, 266 'keys' => $reset_keys, 267 ), 182 268 ); 183 269 -
natural-text-to-speech/trunk/components/analytics/helper.php
r3406103 r3481657 45 45 'player_initialized', 46 46 'completion_count', 47 'completion_session', 47 48 ); 48 49 -
natural-text-to-speech/trunk/components/config.php
r3480845 r3481657 31 31 $data['credentials'] = $security_tools->decrypt( $data['credentials'] ); 32 32 } 33 34 $data['podcasts'] = natuteto_normalize_podcast_settings( $data );35 33 36 34 return $data; … … 112 110 } 113 111 114 /**115 * Normalize the podcasts settings array, falling back to legacy single-feed keys.116 *117 * @param array $data Raw plugin config.118 * @return array119 */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 156 112 157 113 /** … … 216 172 */ 217 173 function 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' ) ); 219 176 220 177 if ( empty( $settings ) ) { … … 245 202 } 246 203 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 } 251 212 252 213 natuteto_set_plugin_config( $new_config ); … … 360 321 'auto_scroll', 361 322 'pronunciation', 323 'user_can_download_audio', 324 'audio_schema_markup', 362 325 'disable_word_highlight', 363 326 'disable_sentence_highlight', … … 368 331 'analytics_button', 369 332 'analytics_insight', 333 'analytics_player_visibility', 334 'analytics_total_listening_time', 335 'analytics_completions', 336 'analytics_completion_rate', 370 337 'analytics_api', 371 338 … … 377 344 'analytics_button', 378 345 'analytics_insight', 346 'analytics_player_visibility', 347 'analytics_total_listening_time', 348 'analytics_completions', 349 'analytics_completion_rate', 379 350 'analytics_api', 380 351 ); -
natural-text-to-speech/trunk/components/implement-plugin.php
r3423673 r3481657 153 153 154 154 /** 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 */ 161 function 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 */ 205 function 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 } 262 add_action( 'wp_head', 'natuteto_output_audio_schema_markup' ); 263 264 /** 155 265 * Normalize a "dirty" JSON string so it uses straight quotes and has no trailing commas. 156 266 * -
natural-text-to-speech/trunk/components/rest/podcast/bootstrap.php
r3480845 r3481657 42 42 */ 43 43 function 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() ); 51 45 } 52 46 -
natural-text-to-speech/trunk/components/rest/route.php
r3480845 r3481657 53 53 ); 54 54 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 55 75 // For pronunciation?. 56 76 register_rest_route( … … 135 155 '/analytics', 136 156 array( 137 'methods' => 'GET ',157 'methods' => 'GET, POST', 138 158 'callback' => 'natuteto_analytics_counter', 139 159 'permission_callback' => '__return_true', -
natural-text-to-speech/trunk/components/rest/tts-init.php
r3471437 r3481657 23 23 'credentials_valid', 24 24 'custom_abbreviation_code_example', 25 'auto_add_for_post_types', 25 26 'storage', 26 27 'storage_config', 27 28 'storage_cache_expiry_months', 29 'audio_schema_markup', 28 30 'analytics_data_retention', 31 'analytics_insight', 32 'analytics_player_visibility', 33 'analytics_api', 29 34 'exclude_from_post_categories', 35 'podcasts', 30 36 ); 31 37 -
natural-text-to-speech/trunk/components/rest/tts-providers/reinventwp.php
r3405531 r3481657 23 23 function natuteto_generate_audio_reinventwp( $text, $credentials, $audio_config = array() ) { 24 24 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'; 26 26 $user_email = get_option( NATUTETO_WEB_EMAIL, 'example@gmail.com' ); 27 27 -
natural-text-to-speech/trunk/components/rest/tts.php
r3471437 r3481657 16 16 if ( ! defined( 'ABSPATH' ) ) { 17 17 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 */ 26 function 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; 18 61 } 19 62 … … 248 291 ) 249 292 ); 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 */ 301 function 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 */ 349 function 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 */ 427 function 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 */ 437 function 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 */ 462 function 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 */ 586 function 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 */ 614 function 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 */ 637 function 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 */ 666 function 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 */ 681 function 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; 250 711 } 251 712 -
natural-text-to-speech/trunk/components/rest/utils.php
r3480845 r3481657 8 8 if ( ! defined( 'ABSPATH' ) ) { 9 9 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 */ 20 function 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 ); 10 28 } 11 29 … … 33 51 34 52 // External API base. 35 $target_base = NATUTETO_REINVENT_BACKEND. '/api/reinvent/transcribe';53 $target_base = untrailingslashit( natuteto_get_reinvent_backend() ) . '/api/reinvent/transcribe'; 36 54 37 55 // Forward all query params. … … 1143 1161 function natuteto_get_quota_reinventwp() { 1144 1162 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'; 1146 1164 $user_email = get_option( NATUTETO_WEB_EMAIL, 'example@gmail.com' ); 1147 1165 -
natural-text-to-speech/trunk/components/tools/class-natuteto-analytics.php
r3416928 r3481657 21 21 22 22 /** 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 /** 23 54 * Normalize option type to always include plugin prefix. 24 55 * … … 61 92 62 93 // 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 ) ) { 65 95 return null; 66 96 } … … 123 153 124 154 /** 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 /** 125 269 * Retrieve analytics data for a given type, optionally filtering by keys and date range. 126 270 * -
natural-text-to-speech/trunk/constants.php
r3478726 r3481657 107 107 const NATUTETO_DEFAULT_CONFIG = array( 108 108 // 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(), 112 112 113 113 // 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, 119 121 120 122 // 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( 128 138 'browser' => array( 129 139 'lang' => 'en-US', … … 135 145 ), 136 146 137 'audio_config_multi_lang' => array(),147 'audio_config_multi_lang' => array(), 138 148 139 149 // 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', 145 155 146 156 // 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( 163 173 array( 164 174 'enabled' => false, -
natural-text-to-speech/trunk/natural-text-to-speech.php
r3481123 r3481657 4 4 * Plugin URI: https://wordpress.org/plugins/natural-text-to-speech 5 5 * 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. 66 * Version: 2.6.7 7 7 * Author: Reinvent WP 8 8 * Author URI: https://reinventwp.com … … 20 20 * serious issues with the options, so no if ( ! defined() ).}} 21 21 */ 22 define( 'NATUTETO_VERSION', '2.6. 6' );22 define( 'NATUTETO_VERSION', '2.6.7' ); 23 23 24 24 if ( ! defined( 'NATUTETO_PLUGIN_DIR' ) ) { … … 158 158 */ 159 159 function 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(); 163 162 164 163 foreach ( $feeds as $feed ) { -
natural-text-to-speech/trunk/readme.txt
r3481123 r3481657 4 4 Requires at least: 5.0 5 5 Tested up to: 6.9 6 Stable tag: 2.6. 67 Version: 2.6. 66 Stable tag: 2.6.7 7 Version: 2.6.7 8 8 Requires PHP: 7.4 9 9 License: GPLv2 or later … … 416 416 * 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. 417 417 * 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 418 421 * Any suggestion? [fill this form](https://reinventwp.com/feedback) 419 422 … … 481 484 == Changelog == 482 485 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 483 492 = 2.6.6 = 484 493 * Improve UI
Note: See TracChangeset
for help on using the changeset viewer.