diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php new file mode 100644 index 000000000000..f7208f728af1 --- /dev/null +++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php @@ -0,0 +1,332 @@ + '/wpcom/v2/posts-to-podcast', + 'jobsPath' => '/wpcom/v2/posts-to-podcast/jobs/', + 'voicePresets' => Jetpack_Posts_To_Podcast_Helper::get_voice_presets(), + 'lengthPresets' => Jetpack_Posts_To_Podcast_Helper::get_length_presets(), + 'windowPresets' => Jetpack_Posts_To_Podcast_Helper::get_window_presets(), + 'pollFastMs' => 3000, + 'pollSlowMs' => 10000, + 'pollSwitchMs' => 30000, + 'pollTimeoutMs' => 5 * 60 * 1000, + 'newPostBaseUrl' => admin_url( 'post-new.php' ), + 'editPostUrl' => admin_url( 'post.php' ), + 'i18n' => array( + 'generate' => __( 'Generate', 'jetpack' ), + 'generating' => __( 'Generating…', 'jetpack' ), + 'queueFailed' => __( 'Failed to queue the generation. Please try again.', 'jetpack' ), + 'pollFailed' => __( 'Failed to read job status. Please try again.', 'jetpack' ), + /* translators: %s: error message returned by the generation pipeline. */ + 'jobFailed' => __( 'Generation failed: %s', 'jetpack' ), + 'noContentNoPosts' => __( 'No posts published in this window — try a longer one.', 'jetpack' ), + 'noContentNoThreshold' => __( 'Posts in this window did not pass the relevance threshold. Try a longer window.', 'jetpack' ), + 'draftCreated' => __( 'Draft created. Opening the editor…', 'jetpack' ), + 'draftCreateFailed' => __( 'Generation completed but the draft post could not be created.', 'jetpack' ), + 'pendingStatus' => __( 'Pending — your job is queued.', 'jetpack' ), + 'runningStatus' => __( 'Running — generating your script. This usually takes 30–90 seconds.', 'jetpack' ), + ), + ); + + wp_add_inline_script( + 'wp-api-fetch', + 'window.jetpackPostsToPodcast = ' . wp_json_encode( $bootstrap, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', + 'after' + ); + + wp_add_inline_script( 'wp-api-fetch', $this->get_inline_app_script(), 'after' ); + } + + /** + * Render the form into the page body. wrap_ui() in the parent class supplies + * the Jetpack masthead/footer; we only render the page-specific markup here. + */ + public function page_render() { + $presets = Jetpack_Posts_To_Podcast_Helper::get_window_presets(); + $lengths = Jetpack_Posts_To_Podcast_Helper::get_length_presets(); + $voices = Jetpack_Posts_To_Podcast_Helper::get_voice_presets(); + ?> +
+

+

+ +

+
+

+
+ +

+

+
+ +

+

+
+ +

+

+ +

+
+
+
+ settings.pollTimeoutMs ) { + setStatus( '

' + settings.i18n.pollFailed + '

' ); + setBusy( false ); + return; + } + var nextDelay = elapsed < settings.pollSwitchMs ? settings.pollFastMs : settings.pollSlowMs; + apiFetch( { path: settings.jobsPath + encodeURIComponent( jobId ) } ) + .then( function ( record ) { + if ( record.status === 'pending' ) { + setStatus( '

' + settings.i18n.pendingStatus + '

' ); + setTimeout( function () { pollJob( jobId, startedAt ); }, nextDelay ); + return; + } + if ( record.status === 'running' ) { + setStatus( '

' + settings.i18n.runningStatus + '

' ); + setTimeout( function () { pollJob( jobId, startedAt ); }, nextDelay ); + return; + } + if ( record.status === 'failed' ) { + var msg = record.errorMessage || record.errorCode || 'unknown'; + setStatus( '

' + settings.i18n.jobFailed.replace( '%s', msg ) + '

' ); + setBusy( false ); + return; + } + if ( record.status === 'complete' ) { + if ( ! record.script ) { + var reason = record.metadata && record.metadata.noContentReason; + var message = reason === 'no-items-above-threshold' + ? settings.i18n.noContentNoThreshold + : settings.i18n.noContentNoPosts; + setStatus( '

' + message + '

' ); + setBusy( false ); + return; + } + createDraft( record ); + return; + } + setStatus( '

' + settings.i18n.pollFailed + '

' ); + setBusy( false ); + } ) + .catch( function () { + setStatus( '

' + settings.i18n.pollFailed + '

' ); + setBusy( false ); + } ); + } + + function buildPostMeta( record ) { + var m = record.metadata || {}; + return { + '_p2p_format': m.format || 'two-voice-dialogue', + '_p2p_voices': m.voices || [], + '_p2p_themes': m.themes || [], + '_p2p_source_post_ids': m.sourcePostIds || [], + '_p2p_window': m.window || record.window || {}, + '_p2p_generated_at': m.generatedAt || record.completedAt || '', + '_p2p_warnings': ( m.warnings && m.warnings.length ) ? m.warnings : undefined, + }; + } + + function createDraft( record ) { + var m = record.metadata || {}; + var body = { + status: 'draft', + title: m.title || 'Posts to Podcast — episode', + content: record.script, + meta: buildPostMeta( record ), + }; + apiFetch( { path: '/wp/v2/posts', method: 'POST', data: body } ) + .then( function ( draft ) { + setStatus( '

' + settings.i18n.draftCreated + '

' ); + var url = settings.editPostUrl + '?action=edit&post=' + encodeURIComponent( draft.id ); + window.location.href = url; + } ) + .catch( function () { + setStatus( '

' + settings.i18n.draftCreateFailed + '

' ); + setBusy( false ); + } ); + } + + function onGenerate() { + setBusy( true ); + setStatus( '' ); + var win = buildWindow(); + if ( ! win ) { + setStatus( '

' + settings.i18n.queueFailed + '

' ); + setBusy( false ); + return; + } + var body = { + window: win, + length: $( 'jp-p2p-length' ).value, + voicePreset: $( 'jp-p2p-voice' ).value, + }; + apiFetch( { path: settings.apiPath, method: 'POST', data: body } ) + .then( function ( resp ) { + if ( ! resp || ! resp.jobId ) { + setStatus( '

' + settings.i18n.queueFailed + '

' ); + setBusy( false ); + return; + } + setStatus( '

' + settings.i18n.pendingStatus + '

' ); + pollJob( resp.jobId, Date.now() ); + } ) + .catch( function () { + setStatus( '

' + settings.i18n.queueFailed + '

' ); + setBusy( false ); + } ); + } + + document.addEventListener( 'DOMContentLoaded', function () { + var btn = document.getElementById( 'jp-p2p-generate' ); + if ( btn ) { + btn.addEventListener( 'click', onGenerate ); + } + } ); +} )(); +JS; + // phpcs:enable + } +} diff --git a/projects/plugins/jetpack/_inc/lib/class-jetpack-posts-to-podcast-helper.php b/projects/plugins/jetpack/_inc/lib/class-jetpack-posts-to-podcast-helper.php new file mode 100644 index 000000000000..37bb68e73d28 --- /dev/null +++ b/projects/plugins/jetpack/_inc/lib/class-jetpack-posts-to-podcast-helper.php @@ -0,0 +1,144 @@ + + */ + public static function get_voice_presets() { + return array( + array( + 'id' => 'witty', + 'label' => __( 'Witty', 'jetpack' ), + ), + array( + 'id' => 'earnest', + 'label' => __( 'Earnest', 'jetpack' ), + ), + array( + 'id' => 'professional', + 'label' => __( 'Professional', 'jetpack' ), + ), + ); + } + + /** + * Length presets surfaced to the admin UI. + * + * @return array + */ + public static function get_length_presets() { + return array( + array( + 'id' => 'short', + 'label' => __( 'Short (~3 min)', 'jetpack' ), + ), + array( + 'id' => 'medium', + 'label' => __( 'Medium (~7 min)', 'jetpack' ), + ), + array( + 'id' => 'long', + 'label' => __( 'Long (~12 min)', 'jetpack' ), + ), + ); + } + + /** + * Window quick-picks surfaced to the admin UI. The relative-form `unit/n` shape + * matches the wpcom endpoint contract; the absolute `from/to` form is also accepted + * by the endpoint but is built from the date inputs in the UI. + * + * @return array + */ + public static function get_window_presets() { + return array( + array( + 'id' => 'last-7-days', + 'label' => __( 'Last 7 days', 'jetpack' ), + 'unit' => 'days', + 'n' => 7, + ), + array( + 'id' => 'last-14-days', + 'label' => __( 'Last 14 days', 'jetpack' ), + 'unit' => 'days', + 'n' => 14, + ), + array( + 'id' => 'last-30-days', + 'label' => __( 'Last 30 days', 'jetpack' ), + 'unit' => 'days', + 'n' => 30, + ), + array( + 'id' => 'last-3-months', + 'label' => __( 'Last 3 months', 'jetpack' ), + 'unit' => 'months', + 'n' => 3, + ), + ); + } +} diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-posts-to-podcast.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-posts-to-podcast.php new file mode 100644 index 000000000000..f696878389ad --- /dev/null +++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-posts-to-podcast.php @@ -0,0 +1,208 @@ +is_wpcom = true; + $this->wpcom_is_wpcom_only_endpoint = false; + + if ( ! Jetpack_Posts_To_Podcast_Helper::is_enabled() ) { + return; + } + + add_action( 'rest_api_init', array( $this, 'register_routes' ) ); + } + + /** + * Register routes. + */ + public function register_routes() { + register_rest_route( + $this->namespace, + $this->rest_base, + array( + array( + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => array( $this, 'enqueue_generation' ), + 'permission_callback' => array( 'Jetpack_Posts_To_Podcast_Helper', 'get_status_permission_check' ), + 'args' => $this->get_enqueue_args(), + ), + ) + ); + + register_rest_route( + $this->namespace, + $this->rest_base . '/jobs/(?P[a-f0-9\-]+)', + array( + array( + 'methods' => WP_REST_Server::READABLE, + 'callback' => array( $this, 'read_job_status' ), + 'permission_callback' => array( 'Jetpack_Posts_To_Podcast_Helper', 'get_status_permission_check' ), + 'args' => array( + 'job_id' => array( + 'type' => 'string', + 'required' => true, + ), + ), + ), + ) + ); + } + + /** + * Argument schema for the POST route. + * + * @return array> + */ + private function get_enqueue_args() { + return array( + 'window' => array( + 'type' => 'object', + 'required' => true, + 'description' => __( 'Either { unit: days|weeks|months, n: } or { from, to } as ISO-8601 dates.', 'jetpack' ), + ), + 'length' => array( + 'type' => 'string', + 'required' => true, + 'enum' => wp_list_pluck( Jetpack_Posts_To_Podcast_Helper::get_length_presets(), 'id' ), + 'description' => __( 'Length preset id.', 'jetpack' ), + ), + 'voicePreset' => array( + 'type' => 'string', + 'required' => true, + 'enum' => wp_list_pluck( Jetpack_Posts_To_Podcast_Helper::get_voice_presets(), 'id' ), + 'description' => __( 'Voice preset id.', 'jetpack' ), + ), + ); + } + + /** + * Forward POST to the wpcom-side endpoint and return the queued job descriptor. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_REST_Response|WP_Error + */ + public function enqueue_generation( WP_REST_Request $request ) { + $blog_id = (int) Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack' ), array( 'status' => 400 ) ); + } + + $body = array( + 'window' => $request->get_param( 'window' ), + 'length' => $request->get_param( 'length' ), + 'voicePreset' => $request->get_param( 'voicePreset' ), + ); + + $response = Client::wpcom_json_api_request_as_blog( + sprintf( '/sites/%d/posts-to-podcast', $blog_id ), + 2, + array( + 'method' => 'POST', + 'headers' => array( 'content-type' => 'application/json' ), + 'timeout' => 30, + ), + wp_json_encode( $body, JSON_UNESCAPED_SLASHES ), + 'wpcom' + ); + + return $this->relay_response( $response ); + } + + /** + * Forward GET to the wpcom-side polling endpoint and return the job record. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_REST_Response|WP_Error + */ + public function read_job_status( WP_REST_Request $request ) { + $blog_id = (int) Jetpack_Options::get_option( 'id' ); + if ( ! $blog_id ) { + return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack' ), array( 'status' => 400 ) ); + } + + $job_id = (string) $request['job_id']; + + $response = Client::wpcom_json_api_request_as_blog( + sprintf( '/sites/%d/posts-to-podcast/jobs/%s', $blog_id, rawurlencode( $job_id ) ), + 2, + array( + 'method' => 'GET', + 'headers' => array( 'content-type' => 'application/json' ), + 'timeout' => 15, + ), + null, + 'wpcom' + ); + + return $this->relay_response( $response ); + } + + /** + * Relay an upstream Connection\Client response back to the local REST client. + * Preserves the upstream HTTP status code so 202/4xx/5xx mappings flow through. + * + * @param array|WP_Error $response The raw response from Client::wpcom_json_api_request_as_blog. + * + * @return WP_REST_Response|WP_Error + */ + private function relay_response( $response ) { + if ( is_wp_error( $response ) ) { + return $response; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + $decoded = json_decode( $body, true ); + + $rest_response = rest_ensure_response( null === $decoded ? $body : $decoded ); + if ( $code >= 100 && $code < 600 ) { + $rest_response->set_status( $code ); + } + return $rest_response; + } +} + +wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Posts_To_Podcast' ); diff --git a/projects/plugins/jetpack/changelog/add-posts-to-podcast b/projects/plugins/jetpack/changelog/add-posts-to-podcast new file mode 100644 index 000000000000..40e4a9a56648 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-posts-to-podcast @@ -0,0 +1,4 @@ +Significance: minor +Type: other + +Added a post to podcast base feature diff --git a/projects/plugins/jetpack/class.jetpack-admin.php b/projects/plugins/jetpack/class.jetpack-admin.php index 3c2f8bb3224d..c93193d0051f 100644 --- a/projects/plugins/jetpack/class.jetpack-admin.php +++ b/projects/plugins/jetpack/class.jetpack-admin.php @@ -66,6 +66,9 @@ private function __construct() { require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php'; $jetpack_ai = new Jetpack_AI_Page(); + require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php'; + $jetpack_posts_to_podcast = new Jetpack_Posts_To_Podcast_Page(); + add_action( 'admin_init', array( $jetpack_react, 'react_redirects' ), 0 ); add_action( 'admin_menu', array( $jetpack_react, 'add_actions' ), 998 ); add_action( 'admin_menu', array( $jetpack_react, 'remove_jetpack_menu' ), 2000 ); @@ -74,6 +77,7 @@ private function __construct() { add_action( 'jetpack_admin_menu', array( $fallback_page, 'add_actions' ) ); add_action( 'jetpack_admin_menu', array( $jetpack_about, 'add_actions' ) ); add_action( 'jetpack_admin_menu', array( $jetpack_ai, 'add_actions' ) ); + add_action( 'jetpack_admin_menu', array( $jetpack_posts_to_podcast, 'add_actions' ) ); // Add redirect to current page for activation/deactivation of modules. add_action( 'jetpack_pre_activate_module', array( $this, 'fix_redirect' ), 10, 2 );