From 85328d40033c04a7133cc683db5feb92df750818 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Mon, 4 May 2026 13:07:02 +0200 Subject: [PATCH 1/2] Add Jetpack Posts to Podcast admin page and proxy endpoint (Phase A) Wires up the Jetpack-side surface for the Posts to Podcast feature: - New "Posts to Podcast" submenu under the Jetpack admin menu, gated by Jetpack_Posts_To_Podcast_Helper::is_enabled() (delegates to Jetpack_AI_Helper for now; real Jetpack AI entitlement + credit checks land in Phase C). - Vanilla form with three controls (window / length / voice preset) and a Generate button. Inline JS uses wp.apiFetch to POST to the local proxy, polls the job (3s for the first 30s, then 10s) until terminal state, and writes the resulting markdown script as a draft post via /wp/v2/posts. No build step needed for Phase A; Phase B can move this to a built React app under _inc/client/. - Local wpcom/v2 proxy endpoint at posts-to-podcast (and /jobs/{id}) that forwards via Connection\Client to the wpcom-side endpoint at public-api.wordpress.com, preserving the upstream HTTP status so async-job 202/4xx/5xx mappings flow through unchanged. The local proxy + admin page are registered in class.jetpack-admin.php alongside Jetpack_AI_Page. Three voice presets and three length presets are defined in the helper as a single source of truth shared by the form and the proxy validator. Co-Authored-By: Claude Opus 4.7 --- .../class-jetpack-posts-to-podcast-page.php | 332 ++++++++++++++++++ .../class-jetpack-posts-to-podcast-helper.php | 144 ++++++++ ...-rest-api-v2-endpoint-posts-to-podcast.php | 208 +++++++++++ .../plugins/jetpack/class.jetpack-admin.php | 4 + 4 files changed, 688 insertions(+) create mode 100644 projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php create mode 100644 projects/plugins/jetpack/_inc/lib/class-jetpack-posts-to-podcast-helper.php create mode 100644 projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-posts-to-podcast.php 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/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 ); From 6f5fdedb835984dcbf7d6c3be1a8cf6af706c5f4 Mon Sep 17 00:00:00 2001 From: Richard Ortiz Date: Tue, 5 May 2026 11:45:30 +0200 Subject: [PATCH 2/2] changelog --- projects/plugins/jetpack/changelog/add-posts-to-podcast | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 projects/plugins/jetpack/changelog/add-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