initial
This commit is contained in:
+1510
File diff suppressed because it is too large
Load Diff
+91
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared preset arrays for the Create AI Podcast page.
|
||||
*
|
||||
* Values mirror what `dashboard/create/presets.ts` exported before the page
|
||||
* moved to this stack. PHP renders the form's <select> options from these
|
||||
* arrays; the JS island receives the same arrays via wp_localize_script so
|
||||
* status notices can label runs by preset id.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast;
|
||||
|
||||
/**
|
||||
* Date-range presets for the "from a window" source mode.
|
||||
*
|
||||
* @return array<int, array{id:string,label:string,unit:string,n:int}>
|
||||
*/
|
||||
function window_presets(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'last-7-days',
|
||||
'label' => __( 'Last 7 days', 'jetpack-podcast' ),
|
||||
'unit' => 'days',
|
||||
'n' => 7,
|
||||
),
|
||||
array(
|
||||
'id' => 'last-14-days',
|
||||
'label' => __( 'Last 14 days', 'jetpack-podcast' ),
|
||||
'unit' => 'days',
|
||||
'n' => 14,
|
||||
),
|
||||
array(
|
||||
'id' => 'last-30-days',
|
||||
'label' => __( 'Last 30 days', 'jetpack-podcast' ),
|
||||
'unit' => 'days',
|
||||
'n' => 30,
|
||||
),
|
||||
array(
|
||||
'id' => 'last-3-months',
|
||||
'label' => __( 'Last 3 months', 'jetpack-podcast' ),
|
||||
'unit' => 'months',
|
||||
'n' => 3,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode-length presets.
|
||||
*
|
||||
* @return array<int, array{id:string,label:string}>
|
||||
*/
|
||||
function length_presets(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'short',
|
||||
'label' => __( 'Short (~3 min)', 'jetpack-podcast' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'medium',
|
||||
'label' => __( 'Medium (~7 min)', 'jetpack-podcast' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'long',
|
||||
'label' => __( 'Long (~12 min)', 'jetpack-podcast' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice-style presets.
|
||||
*
|
||||
* @return array<int, array{id:string,label:string}>
|
||||
*/
|
||||
function voice_presets(): array {
|
||||
return array(
|
||||
array(
|
||||
'id' => 'witty',
|
||||
'label' => __( 'Witty', 'jetpack-podcast' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'earnest',
|
||||
'label' => __( 'Earnest', 'jetpack-podcast' ),
|
||||
),
|
||||
array(
|
||||
'id' => 'professional',
|
||||
'label' => __( 'Professional', 'jetpack-podcast' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
+896
@@ -0,0 +1,896 @@
|
||||
/*!
|
||||
* Create AI Podcast — page styles.
|
||||
*
|
||||
* Card-based layout aligned with wp-admin's color palette. Scoped under
|
||||
* .jetpack-create-ai-podcast. Inherits wp-admin form styles for native
|
||||
* controls (select, button, etc.); custom rules add the card surfaces,
|
||||
* section headers, credits readout, posts checklist, status notice, and
|
||||
* disabled-form state.
|
||||
*/
|
||||
|
||||
.jetpack-create-ai-podcast {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__page-title {
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
/* --- Intro banner -------------------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__intro {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 28px;
|
||||
margin: 0 0 20px;
|
||||
border-radius: 14px;
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.2), transparent 55%),
|
||||
linear-gradient(135deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
|
||||
box-shadow:
|
||||
0 10px 24px rgba(34, 113, 177, 0.25),
|
||||
0 2px 4px rgba(15, 23, 42, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: -40px;
|
||||
bottom: -60px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-eyebrow--experimental {
|
||||
background: rgba(255, 196, 80, 0.28);
|
||||
color: #ffe6a8;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-wpmark {
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: currentColor;
|
||||
mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgY2xhc3M9ImdyaWRpY29uIGdyaWRpY29ucy1teS1zaXRlcyIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTIgMkM2LjQ3NyAyIDIgNi40NzcgMiAxMnM0LjQ3NyAxMCAxMCAxMCAxMC00LjQ3NyAxMC0xMFMxNy41MjMgMiAxMiAyTTMuNSAxMmMwLTEuMjMyLjI2NC0yLjQwMi43MzYtMy40NTlMOC4yOTEgMTkuNjVBOC41IDguNSAwIDAgMSAzLjUgMTJtOC41IDguNTAxYy0uODM0IDAtMS42NC0uMTIyLTIuNDAxLS4zNDZsMi41NTEtNy40MTEgMi42MTMgNy4xNThhMSAxIDAgMCAwIC4wNjEuMTE3IDguNSA4LjUgMCAwIDEtMi44MjQuNDgybTEuMTcyLTEyLjQ4NmMuNTEyLS4wMjcuOTczLS4wODEuOTczLS4wODEuNDU4LS4wNTQuNDA0LS43MjctLjA1NC0uNzAxIDAgMC0xLjM3Ny4xMDgtMi4yNjYuMTA4LS44MzUgMC0yLjIzOS0uMTA4LTIuMjM5LS4xMDgtLjQ1OS0uMDI2LS41MTIuNjc0LS4wNTQuNzAxIDAgMCAuNDM0LjA1NC44OTIuMDgxbDEuMzI0IDMuNjI5LTEuODYgNS41NzktMy4wOTYtOS4yMDhjLjUxMi0uMDI3Ljk3My0uMDgxLjk3My0uMDgxLjQ1OC0uMDU0LjQwMy0uNzI3LS4wNTUtLjcwMSAwIDAtMS4zNzYuMTA4LTIuMjY1LjEwOC0uMTYgMC0uMzQ3LS4wMDQtLjU0Ny0uMDFBOC40OSA4LjQ5IDAgMCAxIDEyIDMuNWMyLjIxMyAwIDQuMjI4Ljg0NiA1Ljc0IDIuMjMyLS4wMzctLjAwMi0uMDcyLS4wMDctLjExLS4wMDctLjgzNSAwLTEuNDI3LjcyNy0xLjQyNyAxLjUwOSAwIC43MDEuNDA0IDEuMjkzLjgzNSAxLjk5NC4zMjMuNTY2LjcwMSAxLjI5My43MDEgMi4zNDQgMCAuNzI3LS4yOCAxLjU3Mi0uNjQ3IDIuNzQ4bC0uODQ4IDIuODMzem0zLjEwMSAxMS4zMzIgMi41OTYtNy41MDZjLjQ4NS0xLjIxMy42NDYtMi4xODIuNjQ2LTMuMDQ1cS0uMDAyLS40NjktLjA1Ny0uODc0QTguNDYgOC40NiAwIDAgMSAyMC41IDEyYTguNDkgOC40OSAwIDAgMS00LjIyNyA3LjM0NyIvPjwvc3ZnPg==);
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 22px;
|
||||
line-height: 1.25;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-art {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* --- Cards ---------------------------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__card {
|
||||
background: #fff;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 8px;
|
||||
padding: 20px 24px;
|
||||
margin: 0 0 16px;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__card--accent {
|
||||
border-left: 4px solid #2271b1;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__card-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
/* --- Credits readout ------------------------------------------------------ */
|
||||
|
||||
.jetpack-create-ai-podcast__credits {
|
||||
font-size: 14px;
|
||||
color: #1d2327;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits[data-state="hidden"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits[data-state="not-available"] {
|
||||
border-left: 4px solid #d63638;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits[data-state="not-available"] p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-count {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #f0f0f1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
background: linear-gradient(90deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
|
||||
border-radius: 999px;
|
||||
transition: width 320ms cubic-bezier(0.2, 0.8, 0.2, 1), background 200ms ease;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-fill[data-tone="warning"] {
|
||||
background: linear-gradient(90deg, #dba617, #a47800);
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-fill[data-tone="danger"] {
|
||||
background: linear-gradient(90deg, #d63638, #b32d2e);
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px 8px;
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-meta-sep {
|
||||
color: #c3c4c7;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-meta-reset {
|
||||
font-weight: 500;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
background: #fff;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-banner[data-state="low"] {
|
||||
border-color: rgba(219, 166, 23, 0.32);
|
||||
background: rgba(219, 166, 23, 0.08);
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-banner[data-state="out"] {
|
||||
border-color: rgba(214, 54, 56, 0.32);
|
||||
background: rgba(214, 54, 56, 0.08);
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-banner-body {
|
||||
flex: 1 1 240px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-banner-title {
|
||||
margin: 0 0 2px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-banner-message {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__credits-banner-cta {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.jetpack-create-ai-podcast__credits-fill {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Form fields ---------------------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__field {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__field label {
|
||||
display: block;
|
||||
margin: 0 0 6px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1d2327;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__field select,
|
||||
.jetpack-create-ai-podcast__field input[type="search"],
|
||||
.jetpack-create-ai-podcast__field textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --- Radio group ---------------------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__radio {
|
||||
flex: 1 1 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 6px;
|
||||
background: #f6f7f7;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #1d2327;
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__radio:hover {
|
||||
border-color: #2271b1;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__radio:has(input:checked) {
|
||||
border-color: #2271b1;
|
||||
background: #fff;
|
||||
box-shadow: inset 0 0 0 1px #2271b1;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__radio input[type="radio"] {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__field-hint {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
}
|
||||
|
||||
/* --- Posts picker --------------------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__posts {
|
||||
margin-top: 8px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__posts ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__posts li {
|
||||
margin: 0;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f1;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__posts li:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__posts label {
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
font-size: 14px;
|
||||
color: #1d2327;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__posts .date {
|
||||
color: #646970;
|
||||
font-size: 13px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__posts-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
padding: 12px 0;
|
||||
color: #646970;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__posts-loading::before {
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid #dcdcde;
|
||||
border-top-color: #2271b1;
|
||||
border-radius: 50%;
|
||||
animation: jetpack-create-ai-podcast-spin 800ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes jetpack-create-ai-podcast-spin {
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.jetpack-create-ai-podcast__posts-loading::before,
|
||||
.jetpack-create-ai-podcast__status-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Actions row ---------------------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__advanced {
|
||||
margin-top: 20px;
|
||||
padding: 16px 18px;
|
||||
border: 1px dashed #dcdcde;
|
||||
border-radius: 10px;
|
||||
background: #fafafb;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__advanced-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__advanced-title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__soon-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__advanced .jetpack-create-ai-podcast__field label {
|
||||
color: #646970;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__advanced :is(select, textarea, input):disabled {
|
||||
background-color: #fff;
|
||||
color: #8c8f94;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__actions {
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f1;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
/* --- Disabled form state -------------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__form[data-disabled="true"] {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* --- Status notice (floating toast) -------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__status {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
right: 24px;
|
||||
z-index: 100000;
|
||||
max-width: calc(100vw - 48px);
|
||||
width: 360px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status[data-state="idle"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px 14px 20px;
|
||||
box-shadow:
|
||||
0 12px 32px rgba(15, 23, 42, 0.12),
|
||||
0 2px 6px rgba(15, 23, 42, 0.06);
|
||||
pointer-events: auto;
|
||||
animation: jetpack-create-ai-podcast-toast-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
background: #2271b1;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-card[data-tone="success"]::before {
|
||||
background: #00a32a;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-card[data-tone="error"]::before {
|
||||
background: #d63638;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-spinner {
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
border: 2px solid rgba(34, 113, 177, 0.25);
|
||||
border-top-color: #2271b1;
|
||||
border-radius: 50%;
|
||||
animation: jetpack-create-ai-podcast-spin 800ms linear infinite;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #1d2327;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-subtext {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-link {
|
||||
text-decoration: underline;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-dismiss {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: -4px -4px 0 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #50575e;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-dismiss:hover,
|
||||
.jetpack-create-ai-podcast__status-dismiss:focus-visible {
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
color: #1d2327;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status-dismiss:focus-visible {
|
||||
outline: 2px solid #2271b1;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@keyframes jetpack-create-ai-podcast-toast-in {
|
||||
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.jetpack-create-ai-podcast__status-card {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Generated episodes list --------------------------------------------- */
|
||||
|
||||
.jetpack-create-ai-podcast__episodes-empty {
|
||||
margin: 0;
|
||||
color: #50575e;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episodes-items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1d2327;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode-status {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
background: #f0f0f1;
|
||||
color: #50575e;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode-status[data-status="publish"] {
|
||||
background: rgba(0, 163, 42, 0.12);
|
||||
color: #0a6b1c;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode-player {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__episode-edit {
|
||||
text-decoration: underline;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
|
||||
.jetpack-create-ai-podcast__pagination {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #f0f0f1;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-summary {
|
||||
font-size: 12px;
|
||||
color: #646970;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-controls {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-button {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #dcdcde;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #1d2327;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-button:hover:not(:disabled) {
|
||||
border-color: #2271b1;
|
||||
color: #2271b1;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-button:focus-visible {
|
||||
outline: 2px solid #2271b1;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-button:disabled {
|
||||
color: #a7aaad;
|
||||
background: #f6f7f7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-button[aria-current="page"] {
|
||||
background: #1d2327;
|
||||
border-color: #1d2327;
|
||||
color: #fff;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-button--prev,
|
||||
.jetpack-create-ai-podcast__pagination-button--next {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__pagination-ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
color: #8c8f94;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
|
||||
.jetpack-create-ai-podcast__episodes-items--skeleton .jetpack-create-ai-podcast__episode {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__skeleton {
|
||||
display: block;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #eceef0 0%, #f6f7f7 50%, #eceef0 100%);
|
||||
background-size: 200% 100%;
|
||||
animation: jetpack-create-ai-podcast-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__skeleton--title {
|
||||
height: 14px;
|
||||
width: 60%;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__skeleton--pill {
|
||||
height: 18px;
|
||||
width: 64px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__skeleton--player {
|
||||
height: 42px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__skeleton--link {
|
||||
height: 12px;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
@keyframes jetpack-create-ai-podcast-shimmer {
|
||||
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
.jetpack-create-ai-podcast__skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Narrow viewport tweaks ---------------------------------------------- */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
|
||||
.jetpack-create-ai-podcast {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__radio {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__actions .button-hero {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__status {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.jetpack-create-ai-podcast__intro-art {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"$schema": "https://schemas.wp.org/trunk/block.json",
|
||||
"apiVersion": 3,
|
||||
"name": "jetpack/podcast-episode",
|
||||
"title": "Podcast Episode",
|
||||
"description": "Embed a single podcast episode from an audio or video file, with Podcasting 2.0 metadata.",
|
||||
"keywords": [ "audio", "podcast", "episode" ],
|
||||
"version": "1.0.0",
|
||||
"textdomain": "jetpack-podcast",
|
||||
"category": "embed",
|
||||
"icon": "<svg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'><path d='M12 2a5 5 0 0 0-5 5v5a5 5 0 0 0 10 0V7a5 5 0 0 0-5-5zm0 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V7a3 3 0 0 1 3-3zm-7 8a1 1 0 0 1 1 1 6 6 0 0 0 12 0 1 1 0 1 1 2 0 8 8 0 0 1-7 7.93V22h3v2H8v-2h3v-1.07A8 8 0 0 1 4 13a1 1 0 0 1 1-1z'/></svg>",
|
||||
"usesContext": [ "postId", "postType" ],
|
||||
"supports": {
|
||||
"spacing": {
|
||||
"padding": true,
|
||||
"margin": true
|
||||
},
|
||||
"anchor": true,
|
||||
"customClassName": true,
|
||||
"className": true,
|
||||
"html": false,
|
||||
"multiple": true,
|
||||
"reusable": true
|
||||
},
|
||||
"attributes": {
|
||||
"mediaId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"mediaUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"mediaType": {
|
||||
"type": "string",
|
||||
"enum": [ "audio", "video" ]
|
||||
},
|
||||
"mediaMimeType": {
|
||||
"type": "string"
|
||||
},
|
||||
"episodeNumber": {
|
||||
"type": "integer"
|
||||
},
|
||||
"seasonNumber": {
|
||||
"type": "integer"
|
||||
},
|
||||
"episodeType": {
|
||||
"type": "string",
|
||||
"enum": [ "full", "trailer", "bonus" ],
|
||||
"default": "full"
|
||||
},
|
||||
"explicit": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"transcriptUrl": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"transcriptType": {
|
||||
"type": "string",
|
||||
"enum": [ "text/vtt", "text/html", "application/srt", "application/json" ],
|
||||
"default": "text/vtt"
|
||||
},
|
||||
"chaptersUrl": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"chaptersType": {
|
||||
"type": "string",
|
||||
"default": "application/json+chapters"
|
||||
},
|
||||
"locationName": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"license": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"licenseUrl": {
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"people": {
|
||||
"type": "array",
|
||||
"default": []
|
||||
},
|
||||
"showPoster": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"coverArt": {
|
||||
"type": "object",
|
||||
"default": {}
|
||||
},
|
||||
"soundbites": {
|
||||
"type": "array",
|
||||
"default": []
|
||||
},
|
||||
"alternateEnclosures": {
|
||||
"type": "array",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"attributes": {
|
||||
"episodeNumber": 1,
|
||||
"seasonNumber": 1,
|
||||
"episodeType": "full",
|
||||
"duration": "11:25"
|
||||
}
|
||||
}
|
||||
}
|
||||
+760
@@ -0,0 +1,760 @@
|
||||
<?php
|
||||
/**
|
||||
* Podcast Episode block.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Assets;
|
||||
use Automattic\Jetpack\Blocks;
|
||||
use Automattic\Jetpack\Status\Request;
|
||||
|
||||
/**
|
||||
* Registers and renders the Podcast Episode block.
|
||||
*
|
||||
* The caller (Podcast::init()) is responsible for the host gate.
|
||||
*/
|
||||
class Podcast_Episode_Block {
|
||||
|
||||
/**
|
||||
* Editor script handle.
|
||||
*/
|
||||
const EDITOR_HANDLE = 'jetpack-podcast-episode-editor';
|
||||
|
||||
/**
|
||||
* Front-end + editor shared style handle. Side-loaded by
|
||||
* `Assets::register_script` from the sibling `style.css` bundle.
|
||||
*/
|
||||
const STYLE_HANDLE = 'jetpack-podcast-episode-style';
|
||||
|
||||
/**
|
||||
* Front-end view script handle. Enqueued from the render callback
|
||||
* because the block.json `viewScript` field can't resolve to the
|
||||
* package's dist directory from the deployed src location.
|
||||
*/
|
||||
const VIEW_HANDLE = 'jetpack-podcast-episode-view';
|
||||
|
||||
/**
|
||||
* Wire the block's actions.
|
||||
*/
|
||||
public static function register_hooks() {
|
||||
add_action( 'init', array( __CLASS__, 'register_block' ), 9 );
|
||||
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'load_editor_scripts' ), 9 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the block.
|
||||
*
|
||||
* Also registers the front-end style bundle (built separately from the
|
||||
* editor bundle so it actually ships on the public post page) and hands
|
||||
* the handle to `register_block_type` via the `style` arg, which auto-
|
||||
* enqueues it whenever the block is rendered.
|
||||
*/
|
||||
public static function register_block() {
|
||||
// Assets::register_script side-loads the sibling style.css and
|
||||
// registers a style handle under the same name. The accompanying
|
||||
// (essentially empty) style.js handle is registered too but never
|
||||
// enqueued — only the style is passed to register_block_type below.
|
||||
Assets::register_script(
|
||||
self::STYLE_HANDLE,
|
||||
'../../../dist/blocks/podcast-episode/style.js',
|
||||
__FILE__,
|
||||
array(
|
||||
'css_path' => '../../../dist/blocks/podcast-episode/style.css',
|
||||
)
|
||||
);
|
||||
|
||||
Blocks::jetpack_register_block(
|
||||
__DIR__,
|
||||
array(
|
||||
'render_callback' => array( __CLASS__, 'render_block' ),
|
||||
'style' => self::STYLE_HANDLE,
|
||||
'render_email_callback' => array( __CLASS__, 'render_email' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register and enqueue the front-end view script that wires chapter and
|
||||
* soundbite buttons to the audio player. Called from the render callback
|
||||
* so the script only ships on pages that actually contain the block.
|
||||
*
|
||||
* `Assets::register_script` dedups internally, so calling this for each
|
||||
* rendered instance of the block is safe.
|
||||
*/
|
||||
private static function enqueue_view_script() {
|
||||
Assets::register_script(
|
||||
self::VIEW_HANDLE,
|
||||
'../../../dist/blocks/podcast-episode/view.js',
|
||||
__FILE__,
|
||||
array(
|
||||
'in_footer' => true,
|
||||
'enqueue' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the bundled editor script + style from the package's dist/.
|
||||
*/
|
||||
public static function load_editor_scripts() {
|
||||
Assets::register_script(
|
||||
self::EDITOR_HANDLE,
|
||||
'../../../dist/blocks/podcast-episode/editor.js',
|
||||
__FILE__,
|
||||
array(
|
||||
'in_footer' => true,
|
||||
'enqueue' => true,
|
||||
'textdomain' => 'jetpack-podcast',
|
||||
)
|
||||
);
|
||||
|
||||
// Add the script_loader_src rewrite only while the editor script is
|
||||
// in flight, so the filter doesn't run on every front-end script load.
|
||||
add_filter( 'script_loader_src', array( __CLASS__, 'filter_editor_script_src' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the editor script src to match the admin scheme.
|
||||
*
|
||||
* On WPCOM sites with a custom domain mapping that lacks SSL, `home_url()`
|
||||
* (and therefore `plugins_url()`) returns `http://mapped-domain.test` even
|
||||
* though wp-admin is served from `.wordpress.com` over HTTPS. The script
|
||||
* URL then trips the browser's mixed-content block. Routing through the
|
||||
* canonical `script_loader_src` filter keeps the URL valid in both cases
|
||||
* without mutating `$wp_scripts->registered` directly.
|
||||
*
|
||||
* @param string $src Script source URL.
|
||||
* @param string $handle Script handle.
|
||||
* @return string
|
||||
*/
|
||||
public static function filter_editor_script_src( $src, $handle ) {
|
||||
if ( self::EDITOR_HANDLE !== $handle ) {
|
||||
return $src;
|
||||
}
|
||||
|
||||
$admin_scheme = wp_parse_url( admin_url(), PHP_URL_SCHEME );
|
||||
if ( ! $admin_scheme ) {
|
||||
return $src;
|
||||
}
|
||||
|
||||
return set_url_scheme( $src, $admin_scheme );
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a seconds value as a MM:SS or HH:MM:SS label. Negative input clamps to 0.
|
||||
*
|
||||
* @param float|int|string $seconds Seconds value (Podcasting 2.0 soundbite startTime/duration are floats).
|
||||
* @return string Formatted label (always non-empty; "0:00" for zero input).
|
||||
*/
|
||||
private static function format_seconds_label( $seconds ) {
|
||||
$total = (int) floor( max( 0, (float) $seconds ) );
|
||||
$hours = (int) floor( $total / 3600 );
|
||||
$minutes = (int) floor( ( $total % 3600 ) / 60 );
|
||||
$secs = $total % 60;
|
||||
|
||||
if ( $hours > 0 ) {
|
||||
return sprintf( '%d:%02d:%02d', $hours, $minutes, $secs );
|
||||
}
|
||||
return sprintf( '%d:%02d', $minutes, $secs );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the episode cover art URL: episode override → post featured
|
||||
* image → show-level `podcasting_image` option → empty string.
|
||||
*
|
||||
* @param array $attributes Block attributes.
|
||||
* @param \WP_Post $post Episode post.
|
||||
* @param string $size Featured-image size to request.
|
||||
* @return string
|
||||
*/
|
||||
private static function resolve_cover_art_url( array $attributes, $post, $size ) {
|
||||
if ( isset( $attributes['coverArt'] ) && is_array( $attributes['coverArt'] ) && ! empty( $attributes['coverArt']['url'] ) ) {
|
||||
return esc_url_raw( $attributes['coverArt']['url'] );
|
||||
}
|
||||
|
||||
$featured_id = (int) get_post_thumbnail_id( $post );
|
||||
if ( $featured_id ) {
|
||||
$featured_url = (string) wp_get_attachment_image_url( $featured_id, $size );
|
||||
if ( '' !== $featured_url ) {
|
||||
return $featured_url;
|
||||
}
|
||||
}
|
||||
|
||||
return (string) get_option( 'podcasting_image', '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Render callback.
|
||||
*
|
||||
* Pulls title, author, and date from the surrounding post — the post is
|
||||
* the episode. Cover art falls back to the show-level `podcasting_image`
|
||||
* option when the block has no episode-specific override.
|
||||
*
|
||||
* @param array $attributes Block attributes.
|
||||
* @param string $content Inner content (fallback direct-link markup from save.js).
|
||||
* @param \WP_Block $block The parsed block instance, used to read post context.
|
||||
* @return string
|
||||
*/
|
||||
public static function render_block( $attributes, $content, $block = null ) {
|
||||
// Outside the frontend, fall back to the saved direct link so RSS / email / REST export stays
|
||||
// simple and predictable.
|
||||
if ( ! Request::is_frontend() ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
if ( empty( $attributes['mediaUrl'] ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Resolve the post that backs this episode. Prefer block context (set by Query Loop / singular
|
||||
// templates / post-bound block contexts) and fall back to the global loop for direct theme
|
||||
// rendering. With no resolvable post, the block has nothing to display.
|
||||
$post_id = 0;
|
||||
if ( $block && isset( $block->context['postId'] ) ) {
|
||||
$post_id = (int) $block->context['postId'];
|
||||
}
|
||||
if ( ! $post_id ) {
|
||||
$post_id = (int) get_the_ID();
|
||||
}
|
||||
if ( ! $post_id ) {
|
||||
return '';
|
||||
}
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$media_url = esc_url_raw( $attributes['mediaUrl'] );
|
||||
if ( ! wp_http_validate_url( $media_url ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$media_type = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio';
|
||||
$mime_type = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : '';
|
||||
$episode_number = isset( $attributes['episodeNumber'] ) ? (int) $attributes['episodeNumber'] : 0;
|
||||
$season_number = isset( $attributes['seasonNumber'] ) ? (int) $attributes['seasonNumber'] : 0;
|
||||
$episode_type = isset( $attributes['episodeType'] ) ? (string) $attributes['episodeType'] : 'full';
|
||||
$is_explicit = ! empty( $attributes['explicit'] );
|
||||
$duration = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : '';
|
||||
$show_poster = ! isset( $attributes['showPoster'] ) || ! empty( $attributes['showPoster'] );
|
||||
$transcript_url = isset( $attributes['transcriptUrl'] ) ? esc_url_raw( $attributes['transcriptUrl'] ) : '';
|
||||
$location_name = isset( $attributes['locationName'] ) ? (string) $attributes['locationName'] : '';
|
||||
$license = isset( $attributes['license'] ) ? (string) $attributes['license'] : '';
|
||||
$license_url = isset( $attributes['licenseUrl'] ) ? esc_url_raw( $attributes['licenseUrl'] ) : '';
|
||||
$people = isset( $attributes['people'] ) && is_array( $attributes['people'] ) ? $attributes['people'] : array();
|
||||
|
||||
$soundbites = isset( $attributes['soundbites'] ) && is_array( $attributes['soundbites'] ) ? $attributes['soundbites'] : array();
|
||||
$alternate_enclosures = isset( $attributes['alternateEnclosures'] ) && is_array( $attributes['alternateEnclosures'] ) ? $attributes['alternateEnclosures'] : array();
|
||||
|
||||
// Only ship the click-to-seek script on episodes that actually have soundbites to wire.
|
||||
// Chapters are hosted as an external JSON file and consumed by Podcasting 2.0 players directly.
|
||||
if ( ! empty( $soundbites ) ) {
|
||||
self::enqueue_view_script();
|
||||
}
|
||||
|
||||
if ( '' !== $transcript_url && ! wp_http_validate_url( $transcript_url ) ) {
|
||||
$transcript_url = '';
|
||||
}
|
||||
|
||||
$author_id = (int) $post->post_author;
|
||||
$title = get_the_title( $post );
|
||||
$author_name = get_the_author_meta( 'display_name', $author_id );
|
||||
$author_url = esc_url_raw( (string) get_the_author_meta( 'url', $author_id ) );
|
||||
$publish_date_iso = get_the_date( 'c', $post );
|
||||
$publish_date = get_the_date( '', $post );
|
||||
$episode_url = get_permalink( $post );
|
||||
$transcript_type = isset( $attributes['transcriptType'] ) ? (string) $attributes['transcriptType'] : '';
|
||||
|
||||
// Show-level data backs the `partOfSeries` PodcastSeries reference so
|
||||
// search engines can connect the episode to its parent show.
|
||||
$show_title = (string) get_option( 'podcasting_title', '' );
|
||||
$show_image_url = (string) get_option( 'podcasting_image', '' );
|
||||
$show_email = (string) get_option( 'podcasting_email', '' );
|
||||
|
||||
// Cover art chain resolved unconditionally so schema metadata always carries
|
||||
// the image; the `$show_poster` toggle only gates the visible figure and the
|
||||
// video poster.
|
||||
$image_url = self::resolve_cover_art_url( $attributes, $post, 'full' );
|
||||
|
||||
// AudioObject/VideoObject @type for the embedded media.
|
||||
$media_object_type = 'video' === $media_type ? 'VideoObject' : 'AudioObject';
|
||||
|
||||
$wrapper_attributes = get_block_wrapper_attributes();
|
||||
|
||||
ob_start();
|
||||
?>
|
||||
<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns pre-escaped attribute output. ?>>
|
||||
<article class="jetpack-podcast-episode" itemscope itemtype="https://schema.org/PodcastEpisode">
|
||||
<?php if ( $episode_url ) : ?>
|
||||
<link itemprop="url" href="<?php echo esc_url( $episode_url ); ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ( $image_url && $show_poster ) : ?>
|
||||
<figure class="jetpack-podcast-episode__poster">
|
||||
<img
|
||||
src="<?php echo esc_url( $image_url ); ?>"
|
||||
alt=""
|
||||
itemprop="image"
|
||||
loading="lazy"
|
||||
/>
|
||||
</figure>
|
||||
<?php elseif ( $image_url ) : ?>
|
||||
<meta itemprop="image" content="<?php echo esc_url( $image_url ); ?>" />
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="jetpack-podcast-episode__body">
|
||||
<?php if ( $season_number || $episode_number || 'full' !== $episode_type || $is_explicit ) : ?>
|
||||
<p class="jetpack-podcast-episode__meta-line">
|
||||
<?php if ( $season_number ) : ?>
|
||||
<span class="jetpack-podcast-episode__season" itemprop="partOfSeason" itemscope itemtype="https://schema.org/PodcastSeason">
|
||||
<meta itemprop="seasonNumber" content="<?php echo esc_attr( (string) $season_number ); ?>" />
|
||||
<?php
|
||||
/* translators: %d: season number. */
|
||||
echo esc_html( sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number ) );
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $episode_number ) : ?>
|
||||
<span class="jetpack-podcast-episode__episode-number">
|
||||
<meta itemprop="episodeNumber" content="<?php echo esc_attr( (string) $episode_number ); ?>" />
|
||||
<?php
|
||||
/* translators: %d: episode number. */
|
||||
echo esc_html( sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number ) );
|
||||
?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( 'trailer' === $episode_type ) : ?>
|
||||
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--trailer"><?php esc_html_e( 'Trailer', 'jetpack-podcast' ); ?></span>
|
||||
<?php elseif ( 'bonus' === $episode_type ) : ?>
|
||||
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--bonus"><?php esc_html_e( 'Bonus', 'jetpack-podcast' ); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $is_explicit ) : ?>
|
||||
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--explicit" title="<?php esc_attr_e( 'Explicit content', 'jetpack-podcast' ); ?>"><?php echo esc_html( _x( 'E', 'short label for explicit content', 'jetpack-podcast' ) ); ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $title ) : ?>
|
||||
<h3 class="jetpack-podcast-episode__title" itemprop="name"><?php echo esc_html( $title ); ?></h3>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $author_name || $publish_date || $duration ) : ?>
|
||||
<p class="jetpack-podcast-episode__byline">
|
||||
<?php if ( $author_name ) : ?>
|
||||
<span class="jetpack-podcast-episode__author" itemprop="author" itemscope itemtype="https://schema.org/Person">
|
||||
<?php if ( $author_url ) : ?>
|
||||
<a href="<?php echo esc_url( $author_url ); ?>" itemprop="url">
|
||||
<span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
|
||||
<?php endif; ?>
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $publish_date ) : ?>
|
||||
<time
|
||||
class="jetpack-podcast-episode__date"
|
||||
datetime="<?php echo esc_attr( $publish_date_iso ); ?>"
|
||||
itemprop="datePublished"
|
||||
>
|
||||
<?php echo esc_html( $publish_date ); ?>
|
||||
</time>
|
||||
<?php endif; ?>
|
||||
<?php if ( $duration ) : ?>
|
||||
<span class="jetpack-podcast-episode__duration"><?php echo esc_html( $duration ); ?></span>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div
|
||||
class="jetpack-podcast-episode__player"
|
||||
itemprop="<?php echo 'video' === $media_type ? 'video' : 'audio'; ?>"
|
||||
itemscope
|
||||
itemtype="https://schema.org/<?php echo esc_attr( $media_object_type ); ?>"
|
||||
>
|
||||
<meta itemprop="contentUrl" content="<?php echo esc_url( $media_url ); ?>" />
|
||||
<?php if ( $mime_type ) : ?>
|
||||
<meta itemprop="encodingFormat" content="<?php echo esc_attr( $mime_type ); ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ( $duration ) : ?>
|
||||
<meta itemprop="duration" content="<?php echo esc_attr( $duration ); ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ( 'video' === $media_type ) : ?>
|
||||
<video
|
||||
class="jetpack-podcast-episode__video"
|
||||
controls
|
||||
preload="none"
|
||||
src="<?php echo esc_url( $media_url ); ?>"
|
||||
<?php
|
||||
if ( $image_url && $show_poster ) :
|
||||
?>
|
||||
poster="<?php echo esc_url( $image_url ); ?>"<?php endif; ?>
|
||||
<?php
|
||||
if ( $mime_type ) :
|
||||
?>
|
||||
data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
|
||||
></video>
|
||||
<?php else : ?>
|
||||
<audio
|
||||
class="jetpack-podcast-episode__audio"
|
||||
controls
|
||||
preload="none"
|
||||
src="<?php echo esc_url( $media_url ); ?>"
|
||||
<?php
|
||||
if ( $mime_type ) :
|
||||
?>
|
||||
data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
|
||||
></audio>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ( ! empty( $soundbites ) ) : ?>
|
||||
<ul class="jetpack-podcast-episode__soundbites">
|
||||
<?php
|
||||
foreach ( $soundbites as $soundbite ) :
|
||||
if ( ! is_array( $soundbite ) || ! isset( $soundbite['startTime'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$start_label = self::format_seconds_label( $soundbite['startTime'] );
|
||||
$soundbite_title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
|
||||
$start_seconds = (int) floor( max( 0, (float) $soundbite['startTime'] ) );
|
||||
$end_seconds = isset( $soundbite['duration'] )
|
||||
? $start_seconds + (int) floor( max( 0, (float) $soundbite['duration'] ) )
|
||||
: null;
|
||||
?>
|
||||
<li
|
||||
class="jetpack-podcast-episode__soundbite"
|
||||
itemprop="hasPart"
|
||||
itemscope
|
||||
itemtype="https://schema.org/Clip"
|
||||
>
|
||||
<meta itemprop="startOffset" content="<?php echo esc_attr( (string) $start_seconds ); ?>" />
|
||||
<?php if ( null !== $end_seconds ) : ?>
|
||||
<meta itemprop="endOffset" content="<?php echo esc_attr( (string) $end_seconds ); ?>" />
|
||||
<?php endif; ?>
|
||||
<button
|
||||
type="button"
|
||||
class="jetpack-podcast-episode__soundbite-button"
|
||||
data-start-time="<?php echo esc_attr( (string) $start_seconds ); ?>"
|
||||
>
|
||||
<time class="jetpack-podcast-episode__soundbite-time"><?php echo esc_html( $start_label ); ?></time>
|
||||
<?php if ( '' !== $soundbite_title ) : ?>
|
||||
<span class="jetpack-podcast-episode__soundbite-title" itemprop="name"><?php echo esc_html( $soundbite_title ); ?></span>
|
||||
<?php endif; ?>
|
||||
</button>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( ! empty( $alternate_enclosures ) ) : ?>
|
||||
<ul class="jetpack-podcast-episode__alternates">
|
||||
<?php
|
||||
foreach ( $alternate_enclosures as $alt ) :
|
||||
if ( ! is_array( $alt ) || empty( $alt['url'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$alt_url = esc_url_raw( (string) $alt['url'] );
|
||||
if ( ! wp_http_validate_url( $alt_url ) ) {
|
||||
continue;
|
||||
}
|
||||
$alt_type = isset( $alt['type'] ) ? (string) $alt['type'] : '';
|
||||
$alt_title = isset( $alt['title'] ) ? trim( (string) $alt['title'] ) : '';
|
||||
$alt_lang = isset( $alt['lang'] ) ? (string) $alt['lang'] : '';
|
||||
$alt_bitrate = isset( $alt['bitrate'] ) ? (int) $alt['bitrate'] : 0;
|
||||
$details = array();
|
||||
if ( '' !== $alt_lang ) {
|
||||
$details[] = $alt_lang;
|
||||
}
|
||||
if ( $alt_bitrate > 0 ) {
|
||||
$details[] = sprintf(
|
||||
/* translators: %d: bitrate in kilobits per second. */
|
||||
__( '%d kbps', 'jetpack-podcast' ),
|
||||
(int) round( $alt_bitrate / 1000 )
|
||||
);
|
||||
}
|
||||
if ( '' !== $alt_type ) {
|
||||
$details[] = $alt_type;
|
||||
}
|
||||
$details_label = $details ? ' (' . implode( ', ', $details ) . ')' : '';
|
||||
$display_label = '' !== $alt_title ? $alt_title : __( 'Alternative version', 'jetpack-podcast' );
|
||||
?>
|
||||
<li class="jetpack-podcast-episode__alternate">
|
||||
<a href="<?php echo esc_url( $alt_url ); ?>"<?php echo '' !== $alt_lang ? ' hreflang="' . esc_attr( $alt_lang ) . '"' : ''; ?>>
|
||||
<?php echo esc_html( $display_label . $details_label ); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( ! empty( $people ) ) : ?>
|
||||
<ul class="jetpack-podcast-episode__people">
|
||||
<?php
|
||||
foreach ( $people as $person ) :
|
||||
if ( ! is_array( $person ) || empty( $person['name'] ) ) {
|
||||
continue;
|
||||
}
|
||||
$person_name = (string) $person['name'];
|
||||
$person_role = isset( $person['role'] ) ? (string) $person['role'] : '';
|
||||
$person_href = isset( $person['href'] ) ? esc_url_raw( $person['href'] ) : '';
|
||||
$person_img = isset( $person['img'] ) ? esc_url_raw( $person['img'] ) : '';
|
||||
?>
|
||||
<li class="jetpack-podcast-episode__person" itemprop="contributor" itemscope itemtype="https://schema.org/Person">
|
||||
<?php if ( $person_img ) : ?>
|
||||
<img src="<?php echo esc_url( $person_img ); ?>" alt="" loading="lazy" />
|
||||
<?php endif; ?>
|
||||
<?php if ( $person_href ) : ?>
|
||||
<a href="<?php echo esc_url( $person_href ); ?>" itemprop="url">
|
||||
<span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
|
||||
</a>
|
||||
<?php else : ?>
|
||||
<span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
|
||||
<?php endif; ?>
|
||||
<?php if ( $person_role ) : ?>
|
||||
<span class="jetpack-podcast-episode__person-role"><?php echo esc_html( $person_role ); ?></span>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( '' !== $show_title ) : ?>
|
||||
<div class="jetpack-podcast-episode__series" itemprop="partOfSeries" itemscope itemtype="https://schema.org/PodcastSeries">
|
||||
<meta itemprop="name" content="<?php echo esc_attr( $show_title ); ?>" />
|
||||
<?php if ( $show_image_url ) : ?>
|
||||
<meta itemprop="image" content="<?php echo esc_url( $show_image_url ); ?>" />
|
||||
<?php endif; ?>
|
||||
<?php if ( $show_email ) : ?>
|
||||
<span itemprop="publisher" itemscope itemtype="https://schema.org/Organization">
|
||||
<meta itemprop="email" content="<?php echo esc_attr( $show_email ); ?>" />
|
||||
</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ( $transcript_url || $location_name || $license ) : ?>
|
||||
<ul class="jetpack-podcast-episode__links">
|
||||
<?php if ( $transcript_url ) : ?>
|
||||
<li itemprop="transcript" itemscope itemtype="https://schema.org/MediaObject">
|
||||
<meta itemprop="contentUrl" content="<?php echo esc_url( $transcript_url ); ?>" />
|
||||
<?php if ( '' !== $transcript_type ) : ?>
|
||||
<meta itemprop="encodingFormat" content="<?php echo esc_attr( $transcript_type ); ?>" />
|
||||
<?php endif; ?>
|
||||
<a href="<?php echo esc_url( $transcript_url ); ?>" class="jetpack-podcast-episode__transcript-link">
|
||||
<?php esc_html_e( 'Read transcript', 'jetpack-podcast' ); ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
<?php if ( $location_name ) : ?>
|
||||
<li class="jetpack-podcast-episode__location" itemprop="contentLocation"><?php echo esc_html( $location_name ); ?></li>
|
||||
<?php endif; ?>
|
||||
<?php if ( $license ) : ?>
|
||||
<li class="jetpack-podcast-episode__license">
|
||||
<?php
|
||||
/* translators: %s: license identifier (e.g. "CC-BY-4.0"). */
|
||||
$license_label = sprintf( _x( 'License: %s', 'episode metadata license label', 'jetpack-podcast' ), $license );
|
||||
?>
|
||||
<?php if ( $license_url ) : ?>
|
||||
<a href="<?php echo esc_url( $license_url ); ?>" itemprop="license"><?php echo esc_html( $license_label ); ?></a>
|
||||
<?php else : ?>
|
||||
<?php echo esc_html( $license_label ); ?>
|
||||
<?php endif; ?>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<?php
|
||||
|
||||
return ob_get_clean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the block for email via the WooCommerce Email Editor.
|
||||
*
|
||||
* Email clients can't run the interactive player, so render a static
|
||||
* episode card — cover art, title, byline, duration — linking back to
|
||||
* the episode post, where the full player lives.
|
||||
*
|
||||
* @param string $block_content The original block HTML content.
|
||||
* @param array $parsed_block The parsed block data including attributes.
|
||||
* @param object $rendering_context Email rendering context.
|
||||
* @return string
|
||||
*/
|
||||
public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
|
||||
if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
|
||||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
|
||||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$attrs = $parsed_block['attrs'];
|
||||
|
||||
if ( empty( $attrs['mediaUrl'] ) || ! wp_http_validate_url( $attrs['mediaUrl'] ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$post = get_post();
|
||||
if ( ! $post ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$post_url = get_permalink( $post );
|
||||
if ( empty( $post_url ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$title = get_the_title( $post );
|
||||
$author_name = get_the_author_meta( 'display_name', (int) $post->post_author );
|
||||
$publish_date = get_the_date( '', $post );
|
||||
$duration = isset( $attrs['duration'] ) ? trim( (string) $attrs['duration'] ) : '';
|
||||
$season_number = isset( $attrs['seasonNumber'] ) ? (int) $attrs['seasonNumber'] : 0;
|
||||
$episode_number = isset( $attrs['episodeNumber'] ) ? (int) $attrs['episodeNumber'] : 0;
|
||||
|
||||
$image_url = self::resolve_cover_art_url( $attrs, $post, 'thumbnail' );
|
||||
if ( '' !== $image_url && ! wp_http_validate_url( $image_url ) ) {
|
||||
$image_url = '';
|
||||
}
|
||||
|
||||
$cta_label = isset( $attrs['mediaType'] ) && 'video' === $attrs['mediaType']
|
||||
? __( 'Watch the episode', 'jetpack-podcast' )
|
||||
: __( 'Listen to the episode', 'jetpack-podcast' );
|
||||
|
||||
$meta_parts = array();
|
||||
if ( $season_number ) {
|
||||
/* translators: %d: season number. */
|
||||
$meta_parts[] = sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number );
|
||||
}
|
||||
if ( $episode_number ) {
|
||||
/* translators: %d: episode number. */
|
||||
$meta_parts[] = sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number );
|
||||
}
|
||||
|
||||
$byline_parts = array_filter( array( $author_name, $publish_date, $duration ) );
|
||||
|
||||
$body = '';
|
||||
if ( $meta_parts ) {
|
||||
$body .= sprintf(
|
||||
'<p style="margin: 0 0 4px; font-size: 12px; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.5px; color: #757575;">%s</p>',
|
||||
esc_html( implode( ' · ', $meta_parts ) )
|
||||
);
|
||||
}
|
||||
if ( $title ) {
|
||||
$body .= sprintf(
|
||||
'<h3 style="margin: 0 0 4px; font-size: 18px; line-height: 1.3;"><a href="%s" style="color: inherit; text-decoration: none;">%s</a></h3>',
|
||||
esc_url( $post_url ),
|
||||
esc_html( $title )
|
||||
);
|
||||
}
|
||||
if ( $byline_parts ) {
|
||||
$body .= sprintf(
|
||||
'<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.4; color: #757575;">%s</p>',
|
||||
esc_html( implode( ' · ', $byline_parts ) )
|
||||
);
|
||||
}
|
||||
$body .= sprintf(
|
||||
'<p style="margin: 0; font-size: 14px;"><a href="%s" style="font-weight: 600;">▶ %s</a></p>',
|
||||
esc_url( $post_url ),
|
||||
esc_html( $cta_label )
|
||||
);
|
||||
|
||||
$cells = '';
|
||||
if ( '' !== $image_url ) {
|
||||
$image_link = sprintf(
|
||||
'<a href="%s"><img src="%s" alt="" width="96" height="96" style="display: block; width: 96px; height: 96px; border-radius: 4px;" /></a>',
|
||||
esc_url( $post_url ),
|
||||
esc_url( $image_url )
|
||||
);
|
||||
|
||||
// Padding lives on an inner wrapper, not the cell: the engine's
|
||||
// mobile media query zeroes `.layout-flex-item` horizontal padding
|
||||
// when it stacks the card, which would otherwise flush the content
|
||||
// against the border.
|
||||
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
|
||||
$cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
|
||||
'<div style="padding: 16px 0 0 16px;">' . $image_link . '</div>',
|
||||
array(
|
||||
'class' => 'layout-flex-item',
|
||||
'width' => '96',
|
||||
'valign' => 'top',
|
||||
'style' => 'width: 96px;',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Wrap the body in a nested table rather than dropping the loose <p>/<h3>
|
||||
// straight into the layout cell. Core blocks (e.g. Media_Text) never put
|
||||
// bare block-level elements in a cell — they table-wrap content so it
|
||||
// survives email pipelines intact. The padding rides on the nested cell,
|
||||
// which the engine's mobile `.layout-flex-item td` rule leaves untouched,
|
||||
// so it also stays put when the card stacks.
|
||||
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
|
||||
$body_table = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
|
||||
$body,
|
||||
array( 'style' => 'width: 100%; border-collapse: collapse;' ),
|
||||
array( 'style' => 'padding: 16px;' )
|
||||
);
|
||||
|
||||
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
|
||||
$cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
|
||||
$body_table,
|
||||
array(
|
||||
'class' => 'layout-flex-item',
|
||||
'valign' => 'top',
|
||||
)
|
||||
);
|
||||
|
||||
// Cap the card to the email layout width when the context exposes it
|
||||
// (method_exists guards against older email editor versions).
|
||||
$target_width = 600;
|
||||
if ( method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
|
||||
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
|
||||
$layout_width = (int) \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
|
||||
if ( $layout_width > 0 ) {
|
||||
$target_width = $layout_width;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve the vertical gap from email_attrs — the engine's spacing
|
||||
// preprocessor sets `margin-top`; horizontal root padding is applied
|
||||
// to the callback output by the engine itself.
|
||||
$email_attrs = $parsed_block['email_attrs'] ?? array();
|
||||
$table_margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' );
|
||||
|
||||
// `layout-flex-wrapper` opts the card into the engine's own mobile
|
||||
// stacking: its template-canvas.css media query collapses
|
||||
// `.layout-flex-wrapper`/`.layout-flex-item` to full-width blocks under
|
||||
// 660px, so no custom media query (or template-style filter) is needed.
|
||||
// border-collapse must stay `separate` for the rounded card border to render.
|
||||
$table_style = sprintf(
|
||||
'%s width: 100%%; max-width: %dpx; border-collapse: separate; border: 1px solid #ddd; border-radius: 6px;',
|
||||
$table_margin_style ? $table_margin_style : 'margin: 16px 0;',
|
||||
$target_width
|
||||
);
|
||||
|
||||
// Append user-set block supports (padding, border, colors) so editor
|
||||
// styling overrides the card defaults.
|
||||
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
|
||||
$user_styles = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::get_block_styles( $attrs, $rendering_context, array( 'padding', 'border', 'background-color', 'color' ) );
|
||||
if ( ! empty( $user_styles['css'] ) ) {
|
||||
$table_style .= ' ' . $user_styles['css'];
|
||||
}
|
||||
|
||||
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
|
||||
return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
|
||||
$cells,
|
||||
array(
|
||||
'class' => 'jetpack-podcast-episode-email-card layout-flex-wrapper',
|
||||
'style' => $table_style,
|
||||
),
|
||||
array(),
|
||||
array(),
|
||||
false
|
||||
);
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
/**
|
||||
* Registers the Jetpack Podcast wp-admin page and loads the wp-build dashboard.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Admin_UI\Admin_Menu;
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
|
||||
|
||||
/**
|
||||
* Adds the "Jetpack > Podcast" wp-admin screen.
|
||||
*/
|
||||
class Admin_Page {
|
||||
|
||||
const ADMIN_PAGE_SLUG = 'jetpack-podcast';
|
||||
|
||||
/**
|
||||
* Query var the checkout return URL carries so the gate busts its cached
|
||||
* purchases lookup the instant a buyer lands back on the dashboard. Kept in
|
||||
* sync with the `podcast_purchased` literal in `withPurchaseReturnMarker()`
|
||||
* (`src/dashboard/upgrade.ts`).
|
||||
*/
|
||||
const PURCHASE_RETURN_QUERY_VAR = 'podcast_purchased';
|
||||
|
||||
/**
|
||||
* Where the Podcast item sits in the Jetpack submenu on self-hosted.
|
||||
*
|
||||
* Placed after Subscribers (15) and Newsletter (10) to mirror the order
|
||||
* `wpcom-admin-menu.php` uses on Simple/Atomic.
|
||||
*/
|
||||
const MENU_POSITION = 16;
|
||||
|
||||
/**
|
||||
* Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
|
||||
* callback only fires when `$screen->id` matches this value, so we alias
|
||||
* the screen id via `current_screen` without changing the user-facing URL.
|
||||
*/
|
||||
const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
|
||||
|
||||
/**
|
||||
* Whether `init()` has already wired its hooks.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $initialized = false;
|
||||
|
||||
/**
|
||||
* Wire admin hooks. Idempotent.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( self::$initialized ) {
|
||||
return;
|
||||
}
|
||||
self::$initialized = true;
|
||||
|
||||
add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
|
||||
|
||||
// On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
|
||||
// priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
|
||||
// has no such file, so we register our own. Priority 999 queues the item
|
||||
// before Admin_Menu's priority-1000 callback.
|
||||
if ( ! ( new Host() )->is_wpcom_platform() ) {
|
||||
add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Podcast submenu under the Jetpack menu.
|
||||
*/
|
||||
public static function add_wp_admin_submenu() {
|
||||
// Prefer the wp-build render function once it's defined (by
|
||||
// maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
|
||||
$wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
|
||||
$callback = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
|
||||
|
||||
if ( ( new Host() )->is_wpcom_platform() ) {
|
||||
$page_suffix = add_submenu_page(
|
||||
'jetpack',
|
||||
/** "Podcast" is a product name, do not translate. */
|
||||
'Podcast',
|
||||
'Podcast',
|
||||
'manage_options',
|
||||
self::ADMIN_PAGE_SLUG,
|
||||
$callback
|
||||
);
|
||||
} else {
|
||||
$page_suffix = Admin_Menu::add_menu(
|
||||
/** "Podcast" is a product name, do not translate. */
|
||||
'Podcast',
|
||||
'Podcast',
|
||||
'manage_options',
|
||||
self::ADMIN_PAGE_SLUG,
|
||||
$callback,
|
||||
self::MENU_POSITION
|
||||
);
|
||||
}
|
||||
|
||||
if ( $page_suffix ) {
|
||||
add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire admin-init actions once we know the Podcast page is loading.
|
||||
*/
|
||||
public static function admin_init() {
|
||||
// MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
|
||||
add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Hooked at admin_menu priority 1 so polyfills register before
|
||||
* `wp_default_scripts` fires and the wp-build render function is defined
|
||||
* before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
|
||||
* on Simple/Atomic).
|
||||
*/
|
||||
public static function maybe_load_wp_build() {
|
||||
if ( ! self::is_podcast_admin_request() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::load_wp_build();
|
||||
add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
|
||||
add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the podcast gate boolean to `window.JetpackScriptData`.
|
||||
*
|
||||
* Hooked from `maybe_load_wp_build()` so it only runs when the request is
|
||||
* for the podcast admin page.
|
||||
*
|
||||
* @param array $data Script data being injected.
|
||||
* @return array
|
||||
*/
|
||||
public static function inject_podcast_script_data( $data ) {
|
||||
if ( ! is_array( $data ) ) {
|
||||
$data = array();
|
||||
}
|
||||
|
||||
$is_wpcom = ( new Host() )->is_wpcom_platform();
|
||||
|
||||
if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
|
||||
$blog_id = (int) Connection_Manager::get_site_id( true );
|
||||
if ( $blog_id > 0 ) {
|
||||
$data['site']['wpcom']['blog_id'] = $blog_id;
|
||||
}
|
||||
}
|
||||
|
||||
// A buyer returning from checkout carries the purchase marker; bust the
|
||||
// cached purchases lookup so the gate re-reads `/upgrades` and unlocks
|
||||
// the paid surfaces now instead of after the transient expires.
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( isset( $_GET[ self::PURCHASE_RETURN_QUERY_VAR ] ) ) {
|
||||
Podcast_Gate::flush_purchases_cache();
|
||||
}
|
||||
|
||||
// Self-hosted upsells the Growth plan; WordPress.com keeps Premium.
|
||||
// `product_slug` is fed straight to the checkout URL; `plan_name` is a
|
||||
// product name shown in the locked-preview copy (not translated).
|
||||
$data['podcast'] = array(
|
||||
'has_product_access' => Podcast_Gate::has_product_access(),
|
||||
'is_connected' => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
|
||||
'show_url_hosts' => Settings::SHOW_URL_HOSTS,
|
||||
'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
|
||||
// Settings only: categories rejects per_page=-1 server-side, stats is a live relay.
|
||||
'preload' => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
|
||||
'upgrade' => array(
|
||||
'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
|
||||
'plan_name' => $is_wpcom ? 'Premium' : 'Growth',
|
||||
),
|
||||
);
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* The build artifact may be absent on a fresh checkout before
|
||||
* `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
|
||||
* to `render()` so the page still loads (just without the React app).
|
||||
*/
|
||||
private static function load_wp_build() {
|
||||
$build_index = dirname( __DIR__ ) . '/build/build.php';
|
||||
|
||||
if ( ! file_exists( $build_index ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
require_once $build_index;
|
||||
|
||||
WP_Build_Polyfills::register(
|
||||
'jetpack-podcast',
|
||||
array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias the current screen id to wp-build's expected slug.
|
||||
*
|
||||
* @param \WP_Screen|null $screen The current screen object (passed by WP).
|
||||
*/
|
||||
public static function alias_screen_id_for_wp_build( $screen ) {
|
||||
if ( ! is_object( $screen ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$screen->id = self::WP_BUILD_SLUG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback render used when the wp-build artifact is missing.
|
||||
*/
|
||||
public static function render() {
|
||||
?>
|
||||
<div class="wrap">
|
||||
<h1>Podcast</h1>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current request targets the Podcast admin page.
|
||||
*/
|
||||
private static function is_podcast_admin_request() {
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
|
||||
}
|
||||
}
|
||||
+902
@@ -0,0 +1,902 @@
|
||||
<?php
|
||||
/**
|
||||
* Create AI Podcast — wp-admin page under Media.
|
||||
*
|
||||
* Standalone PHP page (no wp-build chassis, no React). Renders a static
|
||||
* form server-side; a single vanilla-JS island fetches quota, drives the
|
||||
* posts picker, submits the generate request, polls the job, and resumes
|
||||
* across reloads via localStorage.
|
||||
*
|
||||
* Bootstrapped from `Podcast::init()` after the Host (Simple/WoA) gate has
|
||||
* already been checked upstream.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
require_once __DIR__ . '/admin-pages/create-ai-podcast/presets.php';
|
||||
|
||||
use Automattic\Jetpack\Assets;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\length_presets;
|
||||
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\voice_presets;
|
||||
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\window_presets;
|
||||
|
||||
/**
|
||||
* Registers the Media > Create AI Podcast submenu and renders the page.
|
||||
*/
|
||||
class Create_AI_Podcast_Page {
|
||||
|
||||
const PAGE_SLUG = 'create-ai-podcast';
|
||||
const SCRIPT_HANDLE = 'jetpack-create-ai-podcast';
|
||||
const STYLE_HANDLE = 'jetpack-create-ai-podcast';
|
||||
const EPISODES_PER_PAGE = 5;
|
||||
|
||||
/**
|
||||
* Maximum number of posts that can be selected when generating from specific posts.
|
||||
*/
|
||||
const MAX_SELECTED_POSTS = 25;
|
||||
|
||||
const POST_PUBLISH_PROMO_SCRIPT_HANDLE = 'jetpack-post-publish-podcast-promo';
|
||||
const POST_PUBLISH_PROMO_DISMISSED_OPTION = 'jetpack_posts_to_podcast_post_publish_promo_dismissed';
|
||||
const POST_PUBLISH_PROMO_MIN_POSTS = 5;
|
||||
const POST_PUBLISH_PROMO_MIN_VISITORS = 50;
|
||||
|
||||
/**
|
||||
* Whether `init()` has wired its hooks.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $initialized = false;
|
||||
|
||||
/**
|
||||
* Wire admin hooks. Idempotent.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( self::$initialized ) {
|
||||
return;
|
||||
}
|
||||
self::$initialized = true;
|
||||
|
||||
add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );
|
||||
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_post_publish_promo_assets' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Media > Create AI Podcast submenu.
|
||||
*/
|
||||
public static function register_menu() {
|
||||
$page_suffix = add_submenu_page(
|
||||
'upload.php',
|
||||
__( 'Create AI Podcast', 'jetpack-podcast' ),
|
||||
__( 'Create AI Podcast', 'jetpack-podcast' ),
|
||||
'upload_files',
|
||||
self::PAGE_SLUG,
|
||||
array( __CLASS__, 'render' )
|
||||
);
|
||||
|
||||
if ( $page_suffix ) {
|
||||
add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_load' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire enqueue once we know the Create AI Podcast page is loading.
|
||||
*/
|
||||
public static function on_load() {
|
||||
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
|
||||
add_action( 'admin_head-media_page_create-ai-podcast', array( __CLASS__, 'render_resource_hints' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* On wpcom Simple sites every wp.apiFetch call is routed through the
|
||||
* wpcom-proxy iframe at public-api.wordpress.com. The iframe load adds a
|
||||
* full DNS + TLS round-trip before our prefetched quota/episodes requests
|
||||
* can leave the page. Preconnect shaves that off.
|
||||
*/
|
||||
public static function render_resource_hints() {
|
||||
?>
|
||||
<link rel="preconnect" href="https://public-api.wordpress.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="//public-api.wordpress.com">
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the static JS island, the page stylesheet, and the localized data bundle.
|
||||
*/
|
||||
public static function enqueue_assets() {
|
||||
$base_url = plugins_url( 'admin-pages/create-ai-podcast/', __FILE__ );
|
||||
$base_path = __DIR__ . '/admin-pages/create-ai-podcast/';
|
||||
|
||||
wp_enqueue_style(
|
||||
self::STYLE_HANDLE,
|
||||
$base_url . 'style.css',
|
||||
array(),
|
||||
(string) ( file_exists( $base_path . 'style.css' ) ? filemtime( $base_path . 'style.css' ) : '0.1.0' )
|
||||
);
|
||||
|
||||
wp_enqueue_script(
|
||||
self::SCRIPT_HANDLE,
|
||||
$base_url . 'index.js',
|
||||
array( 'wp-api-fetch', 'wp-i18n' ),
|
||||
(string) ( file_exists( $base_path . 'index.js' ) ? filemtime( $base_path . 'index.js' ) : '0.1.0' ),
|
||||
true
|
||||
);
|
||||
|
||||
wp_localize_script(
|
||||
self::SCRIPT_HANDLE,
|
||||
'jetpackCreateAiPodcast',
|
||||
self::build_localized_data()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue the post-publish modal in the post block editor for eligible sites.
|
||||
*/
|
||||
public static function enqueue_post_publish_promo_assets() {
|
||||
if (
|
||||
! self::is_post_block_editor()
|
||||
|| self::is_current_post_published_for_post_publish_promo()
|
||||
|| ! self::is_post_publish_promo_site_eligible()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
Assets::register_script(
|
||||
self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
|
||||
'../dist/blocks/post-publish-podcast-promo/editor.js',
|
||||
__FILE__,
|
||||
array(
|
||||
'enqueue' => true,
|
||||
'in_footer' => true,
|
||||
'textdomain' => 'jetpack-podcast',
|
||||
)
|
||||
);
|
||||
|
||||
wp_add_inline_script(
|
||||
self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
|
||||
'window.jetpackPostPublishPodcastPromo = ' . wp_json_encode(
|
||||
array(
|
||||
'createUrl' => admin_url( 'upload.php?page=' . self::PAGE_SLUG ),
|
||||
'dismissPath' => Posts_To_Podcast_Endpoint::get_post_publish_promo_dismiss_rest_path(),
|
||||
),
|
||||
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
|
||||
) . ';',
|
||||
'before'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current editor post has already been published.
|
||||
*/
|
||||
private static function is_current_post_published_for_post_publish_promo(): bool {
|
||||
$post = get_post();
|
||||
|
||||
return $post instanceof \WP_Post
|
||||
&& 'post' === $post->post_type
|
||||
&& 'publish' === $post->post_status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the current screen is the post block editor.
|
||||
*/
|
||||
private static function is_post_block_editor(): bool {
|
||||
if ( ! function_exists( 'get_current_screen' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$screen = get_current_screen();
|
||||
return ! empty( $screen )
|
||||
&& 'post' === $screen->base
|
||||
&& 'post' === $screen->post_type
|
||||
&& $screen->is_block_editor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the site has enough published posts to generate a better episode.
|
||||
*/
|
||||
private static function has_enough_recent_posts_for_post_publish_promo(): bool {
|
||||
/**
|
||||
* Filters the minimum posts published in the last month needed for the Posts to Podcast post-publish promo.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $minimum Minimum number of published posts.
|
||||
*/
|
||||
$minimum = (int) apply_filters(
|
||||
'jetpack_posts_to_podcast_post_publish_promo_min_published_posts',
|
||||
self::POST_PUBLISH_PROMO_MIN_POSTS
|
||||
);
|
||||
$minimum = max( 1, $minimum );
|
||||
|
||||
$published_posts = get_posts(
|
||||
array(
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'post_status' => 'publish',
|
||||
'post_type' => 'post',
|
||||
'posts_per_page' => $minimum,
|
||||
'suppress_filters' => false,
|
||||
'date_query' => array(
|
||||
array(
|
||||
'after' => '1 month ago',
|
||||
'inclusive' => true,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
$total = count( $published_posts );
|
||||
|
||||
$post = get_post();
|
||||
if ( $post && 'post' === $post->post_type && 'publish' !== $post->post_status ) {
|
||||
++$total;
|
||||
}
|
||||
|
||||
return $total >= $minimum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the site has visitors who could benefit from a podcast episode.
|
||||
*/
|
||||
private static function has_visitors_for_post_publish_promo(): bool {
|
||||
$visitors = self::get_post_publish_promo_visitor_count();
|
||||
|
||||
/**
|
||||
* Filters the minimum visitors in the last week needed for the Posts to Podcast post-publish promo.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*
|
||||
* @param int $minimum Minimum number of visitors.
|
||||
*/
|
||||
$minimum = (int) apply_filters(
|
||||
'jetpack_posts_to_podcast_post_publish_promo_min_visitors',
|
||||
self::POST_PUBLISH_PROMO_MIN_VISITORS
|
||||
);
|
||||
|
||||
return $visitors >= max( 1, $minimum );
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the last week's visitor count from Jetpack Stats when available.
|
||||
*/
|
||||
private static function get_post_publish_promo_visitor_count(): int {
|
||||
$host = new Host();
|
||||
if ( $host->is_wpcom_simple() ) {
|
||||
return self::get_wpcom_simple_post_publish_promo_visitor_count();
|
||||
}
|
||||
|
||||
if ( class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) ) {
|
||||
$wpcom_stats = new \Automattic\Jetpack\Stats\WPCOM_Stats();
|
||||
$stats = $wpcom_stats->get_visits(
|
||||
array(
|
||||
'unit' => 'day',
|
||||
'quantity' => 7,
|
||||
'stat_fields' => 'visitors',
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! is_wp_error( $stats ) && is_array( $stats ) ) {
|
||||
return self::sum_visits_field( $stats, 'visitors' );
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the last week's visitor count directly on WordPress.com Simple.
|
||||
*/
|
||||
private static function get_wpcom_simple_post_publish_promo_visitor_count(): int {
|
||||
if ( ! function_exists( 'stats_get_visitors' ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$visitors = stats_get_visitors( get_current_blog_id(), gmdate( 'Y-m-d' ), 7, 1 );
|
||||
|
||||
return is_array( $visitors ) ? (int) array_sum( $visitors ) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum a metric from the Stats visits response.
|
||||
*
|
||||
* @param array $stats Stats visits response.
|
||||
* @param string $field Field to sum.
|
||||
* @return int
|
||||
*/
|
||||
private static function sum_visits_field( array $stats, string $field ): int {
|
||||
if ( ! isset( $stats['data'] ) || ! is_array( $stats['data'] ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$fields = isset( $stats['fields'] ) && is_array( $stats['fields'] ) ? $stats['fields'] : array();
|
||||
$index = array_search( $field, $fields, true );
|
||||
if ( false === $index ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ( $stats['data'] as $row ) {
|
||||
if ( is_array( $row ) && isset( $row[ $index ] ) ) {
|
||||
$total += (int) $row[ $index ];
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the site is relevant for the post-publish promo.
|
||||
*/
|
||||
public static function is_post_publish_promo_site_eligible(): bool {
|
||||
$host = new Host();
|
||||
if ( $host->is_p2_site() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( get_user_option( self::POST_PUBLISH_PROMO_DISMISSED_OPTION, get_current_user_id() ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return self::has_enough_recent_posts_for_post_publish_promo() && self::has_visitors_for_post_publish_promo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the data bundle passed to the JS island via wp_localize_script.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function build_localized_data(): array {
|
||||
$max_posts = self::MAX_SELECTED_POSTS;
|
||||
|
||||
return array(
|
||||
'maxPosts' => $max_posts,
|
||||
'endpoints' => array(
|
||||
'enqueue' => '/wpcom/v2/posts-to-podcast',
|
||||
'job' => '/wpcom/v2/posts-to-podcast/jobs/',
|
||||
'quota' => '/wpcom/v2/posts-to-podcast',
|
||||
'posts' => '/wp/v2/posts',
|
||||
'episodes' => '/wpcom/v2/posts-to-podcast/episodes',
|
||||
),
|
||||
'blogId' => self::resolve_blog_id(),
|
||||
'bootstrap' => self::bootstrap_data(),
|
||||
'presets' => array(
|
||||
'window' => window_presets(),
|
||||
'length' => length_presets(),
|
||||
'voice' => voice_presets(),
|
||||
),
|
||||
'poll' => array(
|
||||
'fastMs' => 3000,
|
||||
'slowMs' => 10000,
|
||||
'switchMs' => 30000,
|
||||
'timeoutMs' => 5 * 60 * 1000,
|
||||
),
|
||||
'i18n' => array(
|
||||
'submitting' => __( 'Submitting…', 'jetpack-podcast' ),
|
||||
'polling' => __( 'Generating your episode…', 'jetpack-podcast' ),
|
||||
'pollingSubtext' => __( "This usually takes about 3 minutes. You can leave this page and come back — we'll keep working in the background.", 'jetpack-podcast' ),
|
||||
'succeeded' => __( 'Episode draft ready.', 'jetpack-podcast' ),
|
||||
'editDraft' => __( 'Edit draft', 'jetpack-podcast' ),
|
||||
'failed' => __( 'Generation failed.', 'jetpack-podcast' ),
|
||||
'timedOut' => __( 'Generation is taking longer than expected. Check your drafts.', 'jetpack-podcast' ),
|
||||
'tryAgain' => __( 'Try again', 'jetpack-podcast' ),
|
||||
'dismiss' => __( 'Dismiss', 'jetpack-podcast' ),
|
||||
'notAvailable' => __( 'Create AI Podcast isn\'t available on your current plan.', 'jetpack-podcast' ),
|
||||
// translators: 1: number of credits used, 2: total credits available.
|
||||
'creditsUsed' => __( '%1$d of %2$d credits used.', 'jetpack-podcast' ),
|
||||
'creditsLabel' => __( 'Credits', 'jetpack-podcast' ),
|
||||
// translators: 1: number of credits used, 2: total credits available.
|
||||
'creditsCount' => __( '%1$d / %2$d', 'jetpack-podcast' ),
|
||||
'creditsUnlimited' => __( 'Unlimited generations available.', 'jetpack-podcast' ),
|
||||
// translators: %d: credits remaining.
|
||||
'creditsRemaining' => __( '%d remaining', 'jetpack-podcast' ),
|
||||
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
|
||||
'creditsResetSummary' => __( 'Resets %s', 'jetpack-podcast' ),
|
||||
'creditsResetMonthly' => __( 'Resets monthly', 'jetpack-podcast' ),
|
||||
'relativeToday' => __( 'today', 'jetpack-podcast' ),
|
||||
'relativeTomorrow' => __( 'tomorrow', 'jetpack-podcast' ),
|
||||
// translators: %d: number of days until reset.
|
||||
'relativeDays' => __( 'in %d days', 'jetpack-podcast' ),
|
||||
// translators: %s: formatted date, e.g. "May 20, 2026".
|
||||
'relativeOn' => __( 'on %s', 'jetpack-podcast' ),
|
||||
'trialBannerTitle' => __( 'Try before you buy', 'jetpack-podcast' ),
|
||||
'trialBannerMessage' => __( 'Generate a podcast from your posts and see how it sounds on your site. Free trial is limited to one podcast episode.', 'jetpack-podcast' ),
|
||||
'runningLowTitle' => __( 'Running low', 'jetpack-podcast' ),
|
||||
'runningLowMessage' => __( 'Upgrade your plan to keep generating without waiting for the monthly refresh.', 'jetpack-podcast' ),
|
||||
'outOfCreditsTitle' => __( 'Out of credits', 'jetpack-podcast' ),
|
||||
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
|
||||
'outOfCreditsWait' => __( 'Your credits will refresh %s.', 'jetpack-podcast' ),
|
||||
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
|
||||
'outOfCreditsUpgrade' => __( 'Upgrade your plan for more credits, or wait until they refresh %s.', 'jetpack-podcast' ),
|
||||
'outOfTrialCredits' => __( 'You have used your one-time trial credit. Upgrade your plan for more credits.', 'jetpack-podcast' ),
|
||||
'noPostsFound' => __( 'No posts match.', 'jetpack-podcast' ),
|
||||
'loadingPosts' => __( 'Loading posts…', 'jetpack-podcast' ),
|
||||
'pickPosts' => __( 'Select at least one post to continue.', 'jetpack-podcast' ),
|
||||
// translators: %d: maximum number of posts that can be selected.
|
||||
'maxPostsReached' => sprintf( __( 'You can select up to %d posts.', 'jetpack-podcast' ), $max_posts ),
|
||||
'upgradeCta' => __( 'Upgrade plan', 'jetpack-podcast' ),
|
||||
'episodesTitle' => __( 'Generated podcasts', 'jetpack-podcast' ),
|
||||
'episodesEmpty' => __( 'No generated podcasts yet.', 'jetpack-podcast' ),
|
||||
'episodesLoading' => __( 'Loading podcasts…', 'jetpack-podcast' ),
|
||||
'editPost' => __( 'Edit post', 'jetpack-podcast' ),
|
||||
'statusDraft' => __( 'Draft', 'jetpack-podcast' ),
|
||||
'statusPublished' => __( 'Published', 'jetpack-podcast' ),
|
||||
// translators: 1: range start, 2: range end, 3: total count. Example: "Showing 1–5 of 12"
|
||||
'paginationSummary' => __( 'Showing %1$d–%2$d of %3$d', 'jetpack-podcast' ),
|
||||
'paginationPrev' => __( 'Previous', 'jetpack-podcast' ),
|
||||
'paginationNext' => __( 'Next', 'jetpack-podcast' ),
|
||||
// translators: %d: page number. Example: "Go to page 3"
|
||||
'paginationGoTo' => __( 'Go to page %d', 'jetpack-podcast' ),
|
||||
'paginationLabel' => __( 'Episodes pagination', 'jetpack-podcast' ),
|
||||
'unexpectedError' => __( 'An unexpected error occurred.', 'jetpack-podcast' ),
|
||||
'outOfCreditsError' => __( 'Out of credits.', 'jetpack-podcast' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the wpcom blog id for this site. On Atomic Jetpack stores it
|
||||
* under the `id` option; on Simple sites it's the current blog id.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function resolve_blog_id(): int {
|
||||
if ( class_exists( '\\Jetpack_Options' ) ) {
|
||||
$id = (int) \Jetpack_Options::get_option( 'id' );
|
||||
if ( $id > 0 ) {
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
return (int) get_current_blog_id();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the two initial reads (quota + episodes) server-side via
|
||||
* rest_do_request so the client can render with data immediately instead
|
||||
* of waiting on the wpcom-proxy iframe for the first paint. Failures fall
|
||||
* through silently — the JS island falls back to fetch when an entry is
|
||||
* null or absent.
|
||||
*
|
||||
* @return array{quota: array|null, episodes: array|null}
|
||||
*/
|
||||
private static function bootstrap_data(): array {
|
||||
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
|
||||
return self::bootstrap_data_wpcom();
|
||||
}
|
||||
return self::bootstrap_data_via_proxy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic / self-hosted path: hit the local Jetpack-side proxy via
|
||||
* rest_do_request, which forwards to wpcom over HTTPS using the current
|
||||
* user's Jetpack token.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function bootstrap_data_via_proxy(): array {
|
||||
$bootstrap = array(
|
||||
'quota' => null,
|
||||
'episodes' => self::empty_episodes_envelope(),
|
||||
);
|
||||
|
||||
$quota_request = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast' );
|
||||
$quota_response = rest_do_request( $quota_request );
|
||||
if ( $quota_response instanceof \WP_REST_Response ) {
|
||||
if ( ! $quota_response->is_error() ) {
|
||||
$bootstrap['quota'] = $quota_response->get_data();
|
||||
} else {
|
||||
$status = (int) $quota_response->get_status();
|
||||
if ( 403 === $status || 404 === $status ) {
|
||||
$bootstrap['quota'] = array( 'notAvailable' => true );
|
||||
} else {
|
||||
$bootstrap['quota'] = array(
|
||||
'quota' => 0,
|
||||
'used' => 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$episodes_request = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast/episodes' );
|
||||
$episodes_request->set_param( 'page', 1 );
|
||||
$episodes_request->set_param( 'per_page', self::EPISODES_PER_PAGE );
|
||||
$episodes_response = rest_do_request( $episodes_request );
|
||||
if ( $episodes_response instanceof \WP_REST_Response && ! $episodes_response->is_error() ) {
|
||||
$bootstrap['episodes'] = self::normalize_episodes_payload( $episodes_response->get_data() );
|
||||
}
|
||||
|
||||
return $bootstrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate the wpcom-side endpoint's active-job payload shape so the
|
||||
* client can resume polling the "Generating…" notice across reloads on
|
||||
* Simple sites, the same way it does on Atomic through the proxy.
|
||||
*
|
||||
* @param int $blog_id Current blog id.
|
||||
*
|
||||
* @return array|\stdClass
|
||||
*/
|
||||
private static function build_active_job_payload_wpcom( int $blog_id ) {
|
||||
if ( ! function_exists( 'posts_to_podcast_get_active_job_record' ) ) {
|
||||
return new \stdClass();
|
||||
}
|
||||
$record = posts_to_podcast_get_active_job_record( $blog_id );
|
||||
if ( null === $record ) {
|
||||
return new \stdClass();
|
||||
}
|
||||
|
||||
$status_map = array(
|
||||
'queued' => 'pending',
|
||||
'succeeded' => 'complete',
|
||||
'failed' => 'failed',
|
||||
);
|
||||
$raw = function_exists( 'get_job_status' ) ? get_job_status( $record['id'] ) : 'queued';
|
||||
$status = $status_map[ $raw ] ?? 'pending';
|
||||
|
||||
$payload = array(
|
||||
'jobId' => (int) $record['id'],
|
||||
'status' => $status,
|
||||
'createdAt' => gmdate( 'c', (int) $record['queued_at'] ),
|
||||
);
|
||||
|
||||
if ( 'complete' === $status && function_exists( 'posts_to_podcast_get_job_result' ) ) {
|
||||
$post_id = posts_to_podcast_get_job_result( $record['id'] );
|
||||
if ( null !== $post_id ) {
|
||||
$payload['postId'] = $post_id;
|
||||
$payload['editUrl'] = (string) get_edit_post_link( $post_id, 'raw' );
|
||||
}
|
||||
}
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicate the wpcom-side endpoint's upgrade URL builder so the Out of
|
||||
* credits banner can surface an Upgrade plan CTA even on Simple sites
|
||||
* (where we don't go through the REST proxy). Returns the Calypso
|
||||
* checkout URL for the next tier up, or empty when the site is already
|
||||
* on the top podcast tier.
|
||||
*
|
||||
* @param int $blog_id Current blog id.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function build_upgrade_url_wpcom( int $blog_id ): string {
|
||||
if ( ! class_exists( '\\WPCOM_Features' ) || ! function_exists( 'wpcom_site_has_feature' ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_3, $blog_id ) ) {
|
||||
return '';
|
||||
}
|
||||
if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_2, $blog_id ) ) {
|
||||
$plan = 'business';
|
||||
} elseif ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_1, $blog_id ) ) {
|
||||
$plan = 'premium';
|
||||
} else {
|
||||
$plan = 'personal';
|
||||
}
|
||||
|
||||
$site_slug = class_exists( '\\WPCOM_Masterbar' )
|
||||
? \WPCOM_Masterbar::get_calypso_site_slug( $blog_id )
|
||||
: '';
|
||||
if ( '' === $site_slug ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return sprintf( 'https://wordpress.com/checkout/%s/%s', $site_slug, $plan );
|
||||
}
|
||||
|
||||
/**
|
||||
* Default empty episodes envelope used while the page is still loading
|
||||
* data or when an upstream request errors.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function empty_episodes_envelope(): array {
|
||||
return array(
|
||||
'items' => array(),
|
||||
'total' => 0,
|
||||
'page' => 1,
|
||||
'perPage' => self::EPISODES_PER_PAGE,
|
||||
'totalPages' => 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept either the new envelope shape (preferred) or the legacy bare
|
||||
* array (older sandboxes that haven't shipped the pagination upgrade yet)
|
||||
* and return the envelope.
|
||||
*
|
||||
* @param mixed $payload Upstream response body.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function normalize_episodes_payload( $payload ): array {
|
||||
if ( is_array( $payload ) && isset( $payload['items'] ) && is_array( $payload['items'] ) ) {
|
||||
return array(
|
||||
'items' => array_values( $payload['items'] ),
|
||||
'total' => isset( $payload['total'] ) ? (int) $payload['total'] : count( $payload['items'] ),
|
||||
'page' => isset( $payload['page'] ) ? max( 1, (int) $payload['page'] ) : 1,
|
||||
'perPage' => isset( $payload['perPage'] ) ? max( 1, (int) $payload['perPage'] ) : self::EPISODES_PER_PAGE,
|
||||
'totalPages' => isset( $payload['totalPages'] ) ? max( 0, (int) $payload['totalPages'] ) : 0,
|
||||
);
|
||||
}
|
||||
if ( is_array( $payload ) ) {
|
||||
$items = array_values( $payload );
|
||||
return array(
|
||||
'items' => $items,
|
||||
'total' => count( $items ),
|
||||
'page' => 1,
|
||||
'perPage' => self::EPISODES_PER_PAGE,
|
||||
'totalPages' => count( $items ) > 0 ? 1 : 0,
|
||||
);
|
||||
}
|
||||
return self::empty_episodes_envelope();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple (wpcom) path: rest_do_request can't reach the posts-to-podcast
|
||||
* endpoint here — the wpcom REST plugin loader gates the endpoint files
|
||||
* behind REST_API_PLUGINS, which isn't set in admin context. Call the
|
||||
* underlying wpcom helpers directly. Permissions are still enforced via
|
||||
* the admin caps required to render this page.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
private static function bootstrap_data_wpcom(): array {
|
||||
$bootstrap = array(
|
||||
'quota' => null,
|
||||
'episodes' => self::empty_episodes_envelope(),
|
||||
);
|
||||
|
||||
if ( ! function_exists( 'require_lib' ) ) {
|
||||
return $bootstrap;
|
||||
}
|
||||
require_lib( 'posts-to-podcast' );
|
||||
|
||||
$blog_id = (int) get_current_blog_id();
|
||||
|
||||
if ( function_exists( '\\Automattic\\Posts_To_Podcast\\get_usage' ) || function_exists( 'posts_to_podcast_get_usage' ) ) {
|
||||
$usage = function_exists( 'posts_to_podcast_get_usage' )
|
||||
? posts_to_podcast_get_usage( $blog_id )
|
||||
: array();
|
||||
$bootstrap['quota'] = array(
|
||||
'quota' => $usage,
|
||||
'activeJob' => self::build_active_job_payload_wpcom( $blog_id ),
|
||||
'upgradeUrl' => self::build_upgrade_url_wpcom( $blog_id ),
|
||||
);
|
||||
}
|
||||
|
||||
$per_page = self::EPISODES_PER_PAGE;
|
||||
$query = new \WP_Query(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => array( 'draft', 'publish' ),
|
||||
'posts_per_page' => $per_page,
|
||||
'paged' => 1,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'update_post_term_cache' => false,
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => 'posts_to_podcast_metadata',
|
||||
'compare' => 'EXISTS',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$items = array();
|
||||
foreach ( $query->posts as $post ) {
|
||||
$raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
|
||||
$meta = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
|
||||
$audio = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
|
||||
$title = wp_strip_all_tags(
|
||||
html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
|
||||
);
|
||||
if ( '' === trim( $title ) ) {
|
||||
// translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
|
||||
$title = __( '(no title)', 'jetpack-podcast' );
|
||||
}
|
||||
|
||||
$items[] = array(
|
||||
'id' => $post->ID,
|
||||
'title' => $title,
|
||||
'status' => $post->post_status,
|
||||
'date' => mysql2date( 'c', $post->post_date_gmt, false ),
|
||||
'editUrl' => get_edit_post_link( $post->ID, 'raw' ),
|
||||
'mediaUrl' => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
|
||||
'mediaType' => 'audio',
|
||||
'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
|
||||
'duration' => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
$total = (int) $query->found_posts;
|
||||
$bootstrap['episodes'] = array(
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => 1,
|
||||
'perPage' => $per_page,
|
||||
'totalPages' => (int) ceil( $total / $per_page ),
|
||||
);
|
||||
|
||||
return $bootstrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the page chrome and the static form HTML.
|
||||
*/
|
||||
public static function render() {
|
||||
$window = window_presets();
|
||||
$length = length_presets();
|
||||
$voice = voice_presets();
|
||||
?>
|
||||
<div class="wrap jetpack-create-ai-podcast">
|
||||
<h1 class="jetpack-create-ai-podcast__page-title">
|
||||
<?php echo esc_html__( 'Create AI Podcast', 'jetpack-podcast' ); ?>
|
||||
</h1>
|
||||
|
||||
<div id="jetpack-create-ai-podcast-app">
|
||||
<section class="jetpack-create-ai-podcast__intro" role="region" aria-labelledby="jetpack-create-ai-podcast-intro-title">
|
||||
<div class="jetpack-create-ai-podcast__intro-body">
|
||||
<div class="jetpack-create-ai-podcast__intro-badges">
|
||||
<p class="jetpack-create-ai-podcast__intro-eyebrow">
|
||||
<span class="jetpack-create-ai-podcast__intro-wpmark" aria-hidden="true"></span>
|
||||
<span><?php echo esc_html__( 'WordPress.com exclusive', 'jetpack-podcast' ); ?></span>
|
||||
</p>
|
||||
<p class="jetpack-create-ai-podcast__intro-eyebrow jetpack-create-ai-podcast__intro-eyebrow--experimental">
|
||||
<span><?php echo esc_html__( 'Experimental', 'jetpack-podcast' ); ?></span>
|
||||
</p>
|
||||
</div>
|
||||
<h2 id="jetpack-create-ai-podcast-intro-title" class="jetpack-create-ai-podcast__intro-title">
|
||||
<?php echo esc_html__( 'Turn your posts into a podcast episode', 'jetpack-podcast' ); ?>
|
||||
</h2>
|
||||
<p class="jetpack-create-ai-podcast__intro-text">
|
||||
<?php echo esc_html__( 'Pick a date range or a few specific posts and we’ll generate a two-host conversation, complete with narration and a ready-to-publish draft. Edit, refine, and hit Publish when you’re happy.', 'jetpack-podcast' ); ?>
|
||||
</p>
|
||||
</div>
|
||||
<div class="jetpack-create-ai-podcast__intro-art" aria-hidden="true">
|
||||
<svg viewBox="0 0 64 64" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="jetpack-create-ai-podcast-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
|
||||
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<circle cx="32" cy="32" r="28" fill="url(#jetpack-create-ai-podcast-grad)"/>
|
||||
<path fill="#fff" d="M32 14a8 8 0 0 0-8 8v10a8 8 0 0 0 16 0V22a8 8 0 0 0-8-8zm-12 18a1.5 1.5 0 0 1 3 0 9 9 0 0 0 18 0 1.5 1.5 0 0 1 3 0 12 12 0 0 1-10.5 11.9V48h4.5v3h-12v-3H30v-2.1A12 12 0 0 1 20 32z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__credits"
|
||||
data-region="credits"
|
||||
></div>
|
||||
|
||||
<form class="jetpack-create-ai-podcast__form" data-region="form">
|
||||
<section class="jetpack-create-ai-podcast__card">
|
||||
<h2 class="jetpack-create-ai-podcast__card-title">
|
||||
<?php echo esc_html__( 'Source', 'jetpack-podcast' ); ?>
|
||||
</h2>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__radio-group" role="radiogroup">
|
||||
<label class="jetpack-create-ai-podcast__radio">
|
||||
<input type="radio" name="source" value="window" checked>
|
||||
<span><?php echo esc_html__( 'From a date range', 'jetpack-podcast' ); ?></span>
|
||||
</label>
|
||||
<label class="jetpack-create-ai-podcast__radio">
|
||||
<input type="radio" name="source" value="posts">
|
||||
<span><?php echo esc_html__( 'From specific posts', 'jetpack-podcast' ); ?></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__field" data-source="window">
|
||||
<label for="jetpack-create-ai-podcast-window">
|
||||
<?php echo esc_html__( 'Date range', 'jetpack-podcast' ); ?>
|
||||
</label>
|
||||
<select id="jetpack-create-ai-podcast-window" name="window">
|
||||
<?php foreach ( $window as $opt ) : ?>
|
||||
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__field" data-source="posts" hidden>
|
||||
<label for="jetpack-create-ai-podcast-posts-search">
|
||||
<?php echo esc_html__( 'Search posts', 'jetpack-podcast' ); ?>
|
||||
</label>
|
||||
<input
|
||||
type="search"
|
||||
id="jetpack-create-ai-podcast-posts-search"
|
||||
placeholder="<?php echo esc_attr__( 'Type to filter…', 'jetpack-podcast' ); ?>"
|
||||
>
|
||||
<p class="jetpack-create-ai-podcast__field-hint">
|
||||
<?php
|
||||
echo esc_html(
|
||||
sprintf(
|
||||
/* translators: %d: maximum number of posts that can be selected. */
|
||||
__( 'You can choose up to %d posts.', 'jetpack-podcast' ),
|
||||
self::MAX_SELECTED_POSTS
|
||||
)
|
||||
);
|
||||
?>
|
||||
</p>
|
||||
<div class="jetpack-create-ai-podcast__posts" data-region="posts"></div>
|
||||
</div>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__advanced" aria-labelledby="jetpack-create-ai-podcast-advanced-title">
|
||||
<div class="jetpack-create-ai-podcast__advanced-header">
|
||||
<h3 id="jetpack-create-ai-podcast-advanced-title" class="jetpack-create-ai-podcast__advanced-title">
|
||||
<?php echo esc_html__( 'Customize', 'jetpack-podcast' ); ?>
|
||||
</h3>
|
||||
<span class="jetpack-create-ai-podcast__soon-pill">
|
||||
<?php echo esc_html__( 'Coming soon', 'jetpack-podcast' ); ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__field">
|
||||
<label for="jetpack-create-ai-podcast-length">
|
||||
<?php echo esc_html__( 'Length', 'jetpack-podcast' ); ?>
|
||||
</label>
|
||||
<select id="jetpack-create-ai-podcast-length" name="length" disabled data-locked-disabled="true">
|
||||
<?php foreach ( $length as $opt ) : ?>
|
||||
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__field">
|
||||
<label for="jetpack-create-ai-podcast-voice">
|
||||
<?php echo esc_html__( 'Voice', 'jetpack-podcast' ); ?>
|
||||
</label>
|
||||
<select id="jetpack-create-ai-podcast-voice" name="voice" disabled data-locked-disabled="true">
|
||||
<?php foreach ( $voice as $opt ) : ?>
|
||||
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__field">
|
||||
<label for="jetpack-create-ai-podcast-prompt">
|
||||
<?php echo esc_html__( 'Prompt (optional)', 'jetpack-podcast' ); ?>
|
||||
</label>
|
||||
<textarea
|
||||
id="jetpack-create-ai-podcast-prompt"
|
||||
name="prompt"
|
||||
rows="3"
|
||||
disabled
|
||||
data-locked-disabled="true"
|
||||
placeholder="<?php echo esc_attr__( 'Steer the tone, framing, or focus of the episode…', 'jetpack-podcast' ); ?>"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__actions">
|
||||
<button type="submit" class="button button-primary button-hero">
|
||||
<?php echo esc_html__( 'Generate', 'jetpack-podcast' ); ?>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<div class="jetpack-create-ai-podcast__status" aria-live="polite" data-region="status"></div>
|
||||
|
||||
<section
|
||||
class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__episodes"
|
||||
data-region="episodes"
|
||||
aria-busy="false"
|
||||
>
|
||||
<h2 class="jetpack-create-ai-podcast__card-title">
|
||||
<?php echo esc_html__( 'Generated podcasts', 'jetpack-podcast' ); ?>
|
||||
</h2>
|
||||
<div class="jetpack-create-ai-podcast__episodes-list" data-region="episodes-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/**
|
||||
* Server-side prefill for `post-new.php?podcast_episode=1`.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
/**
|
||||
* Prefills the new-post screen with the configured podcast category and, on
|
||||
* Premium, an inserted Podcast Episode block.
|
||||
*/
|
||||
class New_Episode_Prefill {
|
||||
|
||||
const QUERY_VAR = 'podcast_episode';
|
||||
|
||||
/**
|
||||
* ID of the auto-draft we've already handled this request.
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private static $handled_post_id = 0;
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*/
|
||||
public static function init() {
|
||||
add_action( 'admin_init', array( __CLASS__, 'maybe_register_handlers' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind on `admin_init` so `$pagenow` is settled.
|
||||
*/
|
||||
public static function maybe_register_handlers() {
|
||||
global $pagenow;
|
||||
if ( 'post-new.php' !== $pagenow ) {
|
||||
return;
|
||||
}
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( ! isset( $_GET[ self::QUERY_VAR ] ) ) {
|
||||
return;
|
||||
}
|
||||
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
|
||||
if ( '1' !== sanitize_text_field( wp_unslash( $_GET[ self::QUERY_VAR ] ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( (int) get_option( 'podcasting_category_id', 0 ) <= 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action( 'wp_insert_post', array( __CLASS__, 'assign_category' ), 10, 3 );
|
||||
|
||||
if ( Podcast_Gate::has_product_access() ) {
|
||||
add_filter( 'default_content', array( __CLASS__, 'prefill_block_content' ), 10, 2 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign the configured podcast category to the new auto-draft.
|
||||
*
|
||||
* Narrowed to the initial auto-draft so user-driven saves later in the
|
||||
* session aren't re-overridden; self-unhooks so sibling auto-drafts in the
|
||||
* same request are left alone.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @param bool $update True for updates, false for inserts.
|
||||
*/
|
||||
public static function assign_category( $post_id, $post, $update ) {
|
||||
if ( $update ) {
|
||||
return;
|
||||
}
|
||||
if ( ! ( $post instanceof \WP_Post ) ) {
|
||||
return;
|
||||
}
|
||||
if ( ! self::is_supported_post( $post ) || 'auto-draft' !== $post->post_status ) {
|
||||
return;
|
||||
}
|
||||
if ( ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$category_id = (int) get_option( 'podcasting_category_id', 0 );
|
||||
if ( $category_id <= 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
wp_set_post_categories( $post_id, array( $category_id ) );
|
||||
|
||||
self::$handled_post_id = (int) $post_id;
|
||||
remove_action( 'wp_insert_post', array( __CLASS__, 'assign_category' ), 10 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a Podcast Episode block as the new post's initial content.
|
||||
*
|
||||
* No-op if another plugin has already filled `$content`, so this composes
|
||||
* politely.
|
||||
*
|
||||
* @param string $content Default post content.
|
||||
* @param \WP_Post $post Post object.
|
||||
* @return string
|
||||
*/
|
||||
public static function prefill_block_content( $content, $post ) {
|
||||
if ( ! self::is_supported_post( $post ) ) {
|
||||
return $content;
|
||||
}
|
||||
if ( self::$handled_post_id > 0 && (int) $post->ID !== self::$handled_post_id ) {
|
||||
return $content;
|
||||
}
|
||||
if ( '' !== trim( (string) $content ) ) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
remove_filter( 'default_content', array( __CLASS__, 'prefill_block_content' ), 10 );
|
||||
|
||||
return "<!-- wp:jetpack/podcast-episode /-->\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the post is a core `post` we prefill for.
|
||||
*
|
||||
* @param mixed $post Candidate post object.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_supported_post( $post ) {
|
||||
return $post instanceof \WP_Post && 'post' === $post->post_type;
|
||||
}
|
||||
}
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
/**
|
||||
* Podcast product-access gate.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Automattic\Jetpack\Current_Plan;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
use Jetpack_Options;
|
||||
|
||||
/**
|
||||
* Premium podcast feature gate.
|
||||
*
|
||||
* Resolves the paid surfaces (episode dashboard, stats, episode block) two
|
||||
* ways depending on the host:
|
||||
*
|
||||
* - WordPress.com (Simple/WoA): the `podcasting` plan feature via
|
||||
* `Current_Plan::supports`, plus the launch-day grandfather rule. Reads
|
||||
* request-scoped state, so callers gating a different blog must
|
||||
* `switch_to_blog` first.
|
||||
* - Self-hosted Jetpack: the site's purchased plan over the Jetpack
|
||||
* connection. Per PODS-123, the Growth (and Complete) plans unlock the paid
|
||||
* surfaces; everything else is feed-only.
|
||||
*/
|
||||
class Podcast_Gate {
|
||||
|
||||
const FEATURE_SLUG = 'podcasting';
|
||||
|
||||
/**
|
||||
* Launch-day cutoff for the paying-blog grandfather rule. Paid blogs
|
||||
* registered before this date keep Premium podcast features without
|
||||
* needing the `podcasting` plan feature. WordPress.com only.
|
||||
*/
|
||||
const GRANDFATHER_CUTOFF_DATE = '2026-05-18';
|
||||
|
||||
/**
|
||||
* Transient holding the cached `/upgrades` response. Short-lived (30s, set
|
||||
* below): mainly dedupes the lookup across a single page load. A buyer
|
||||
* returning from checkout busts it outright via `flush_purchases_cache()`,
|
||||
* so the TTL only bounds the unlikely case where that signal is missed.
|
||||
*/
|
||||
const PURCHASES_TRANSIENT = 'jetpack_podcast_site_purchases';
|
||||
|
||||
/**
|
||||
* Request-scoped memo of the purchases lookup (including failures, so a
|
||||
* failed fetch isn't retried mid-request). Null until first resolved.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private static $purchases_cache = null;
|
||||
|
||||
/**
|
||||
* Whether the current site can use the paid podcast surfaces.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function has_product_access(): bool {
|
||||
if ( ! ( new Host() )->is_wpcom_platform() ) {
|
||||
return self::self_hosted_has_paid_plan();
|
||||
}
|
||||
|
||||
$blog_id = get_current_blog_id();
|
||||
if ( $blog_id <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( self::is_grandfathered( $blog_id ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) Current_Plan::supports( self::FEATURE_SLUG );
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached purchases lookup so the next access check re-reads
|
||||
* `/upgrades`. Called when a buyer returns from checkout so a fresh plan
|
||||
* unlocks the paid surfaces immediately rather than after the TTL.
|
||||
*/
|
||||
public static function flush_purchases_cache(): void {
|
||||
delete_transient( self::PURCHASES_TRANSIENT );
|
||||
self::$purchases_cache = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a self-hosted Jetpack site owns a Growth (or Complete) plan.
|
||||
*
|
||||
* Mirrors the bundle-detection pattern used by My Jetpack's Growth/Security
|
||||
* products: match purchased product slugs rather than the `podcasting`
|
||||
* feature, which maps to all Jetpack sites on WordPress.com and so can't
|
||||
* distinguish free from paid here.
|
||||
*/
|
||||
private static function self_hosted_has_paid_plan(): bool {
|
||||
foreach ( self::get_site_current_purchases() as $purchase ) {
|
||||
$slug = is_array( $purchase ) && isset( $purchase['product_slug'] ) ? $purchase['product_slug'] : '';
|
||||
|
||||
// Growth and Complete bundles unlock the paid surfaces; matched as
|
||||
// prefixes so every billing term/tier counts.
|
||||
foreach ( array( 'jetpack_growth', 'jetpack_complete' ) as $prefix ) {
|
||||
if ( is_string( $slug ) && 0 === strpos( $slug, $prefix ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The site's current purchases from WordPress.com (`/upgrades`).
|
||||
*
|
||||
* Fails closed: an unreachable or malformed response returns no purchases
|
||||
* and isn't written to the transient, so the next request retries rather
|
||||
* than serving a stale empty result.
|
||||
*
|
||||
* @return array List of purchase entries (associative arrays); empty on failure.
|
||||
*/
|
||||
private static function get_site_current_purchases(): array {
|
||||
if ( null !== self::$purchases_cache ) {
|
||||
return self::$purchases_cache;
|
||||
}
|
||||
|
||||
$cached = get_transient( self::PURCHASES_TRANSIENT );
|
||||
if ( is_array( $cached ) ) {
|
||||
self::$purchases_cache = $cached;
|
||||
return self::$purchases_cache;
|
||||
}
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_blog(
|
||||
sprintf( '/upgrades?site=%d', (int) Jetpack_Options::get_option( 'id' ) ),
|
||||
'1.2',
|
||||
array( 'method' => 'GET' )
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
|
||||
self::$purchases_cache = array();
|
||||
return self::$purchases_cache;
|
||||
}
|
||||
|
||||
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
if ( ! is_array( $decoded ) ) {
|
||||
self::$purchases_cache = array();
|
||||
return self::$purchases_cache;
|
||||
}
|
||||
|
||||
// 30s: short enough that a plan change shows up quickly even if the
|
||||
// checkout-return bust is missed, long enough to dedupe a page load.
|
||||
set_transient( self::PURCHASES_TRANSIENT, $decoded, 30 );
|
||||
self::$purchases_cache = $decoded;
|
||||
return self::$purchases_cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the blog is grandfathered: registered before the cutoff AND on a paid plan.
|
||||
*
|
||||
* @param int $blog_id Blog ID.
|
||||
*/
|
||||
protected static function is_grandfathered( int $blog_id ): bool {
|
||||
if ( ! function_exists( 'get_blog_details' ) ) {
|
||||
return false;
|
||||
}
|
||||
$details = get_blog_details( $blog_id );
|
||||
if ( ! $details || empty( $details->registered ) ) {
|
||||
return false;
|
||||
}
|
||||
$registered_ts = strtotime( $details->registered );
|
||||
if ( false === $registered_ts || $registered_ts >= strtotime( self::GRANDFATHER_CUTOFF_DATE ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$plan = Current_Plan::get();
|
||||
return ! empty( $plan['class'] ) && 'free' !== $plan['class'];
|
||||
}
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
/**
|
||||
* Main loader for the Jetpack Podcast package.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
|
||||
/**
|
||||
* Loads Jetpack Podcast on Simple and Atomic sites. The package owns the
|
||||
* podcasting experience outright.
|
||||
*/
|
||||
class Podcast {
|
||||
|
||||
const PACKAGE_VERSION = '1.3.0';
|
||||
|
||||
/**
|
||||
* Whether the class has been initialized.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $initialized = false;
|
||||
|
||||
/**
|
||||
* Initialize the package.
|
||||
*
|
||||
* Always loads on Simple and WoA. On self-hosted Jetpack it stays dormant
|
||||
* unless opted in via the `jetpack_podcast_for_the_world` filter.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( self::$initialized ) {
|
||||
return;
|
||||
}
|
||||
self::$initialized = true;
|
||||
|
||||
$host = new Host();
|
||||
|
||||
/**
|
||||
* Allow the Podcast package to load on self-hosted Jetpack sites.
|
||||
*
|
||||
* @since 1.1.1
|
||||
*
|
||||
* @param bool $enabled Whether to load the package on self-hosted. Default false.
|
||||
*/
|
||||
$for_the_world = (bool) apply_filters( 'jetpack_podcast_for_the_world', false );
|
||||
|
||||
if ( ! $host->is_wpcom_simple() && ! $host->is_woa_site() && ! $for_the_world ) {
|
||||
return;
|
||||
}
|
||||
|
||||
Podcast_Episode_Block::register_hooks();
|
||||
|
||||
Podcast_Stats_Endpoint::init();
|
||||
Podcast_Distribution_Endpoint::init();
|
||||
Podcast_Settings_Endpoint::init();
|
||||
|
||||
Settings::register();
|
||||
|
||||
// Wire the RSS feed customizations (`<itunes:*>` + `<podcast:*>` tags,
|
||||
// stats-tracked enclosure URLs) for the configured podcast category.
|
||||
Customize_Feed::init();
|
||||
|
||||
Tracks::init();
|
||||
|
||||
if ( is_admin() ) {
|
||||
Admin_Page::init();
|
||||
New_Episode_Prefill::init();
|
||||
}
|
||||
|
||||
if ( $host->is_wpcom_simple() || $host->is_woa_site() ) {
|
||||
// Register the local REST routes before request-local rollout gates.
|
||||
// Requests from public-api.wordpress.com may not satisfy those gates,
|
||||
// but the wpcom/v2 routes still need to exist so permission and
|
||||
// callback checks can handle the request.
|
||||
Posts_To_Podcast_Endpoint::init();
|
||||
|
||||
// Posts to Podcast lives behind its own filter so the Create AI
|
||||
// Podcast page can ship independently of the rest of the package.
|
||||
//
|
||||
// Note: no `is_admin()` guard here. The submenu must also register when
|
||||
// Calypso builds its nav via the `wpcom/v2/admin-menu` REST endpoint,
|
||||
// which fires `admin_menu` by loading `wp-admin/menu.php` but runs as a
|
||||
// REST request where `is_admin()` is false. The hooks `init()` wires
|
||||
// (`admin_menu`, `enqueue_block_editor_assets`) self-gate, so this is a
|
||||
// no-op on non-admin/non-editor requests.
|
||||
if ( self::is_posts_to_podcast_enabled() ) {
|
||||
Create_AI_Podcast_Page::init();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Posts to Podcast feature (Create AI Podcast page + REST
|
||||
* proxy) is enabled for the current request.
|
||||
*
|
||||
* Defaults to true for connected WordPress.com users, and can be flipped
|
||||
* globally via the `jetpack_posts_to_podcast` filter.
|
||||
*/
|
||||
public static function is_posts_to_podcast_enabled() {
|
||||
/**
|
||||
* Master switch for the Posts to Podcast (Create AI Podcast) feature.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*
|
||||
* @param bool $enabled Whether to enable Posts to Podcast.
|
||||
*/
|
||||
$enabled = self::is_user_connected( get_current_user_id() );
|
||||
|
||||
return (bool) apply_filters( 'jetpack_posts_to_podcast', $enabled );
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a user is connected to WordPress.com.
|
||||
*
|
||||
* @param int $user_id User ID.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_user_connected( $user_id ) {
|
||||
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
|
||||
}
|
||||
}
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
<?php
|
||||
/**
|
||||
* Podcast settings: option schema, sanitizers, and Jetpack Sync opt-in.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
/**
|
||||
* Registers the `podcasting_*` options with their `sanitize_callback`s and
|
||||
* `show_in_rest` so they keep appearing in core `/wp/v2/settings`. The dashboard
|
||||
* now reads and writes them through the dedicated {@see Podcast_Settings_Endpoint}
|
||||
* (`wpcom/v2/podcast/settings`); the core exposure stays for now and is removed
|
||||
* in a follow-up once WPCOM's settings-controller test is decoupled.
|
||||
*
|
||||
* Array-shaped options merge against stored values on sanitize, not replace —
|
||||
* the SPA can PATCH partial entries without losing the rest.
|
||||
*/
|
||||
class Settings {
|
||||
|
||||
/**
|
||||
* Per-podcatcher hostname allowlist for `podcasting_show_urls`. `www.` is
|
||||
* stripped before comparison.
|
||||
*
|
||||
* @var array<string, string[]>
|
||||
*/
|
||||
const SHOW_URL_HOSTS = array(
|
||||
'pocketcasts' => array( 'pca.st', 'pocketcasts.com' ),
|
||||
'apple' => array( 'podcasts.apple.com' ),
|
||||
'spotify' => array( 'open.spotify.com' ),
|
||||
'youtube' => array( 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ),
|
||||
'amazon' => array(
|
||||
'music.amazon.com',
|
||||
'music.amazon.co.uk',
|
||||
'music.amazon.de',
|
||||
'music.amazon.co.jp',
|
||||
'music.amazon.com.au',
|
||||
'music.amazon.fr',
|
||||
'music.amazon.ca',
|
||||
'music.amazon.es',
|
||||
),
|
||||
'podcastindex' => array( 'podcastindex.org' ),
|
||||
);
|
||||
|
||||
const SHOW_URL_MAX_LENGTH = 2048;
|
||||
|
||||
/**
|
||||
* Drives `register_settings()` and the sync whitelist.
|
||||
*
|
||||
* @var string[]
|
||||
*/
|
||||
const OPTION_NAMES = array(
|
||||
'podcasting_category_id',
|
||||
'podcasting_title',
|
||||
'podcasting_talent_name',
|
||||
'podcasting_summary',
|
||||
'podcasting_copyright',
|
||||
'podcasting_explicit',
|
||||
'podcasting_image',
|
||||
'podcasting_image_id',
|
||||
'podcasting_category_1',
|
||||
'podcasting_category_2',
|
||||
'podcasting_category_3',
|
||||
'podcasting_email',
|
||||
'podcasting_show_urls',
|
||||
'podcasting_show_states',
|
||||
);
|
||||
|
||||
/**
|
||||
* Whether `register()` has wired its hooks.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $registered = false;
|
||||
|
||||
/**
|
||||
* Wire option registrations + Jetpack Sync opt-in. Idempotent.
|
||||
*/
|
||||
public static function register() {
|
||||
if ( self::$registered ) {
|
||||
return;
|
||||
}
|
||||
self::$registered = true;
|
||||
|
||||
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
|
||||
add_action( 'rest_api_init', array( __CLASS__, 'register_settings' ) );
|
||||
|
||||
add_filter(
|
||||
'jetpack_sync_options_whitelist',
|
||||
static function ( $options ) {
|
||||
return array_merge( $options, self::OPTION_NAMES );
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `register_setting()` calls. Hooked on `admin_init` and `rest_api_init`.
|
||||
*/
|
||||
public static function register_settings() {
|
||||
$media_settings = array(
|
||||
array( 'podcasting_category_id', 'integer', 0, 'absint' ),
|
||||
array( 'podcasting_title', 'string', '', 'sanitize_text_field' ),
|
||||
array( 'podcasting_talent_name', 'string', '', 'sanitize_text_field' ),
|
||||
array( 'podcasting_summary', 'string', '', 'sanitize_textarea_field' ),
|
||||
array( 'podcasting_copyright', 'string', '', 'sanitize_text_field' ),
|
||||
array( 'podcasting_category_1', 'string', '', 'sanitize_text_field' ),
|
||||
array( 'podcasting_category_2', 'string', '', 'sanitize_text_field' ),
|
||||
array( 'podcasting_category_3', 'string', '', 'sanitize_text_field' ),
|
||||
);
|
||||
|
||||
// Registered under WP core's `media` group to match WPCOM's legacy Media
|
||||
// Settings form, so it keeps accepting these.
|
||||
foreach ( $media_settings as list( $name, $type, $default, $sanitize ) ) {
|
||||
register_setting(
|
||||
'media',
|
||||
$name,
|
||||
array(
|
||||
'type' => $type,
|
||||
'default' => $default,
|
||||
'sanitize_callback' => $sanitize,
|
||||
'show_in_rest' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
register_setting(
|
||||
'media',
|
||||
'podcasting_image',
|
||||
array(
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'sanitize_callback' => 'esc_url_raw',
|
||||
'show_in_rest' => array(
|
||||
'schema' => array(
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'format' => 'uri',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'media',
|
||||
'podcasting_explicit',
|
||||
array(
|
||||
'type' => 'boolean',
|
||||
'default' => false,
|
||||
'sanitize_callback' => array( __CLASS__, 'sanitize_explicit' ),
|
||||
'show_in_rest' => true,
|
||||
)
|
||||
);
|
||||
|
||||
// Registered under WP core's `options` group: REST-only settings that
|
||||
// WPCOM never wired into the Settings API.
|
||||
register_setting(
|
||||
'options',
|
||||
'podcasting_email',
|
||||
array(
|
||||
'type' => 'string',
|
||||
'default' => '',
|
||||
'sanitize_callback' => 'sanitize_email',
|
||||
'show_in_rest' => true,
|
||||
)
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'options',
|
||||
'podcasting_image_id',
|
||||
array(
|
||||
'type' => 'integer',
|
||||
'default' => 0,
|
||||
'sanitize_callback' => 'absint',
|
||||
'show_in_rest' => true,
|
||||
)
|
||||
);
|
||||
|
||||
$podcatcher_keys = array_keys( self::SHOW_URL_HOSTS );
|
||||
$empty_map = array_fill_keys( $podcatcher_keys, '' );
|
||||
|
||||
register_setting(
|
||||
'options',
|
||||
'podcasting_show_urls',
|
||||
array(
|
||||
'type' => 'object',
|
||||
'default' => array(),
|
||||
'sanitize_callback' => array( __CLASS__, 'sanitize_show_urls' ),
|
||||
'show_in_rest' => array(
|
||||
'schema' => array(
|
||||
'type' => 'object',
|
||||
'default' => $empty_map,
|
||||
'properties' => array_fill_keys(
|
||||
$podcatcher_keys,
|
||||
array(
|
||||
'type' => 'string',
|
||||
'format' => 'uri',
|
||||
'maxLength' => self::SHOW_URL_MAX_LENGTH,
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
register_setting(
|
||||
'options',
|
||||
'podcasting_show_states',
|
||||
array(
|
||||
'type' => 'object',
|
||||
'default' => array(),
|
||||
'sanitize_callback' => array( __CLASS__, 'sanitize_show_states' ),
|
||||
'show_in_rest' => array(
|
||||
'schema' => array(
|
||||
'type' => 'object',
|
||||
'default' => $empty_map,
|
||||
'properties' => array_fill_keys(
|
||||
$podcatcher_keys,
|
||||
array(
|
||||
'type' => 'string',
|
||||
'enum' => array( '', 'pending', 'active' ),
|
||||
)
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable, fully-padded settings payload for the REST endpoint. Every
|
||||
* `OPTION_NAMES` key is present; the two podcatcher maps are padded to all
|
||||
* known directories with empty strings so the SPA always sees a fixed shape.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function get_all(): array {
|
||||
$empty_map = array_fill_keys( array_keys( self::SHOW_URL_HOSTS ), '' );
|
||||
$show_urls = (array) get_option( 'podcasting_show_urls', array() );
|
||||
$show_states = (array) get_option( 'podcasting_show_states', array() );
|
||||
|
||||
return array(
|
||||
'podcasting_category_id' => (int) get_option( 'podcasting_category_id', 0 ),
|
||||
'podcasting_title' => (string) get_option( 'podcasting_title', '' ),
|
||||
'podcasting_talent_name' => (string) get_option( 'podcasting_talent_name', '' ),
|
||||
'podcasting_summary' => (string) get_option( 'podcasting_summary', '' ),
|
||||
'podcasting_copyright' => (string) get_option( 'podcasting_copyright', '' ),
|
||||
'podcasting_explicit' => self::sanitize_explicit( get_option( 'podcasting_explicit', false ) ),
|
||||
'podcasting_image' => self::raw_show_image_url(),
|
||||
'podcasting_image_id' => (int) get_option( 'podcasting_image_id', 0 ),
|
||||
'podcasting_category_1' => (string) get_option( 'podcasting_category_1', '' ),
|
||||
'podcasting_category_2' => (string) get_option( 'podcasting_category_2', '' ),
|
||||
'podcasting_category_3' => (string) get_option( 'podcasting_category_3', '' ),
|
||||
'podcasting_email' => (string) get_option( 'podcasting_email', '' ),
|
||||
'podcasting_show_urls' => array_merge( $empty_map, array_intersect_key( $show_urls, $empty_map ) ),
|
||||
'podcasting_show_states' => array_merge( $empty_map, array_intersect_key( $show_states, $empty_map ) ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-key type map for the endpoint's update args. Type coercion only — the
|
||||
* registered `sanitize_callback`s do the real validation on write, so a single
|
||||
* bad field can't 400 the whole partial patch.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
public static function rest_schema_properties(): array {
|
||||
return array(
|
||||
'podcasting_category_id' => array( 'type' => 'integer' ),
|
||||
'podcasting_title' => array( 'type' => 'string' ),
|
||||
'podcasting_talent_name' => array( 'type' => 'string' ),
|
||||
'podcasting_summary' => array( 'type' => 'string' ),
|
||||
'podcasting_copyright' => array( 'type' => 'string' ),
|
||||
'podcasting_explicit' => array( 'type' => array( 'boolean', 'string' ) ),
|
||||
'podcasting_image' => array( 'type' => 'string' ),
|
||||
'podcasting_image_id' => array( 'type' => 'integer' ),
|
||||
'podcasting_category_1' => array( 'type' => 'string' ),
|
||||
'podcasting_category_2' => array( 'type' => 'string' ),
|
||||
'podcasting_category_3' => array( 'type' => 'string' ),
|
||||
'podcasting_email' => array( 'type' => 'string' ),
|
||||
'podcasting_show_urls' => array( 'type' => 'object' ),
|
||||
'podcasting_show_states' => array( 'type' => 'object' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show cover image URL: `podcasting_image_id` resolved to its attachment
|
||||
* URL when it points at an image, otherwise the raw `podcasting_image`
|
||||
* option. Never Photon-routed — feed rendering applies its own resize.
|
||||
*
|
||||
* @return string Image URL, or '' when not configured.
|
||||
*/
|
||||
public static function raw_show_image_url(): string {
|
||||
$image_id = (int) get_option( 'podcasting_image_id', 0 );
|
||||
if ( $image_id > 0 && wp_attachment_is_image( $image_id ) ) {
|
||||
$url = wp_get_attachment_url( $image_id );
|
||||
if ( false !== $url ) {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
return (string) get_option( 'podcasting_image', '' );
|
||||
}
|
||||
|
||||
/**
|
||||
* `'yes'` (any case) or boolean true → true; everything else → false. The
|
||||
* feed only emits true/false; the legacy `'clean'` value collapses to false
|
||||
* because the WPCOM feed builder already treats it that way.
|
||||
*
|
||||
* @param mixed $value Raw input.
|
||||
* @return bool
|
||||
*/
|
||||
public static function sanitize_explicit( $value ) {
|
||||
if ( is_string( $value ) ) {
|
||||
return in_array( strtolower( $value ), array( 'yes', 'true', '1' ), true );
|
||||
}
|
||||
return true === $value || 1 === $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a partial show-URLs patch into the stored value. Empty string for a
|
||||
* known key removes that entry; URLs failing the per-podcatcher hostname
|
||||
* allowlist are silently dropped (the SPA validates the same allowlist).
|
||||
*
|
||||
* @param mixed $input Incoming patch.
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function sanitize_show_urls( $input ) {
|
||||
$current = array_filter(
|
||||
array_intersect_key( (array) get_option( 'podcasting_show_urls', array() ), self::SHOW_URL_HOSTS ),
|
||||
static function ( $value ) {
|
||||
return is_string( $value ) && '' !== $value;
|
||||
}
|
||||
);
|
||||
|
||||
if ( ! is_array( $input ) ) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
|
||||
$value = is_string( $value ) ? trim( $value ) : '';
|
||||
|
||||
if ( '' === $value ) {
|
||||
unset( $current[ $key ] );
|
||||
continue;
|
||||
}
|
||||
|
||||
$cleaned = self::sanitize_show_url( $key, $value );
|
||||
if ( null !== $cleaned ) {
|
||||
$current[ $key ] = $cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a partial show-states patch into the stored value. Values outside
|
||||
* the allowed `'pending'`/`'active'` set are dropped; empty string clears a
|
||||
* stored entry. `'active'` → `'pending'` is
|
||||
* refused so a stale SPA cache can't downgrade a state that `Feed_Detection`
|
||||
* promoted via real UA evidence (explicit `''` clears still work).
|
||||
*
|
||||
* @param mixed $input Incoming patch.
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function sanitize_show_states( $input ) {
|
||||
$current = array_filter(
|
||||
array_intersect_key( (array) get_option( 'podcasting_show_states', array() ), self::SHOW_URL_HOSTS ),
|
||||
static function ( $value ) {
|
||||
return is_string( $value ) && '' !== $value;
|
||||
}
|
||||
);
|
||||
|
||||
if ( ! is_array( $input ) ) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
|
||||
$value = is_string( $value ) ? trim( $value ) : '';
|
||||
|
||||
if ( '' === $value ) {
|
||||
unset( $current[ $key ] );
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! in_array( $value, array( 'pending', 'active' ), true ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( 'pending' === $value && isset( $current[ $key ] ) && 'active' === $current[ $key ] ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$current[ $key ] = $value;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a URL against the per-podcatcher hostname allowlist.
|
||||
*
|
||||
* @param string $key Podcatcher key.
|
||||
* @param string $url Candidate URL.
|
||||
* @return string|null Cleaned URL, or null if the host isn't in the allowlist.
|
||||
*/
|
||||
private static function sanitize_show_url( $key, $url ) {
|
||||
if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( ! is_string( $url ) || strlen( $url ) > self::SHOW_URL_MAX_LENGTH ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleaned = esc_url_raw( $url, array( 'https' ) );
|
||||
if ( '' === $cleaned ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ( ! wp_http_validate_url( $cleaned ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = wp_parse_url( $cleaned, PHP_URL_HOST );
|
||||
if ( ! is_string( $host ) || '' === $host ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$host = strtolower( $host );
|
||||
if ( 0 === strpos( $host, 'www.' ) ) {
|
||||
$host = substr( $host, 4 );
|
||||
}
|
||||
|
||||
return in_array( $host, self::SHOW_URL_HOSTS[ $key ], true ) ? $cleaned : null;
|
||||
}
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
/**
|
||||
* Tracks instrumentation for Jetpack Podcast.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
|
||||
use Throwable;
|
||||
use WP_Post;
|
||||
use WP_Query;
|
||||
use WP_User;
|
||||
|
||||
/**
|
||||
* Records podcast lifecycle events. Event names stay `wpcom_*` so analytics
|
||||
* queries cover Simple, Atomic, and self-hosted Jetpack feeds without a rewrite.
|
||||
* Dispatch tries `tracks_record_event` (Simple) and falls back to
|
||||
* `\Automattic\Jetpack\Tracking::tracks_record_event` (Atomic). Neither is a
|
||||
* hard dep — silently no-ops when neither is reachable.
|
||||
*/
|
||||
class Tracks {
|
||||
|
||||
/**
|
||||
* Wire the recorder hooks.
|
||||
*/
|
||||
public static function init(): void {
|
||||
// `wp_after_insert_post` runs after terms + meta are saved — required
|
||||
// because Gutenberg/REST publishes set terms after `transition_post_status`.
|
||||
add_action( 'wp_after_insert_post', array( __CLASS__, 'record_episode_published' ), 10, 4 );
|
||||
|
||||
add_action( 'add_attachment', array( __CLASS__, 'record_media_uploaded' ) );
|
||||
|
||||
add_action( 'add_option_podcasting_category_id', array( __CLASS__, 'record_category_added' ), 10, 2 );
|
||||
add_action( 'update_option_podcasting_category_id', array( __CLASS__, 'record_category_updated' ), 10, 3 );
|
||||
|
||||
add_action( 'add_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_added' ), 10, 2 );
|
||||
add_action( 'update_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_updated' ), 10, 3 );
|
||||
|
||||
add_action( 'jetpack_podcast_settings_saved', array( __CLASS__, 'record_settings_saved' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `wpcom_podcast_episode_published` (and `wpcom_podcast_show_launched`
|
||||
* once per site) when a podcast-category post enters `publish`.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param WP_Post|null $post Post object.
|
||||
* @param bool $update Whether this is an update.
|
||||
* @param WP_Post|null $post_before Previous post state.
|
||||
*/
|
||||
public static function record_episode_published( $post_id, $post, $update, $post_before ): void {
|
||||
unset( $post_id, $update );
|
||||
|
||||
try {
|
||||
if ( ! $post instanceof WP_Post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 'publish' !== $post->post_status ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
|
||||
if ( function_exists( 'is_headstart_post' ) && is_headstart_post( $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( in_array( $post->post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$category_id = Customize_Feed::resolve_category_id();
|
||||
if ( 0 === $category_id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! in_category( $category_id, $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Match the RSS feed's definition of an episode — must carry
|
||||
// audio, not just sit in the podcast category.
|
||||
if ( ! self::has_podcast_media( $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$is_first = self::is_first_episode_for_site( $category_id, (int) $post->ID );
|
||||
|
||||
self::record_event(
|
||||
'wpcom_podcast_episode_published',
|
||||
array(
|
||||
'post_id' => (int) $post->ID,
|
||||
'is_first_episode_for_site' => $is_first,
|
||||
),
|
||||
self::identity_for_post( $post )
|
||||
);
|
||||
|
||||
// Atomic INSERT — only one concurrent caller per site wins, so
|
||||
// `show_launched` fires exactly once per site.
|
||||
if ( $is_first && add_option( 'podcast_show_launched_tracked', time(), '', false ) ) {
|
||||
self::record_event(
|
||||
'wpcom_podcast_show_launched',
|
||||
array( 'post_id' => (int) $post->ID ),
|
||||
self::identity_for_post( $post )
|
||||
);
|
||||
}
|
||||
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// Tracks is best-effort — never break a publish.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `wpcom_podcast_media_uploaded` for audio/video attachments on a
|
||||
* podcasting-enabled site.
|
||||
*
|
||||
* @param int $attachment_id Attachment post ID.
|
||||
*/
|
||||
public static function record_media_uploaded( $attachment_id ): void {
|
||||
try {
|
||||
if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attachment = get_post( (int) $attachment_id );
|
||||
// @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
|
||||
if ( $attachment && function_exists( 'is_headstart_post' ) && is_headstart_post( $attachment ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 0 === Customize_Feed::resolve_category_id() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mime_type = (string) get_post_mime_type( (int) $attachment_id );
|
||||
if ( '' === $mime_type ) {
|
||||
return;
|
||||
}
|
||||
if ( 0 !== strpos( $mime_type, 'audio/' ) && 0 !== strpos( $mime_type, 'video/' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::record_event(
|
||||
'wpcom_podcast_media_uploaded',
|
||||
array(
|
||||
'attachment_id' => (int) $attachment_id,
|
||||
'mime_type' => $mime_type,
|
||||
)
|
||||
);
|
||||
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// Tracks is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `add_option_podcasting_category_id` callback — first-ever write of the
|
||||
* option (previous value treated as 0).
|
||||
*
|
||||
* @param string $option Option name.
|
||||
* @param mixed $value Newly stored value.
|
||||
*/
|
||||
public static function record_category_added( $option, $value ): void {
|
||||
unset( $option );
|
||||
|
||||
try {
|
||||
self::maybe_record_status_change( 0, (int) $value );
|
||||
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// Tracks is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_option_podcasting_category_id` callback — every change to an
|
||||
* existing row.
|
||||
*
|
||||
* @param mixed $old_value Previous stored value.
|
||||
* @param mixed $value Newly stored value.
|
||||
* @param string $option Option name.
|
||||
*/
|
||||
public static function record_category_updated( $old_value, $value, $option ): void {
|
||||
unset( $option );
|
||||
|
||||
try {
|
||||
self::maybe_record_status_change( (int) $old_value, (int) $value );
|
||||
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// Tracks is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `add_option_podcasting_show_urls` callback. No prior row exists, so
|
||||
* every entry is a first-time entry.
|
||||
*
|
||||
* @param string $option Option name.
|
||||
* @param mixed $new_value Newly stored value.
|
||||
*/
|
||||
public static function record_show_url_added( $option, $new_value ): void {
|
||||
unset( $option );
|
||||
self::maybe_record_show_url_addition( array(), $new_value );
|
||||
}
|
||||
|
||||
/**
|
||||
* `update_option_podcasting_show_urls` callback. Compare the new value
|
||||
* against the prior array to find the first directory that transitioned
|
||||
* from absent/empty to a non-empty URL.
|
||||
*
|
||||
* @param mixed $old_value Previous stored value (expected: array).
|
||||
* @param mixed $new_value Newly stored value.
|
||||
* @param string $option Option name.
|
||||
*/
|
||||
public static function record_show_url_updated( $old_value, $new_value, $option ): void {
|
||||
unset( $option );
|
||||
self::maybe_record_show_url_addition( is_array( $old_value ) ? $old_value : array(), $new_value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `wpcom_podcasting_show_url_saved` for the first podcatcher key
|
||||
* that transitions from absent/empty to a non-empty string.
|
||||
*
|
||||
* @param array $old_value Previous map of directory => url.
|
||||
* @param mixed $new_value Newly stored value.
|
||||
*/
|
||||
private static function maybe_record_show_url_addition( array $old_value, $new_value ): void {
|
||||
try {
|
||||
if ( ! is_array( $new_value ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $new_value as $app => $url ) {
|
||||
if ( ! is_string( $url ) || '' === $url ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$previous = isset( $old_value[ $app ] ) && is_string( $old_value[ $app ] ) ? $old_value[ $app ] : '';
|
||||
if ( '' !== $previous ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
self::record_event(
|
||||
'wpcom_podcasting_show_url_saved',
|
||||
array( 'app' => (string) $app )
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// Tracks is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `wpcom_podcasting_settings_saved` after a podcast settings write.
|
||||
*
|
||||
* Fired off the `jetpack_podcast_settings_saved` action that
|
||||
* {@see Podcast_Settings_Endpoint::update_item()} triggers, so it's agnostic
|
||||
* to the REST transport — the endpoint already gates on a saved option.
|
||||
*/
|
||||
public static function record_settings_saved(): void {
|
||||
try {
|
||||
// Skip user-supplied free-text fields — keep PII out of tracks.
|
||||
$pii = array( 'podcasting_email', 'podcasting_talent_name' );
|
||||
$state = array();
|
||||
foreach ( Settings::OPTION_NAMES as $name ) {
|
||||
if ( in_array( $name, $pii, true ) ) {
|
||||
continue;
|
||||
}
|
||||
$state[ $name ] = get_option( $name, '' );
|
||||
}
|
||||
self::record_event( 'wpcom_podcasting_settings_saved', $state );
|
||||
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// Tracks is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `wpcom_podcasting_status_changed` (enabled / disabled / changed)
|
||||
* when the `podcasting_category_id` option transitions.
|
||||
*
|
||||
* @param int $old_value Previous category ID (0 == disabled).
|
||||
* @param int $new_value New category ID (0 == disabled).
|
||||
*/
|
||||
private static function maybe_record_status_change( int $old_value, int $new_value ): void {
|
||||
if ( $old_value === $new_value ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( 0 === $old_value && 0 !== $new_value ) {
|
||||
$status = 'enabled';
|
||||
} elseif ( 0 !== $old_value && 0 === $new_value ) {
|
||||
$status = 'disabled';
|
||||
} else {
|
||||
$status = 'changed';
|
||||
}
|
||||
|
||||
// `WPCOM_Store_API` on Simple, `Current_Plan` on Atomic — same dual
|
||||
// pattern as `Masterbar\Dashboard_Switcher_Tracking::get_plan()`.
|
||||
$plan = class_exists( '\WPCOM_Store_API' )
|
||||
? \WPCOM_Store_API::get_current_plan( (int) get_current_blog_id() )
|
||||
: ( class_exists( '\Automattic\Jetpack\Current_Plan' ) ? \Automattic\Jetpack\Current_Plan::get() : array() );
|
||||
|
||||
self::record_event(
|
||||
'wpcom_podcasting_status_changed',
|
||||
array(
|
||||
'status' => $status,
|
||||
'surface' => 'option_write',
|
||||
'previous_category_id' => $old_value,
|
||||
'new_category_id' => $new_value,
|
||||
'user_id' => (int) get_current_user_id(),
|
||||
'product_slug' => (string) ( $plan['product_slug'] ?? '' ),
|
||||
)
|
||||
);
|
||||
|
||||
/** This action is documented in projects/packages/forms/src/contact-form/class-util.php */
|
||||
do_action( 'jetpack_bump_stats_extras', 'wpcom-podcasting-status', $status );
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity for the publish event. Scheduled/cron publishes have no
|
||||
* logged-in user — fall back to the post author.
|
||||
*
|
||||
* @param WP_Post $post Post being published.
|
||||
*/
|
||||
private static function identity_for_post( WP_Post $post ): WP_User {
|
||||
if ( ! empty( $post->post_author ) ) {
|
||||
$user = get_userdata( (int) $post->post_author );
|
||||
if ( $user instanceof WP_User ) {
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
return wp_get_current_user();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out posts in the podcast category that aren't actually episodes.
|
||||
* `core/audio` block + classic-editor attached audio cover the supported
|
||||
* authoring paths.
|
||||
*
|
||||
* @param WP_Post $post Post being checked.
|
||||
*/
|
||||
private static function has_podcast_media( WP_Post $post ): bool {
|
||||
return has_block( 'core/audio', $post )
|
||||
|| ! empty( get_attached_media( 'audio', $post->ID ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* True when no other published post exists in the podcast category.
|
||||
*
|
||||
* @param int $category_id Configured podcast category ID.
|
||||
* @param int $current_post_id Post being published (excluded from the check).
|
||||
*/
|
||||
private static function is_first_episode_for_site( int $category_id, int $current_post_id ): bool {
|
||||
$existing = new WP_Query(
|
||||
array(
|
||||
'post_status' => 'publish',
|
||||
'post_type' => 'post',
|
||||
'cat' => $category_id,
|
||||
'post__not_in' => array( $current_post_id ),
|
||||
'posts_per_page' => 1,
|
||||
'fields' => 'ids',
|
||||
'no_found_rows' => true,
|
||||
'suppress_filters' => true,
|
||||
)
|
||||
);
|
||||
|
||||
return empty( $existing->posts );
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a tracks event. Auto-injects `blog_id` and defaults `$user`
|
||||
* to the current user.
|
||||
*
|
||||
* @param string $event_name Tracks event name.
|
||||
* @param array $properties Event properties.
|
||||
* @param WP_User|null $user Identity override; defaults to current user.
|
||||
* @return mixed
|
||||
*/
|
||||
private static function record_event( string $event_name, array $properties, ?WP_User $user = null ) {
|
||||
try {
|
||||
$user = $user ?? wp_get_current_user();
|
||||
$properties['blog_id'] = (int) Connection_Manager::get_site_id( true );
|
||||
|
||||
if ( ! function_exists( 'tracks_record_event' ) && function_exists( 'require_lib' ) ) {
|
||||
require_lib( 'tracks/client' );
|
||||
}
|
||||
|
||||
if ( function_exists( 'tracks_record_event' ) ) {
|
||||
return tracks_record_event( $user, $event_name, $properties );
|
||||
}
|
||||
|
||||
if ( class_exists( '\Automattic\Jetpack\Tracking' ) ) {
|
||||
return ( new \Automattic\Jetpack\Tracking() )->tracks_record_event( $user, $event_name, $properties );
|
||||
}
|
||||
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
|
||||
// Tracks is best-effort.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
/**
|
||||
* Local Jetpack-side REST proxy for podcast distribution submissions.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Jetpack_Options;
|
||||
use WP_Error;
|
||||
use WP_REST_Controller;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
|
||||
/**
|
||||
* Forwards podcast distribution `wp.apiFetch` calls from the dashboard SPA to
|
||||
* the wpcom-side podcast-distribution endpoints. Same rationale as
|
||||
* Podcast_Stats_Endpoint: wpcom-proxy-request can't authenticate from Atomic
|
||||
* origins, so we proxy server-to-server with the user's token.
|
||||
*/
|
||||
class Podcast_Distribution_Endpoint extends WP_REST_Controller {
|
||||
|
||||
use Relay_Response;
|
||||
|
||||
/**
|
||||
* Wire up routes.
|
||||
*/
|
||||
public static function init() {
|
||||
add_action( 'rest_api_init', array( self::class, 'register' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the REST routes on the `rest_api_init` hook.
|
||||
*
|
||||
* Instantiated here, rather than eagerly, so the endpoint class only loads
|
||||
* on requests that reach `rest_api_init`. Static so the callback can be
|
||||
* unregistered.
|
||||
*/
|
||||
public static function register() {
|
||||
( new self() )->register_routes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Pocket Casts submit proxy route.
|
||||
*/
|
||||
public function register_routes() {
|
||||
$this->namespace = 'wpcom/v2';
|
||||
$this->rest_base = 'podcast-distribution';
|
||||
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base . '/pocket-casts/submit',
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'submit_pocket_casts' ),
|
||||
'permission_callback' => array( $this, 'permission_check' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback. Submitting a feed mutates the site's distribution
|
||||
* state, so require the same edit permission the dashboard SPA itself uses.
|
||||
*
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function permission_check() {
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
return new WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'Sorry, you are not allowed to submit podcasts for this site.', 'jetpack-podcast' ),
|
||||
array( 'status' => rest_authorization_required_code() )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /wpcom/v2/podcast-distribution/pocket-casts/submit — forward to wpcom.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function submit_pocket_casts() {
|
||||
$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-podcast' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_user(
|
||||
sprintf( '/sites/%d/podcast-distribution/pocket-casts/submit', $blog_id ),
|
||||
'2',
|
||||
array(
|
||||
// Pocket Casts relay can be slow; keep a generous timeout.
|
||||
'method' => 'POST',
|
||||
'headers' => array( 'content-type' => 'application/json' ),
|
||||
'timeout' => 30,
|
||||
),
|
||||
null,
|
||||
'wpcom'
|
||||
);
|
||||
|
||||
return $this->relay_response( $response );
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
/**
|
||||
* Dedicated REST endpoint for `podcasting_*` site settings.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use WP_Error;
|
||||
use WP_REST_Controller;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
|
||||
/**
|
||||
* Reads and writes the `podcasting_*` options for the dashboard SPA over its own
|
||||
* `wpcom/v2/podcast/settings` route. Schema and sanitizers live in {@see Settings}.
|
||||
*
|
||||
* Registered through the WPCOM REST API v2 plugin framework
|
||||
* ({@see wpcom_rest_api_v2_load_plugin()}), so a single definition is reachable on
|
||||
* Simple and WoA via the `public-api.wordpress.com` proxy and, once Podcast ships in
|
||||
* the Jetpack plugin, same-origin on self-hosted sites — no per-platform relay.
|
||||
*/
|
||||
class Podcast_Settings_Endpoint extends WP_REST_Controller {
|
||||
|
||||
/**
|
||||
* Wire the routes onto `rest_api_init`. The framework instantiates this once.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->namespace = 'wpcom/v2';
|
||||
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the endpoint through the WPCOM REST API v2 framework. The loader
|
||||
* ships with the Jetpack plugin core-api, present in every context Podcast runs
|
||||
* in (Simple/WoA today, the Jetpack plugin once Podcast moves there); guarded so
|
||||
* the package no-ops rather than fatals if it's somehow absent.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) {
|
||||
wpcom_rest_api_v2_load_plugin( self::class );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the GET (full record) + writable (partial patch) routes.
|
||||
*
|
||||
* Update args only coerce top-level types — the registered `sanitize_callback`s
|
||||
* do the real validation on write, so one bad field can't 400 the whole patch.
|
||||
*/
|
||||
public function register_routes() {
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
'podcast/settings',
|
||||
array(
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'get_item' ),
|
||||
'permission_callback' => array( $this, 'permission_check' ),
|
||||
),
|
||||
array(
|
||||
'methods' => WP_REST_Server::EDITABLE,
|
||||
'callback' => array( $this, 'update_item' ),
|
||||
'permission_callback' => array( $this, 'permission_check' ),
|
||||
'args' => Settings::rest_schema_properties(),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Site admins only — same gate as the wp-admin Podcast dashboard.
|
||||
*
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function permission_check() {
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
return new WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'Sorry, you are not allowed to manage podcast settings for this site.', 'jetpack-podcast' ),
|
||||
array( 'status' => rest_authorization_required_code() )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET — the full, padded settings record.
|
||||
*
|
||||
* @param WP_REST_Request $request Unused.
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function get_item( $request ) {
|
||||
unset( $request );
|
||||
return rest_ensure_response( Settings::get_all() );
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT/POST/PATCH — apply a partial patch and return the full merged record.
|
||||
*
|
||||
* Only keys actually present in the request are written, so absent keys can
|
||||
* never clobber stored values. Array-shaped options merge on sanitize.
|
||||
*
|
||||
* @param WP_REST_Request $request Incoming request.
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function update_item( $request ) {
|
||||
$saved = false;
|
||||
|
||||
foreach ( Settings::OPTION_NAMES as $name ) {
|
||||
$value = $request->get_param( $name );
|
||||
if ( null === $value ) {
|
||||
continue;
|
||||
}
|
||||
if ( update_option( $name, $value ) ) {
|
||||
$saved = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $saved ) {
|
||||
/**
|
||||
* Fires after a podcast settings write changes at least one option.
|
||||
*
|
||||
* @since 1.1.1
|
||||
*/
|
||||
do_action( 'jetpack_podcast_settings_saved' );
|
||||
}
|
||||
|
||||
return rest_ensure_response( Settings::get_all() );
|
||||
}
|
||||
}
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
<?php
|
||||
/**
|
||||
* Local Jetpack-side REST proxy for podcast stats.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Jetpack_Options;
|
||||
use WP_Error;
|
||||
use WP_REST_Controller;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
|
||||
/**
|
||||
* Forwards podcast stats `wp.apiFetch` calls from the dashboard SPA to the
|
||||
* wpcom-side podcast-stats endpoints. Browser-side wpcom-proxy-request only
|
||||
* authenticates from wpcom origins, so Atomic admin (which is the whole point
|
||||
* of this proxy) needs the request to go server-to-server with the blog
|
||||
* token. Simple sites pass through `Client::wpcom_json_api_request_as_user`
|
||||
* via the in-process IS_WPCOM short-circuit, so the round-trip cost is negligible.
|
||||
*/
|
||||
class Podcast_Stats_Endpoint extends WP_REST_Controller {
|
||||
|
||||
use Relay_Response;
|
||||
|
||||
/**
|
||||
* Wire up routes.
|
||||
*/
|
||||
public static function init() {
|
||||
add_action( 'rest_api_init', array( self::class, 'register' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the REST routes on the `rest_api_init` hook.
|
||||
*
|
||||
* Instantiated here, rather than eagerly, so the endpoint class only loads
|
||||
* on requests that reach `rest_api_init`. Static so the callback can be
|
||||
* unregistered.
|
||||
*/
|
||||
public static function register() {
|
||||
( new self() )->register_routes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the four stats proxy routes the dashboard SPA consumes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
$this->namespace = 'wpcom/v2';
|
||||
$this->rest_base = 'podcast-stats';
|
||||
|
||||
// Period summary (top apps/countries/episodes for a date range).
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base,
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'read_summary' ),
|
||||
'permission_callback' => array( $this, 'permission_check' ),
|
||||
'args' => array(
|
||||
'from' => array(
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
),
|
||||
'to' => array(
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
),
|
||||
'limit' => array(
|
||||
'type' => 'integer',
|
||||
'required' => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Period-independent overview (all-time totals, preset windows, top day).
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base . '/overview',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'read_overview' ),
|
||||
'permission_callback' => array( $this, 'permission_check' ),
|
||||
'args' => array(
|
||||
'limit' => array(
|
||||
'type' => 'integer',
|
||||
'required' => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Plays + duration for a batch of episode post IDs.
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base . '/episode-totals',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'read_episode_totals' ),
|
||||
'permission_callback' => array( $this, 'permission_check' ),
|
||||
'args' => array(
|
||||
'post_ids' => array(
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
// Per-episode detail stats for a date range.
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base . '/episode/(?P<post_id>\d+)',
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'read_episode_detail' ),
|
||||
'permission_callback' => array( $this, 'permission_check' ),
|
||||
'args' => array(
|
||||
'post_id' => array(
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
),
|
||||
'from' => array(
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
),
|
||||
'to' => array(
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback. Stats are surfaced in the dashboard SPA which is
|
||||
* limited to users who can edit the site's posts.
|
||||
*
|
||||
* @return true|WP_Error
|
||||
*/
|
||||
public function permission_check() {
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
return new WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'Sorry, you are not allowed to view podcast stats for this site.', 'jetpack-podcast' ),
|
||||
array( 'status' => rest_authorization_required_code() )
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wpcom/v2/podcast-stats — period summary.
|
||||
*
|
||||
* @param WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function read_summary( WP_REST_Request $request ) {
|
||||
return $this->forward(
|
||||
'podcast-stats',
|
||||
array(
|
||||
'from' => $request->get_param( 'from' ),
|
||||
'to' => $request->get_param( 'to' ),
|
||||
'limit' => $request->get_param( 'limit' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wpcom/v2/podcast-stats/overview — period-independent overview.
|
||||
*
|
||||
* @param WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function read_overview( WP_REST_Request $request ) {
|
||||
return $this->forward(
|
||||
'podcast-stats/overview',
|
||||
array(
|
||||
'limit' => $request->get_param( 'limit' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wpcom/v2/podcast-stats/episode-totals — plays + duration per episode.
|
||||
*
|
||||
* @param WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function read_episode_totals( WP_REST_Request $request ) {
|
||||
return $this->forward(
|
||||
'podcast-stats/episode-totals',
|
||||
array(
|
||||
'post_ids' => $request->get_param( 'post_ids' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /wpcom/v2/podcast-stats/episode/{post_id} — per-episode detail.
|
||||
*
|
||||
* @param WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function read_episode_detail( WP_REST_Request $request ) {
|
||||
$post_id = (int) $request['post_id'];
|
||||
|
||||
return $this->forward(
|
||||
sprintf( 'podcast-stats/episode/%d', $post_id ),
|
||||
array(
|
||||
'from' => $request->get_param( 'from' ),
|
||||
'to' => $request->get_param( 'to' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward a GET to `public-api.wordpress.com/wpcom/v2/sites/{blog_id}/{sub_path}`
|
||||
* as the current user, dropping query args whose value is null/''.
|
||||
*
|
||||
* @param string $sub_path Sub-path under `/sites/{blog_id}/` (no leading slash).
|
||||
* @param array $query Query args to append.
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
private function forward( $sub_path, $query ) {
|
||||
$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-podcast' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
$query = array_filter(
|
||||
$query,
|
||||
static function ( $value ) {
|
||||
return null !== $value && '' !== $value;
|
||||
}
|
||||
);
|
||||
|
||||
$path = sprintf( '/sites/%d/%s', $blog_id, $sub_path );
|
||||
if ( ! empty( $query ) ) {
|
||||
$path = add_query_arg( $query, $path );
|
||||
}
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_user(
|
||||
$path,
|
||||
'2',
|
||||
array(
|
||||
'method' => 'GET',
|
||||
'headers' => array( 'content-type' => 'application/json' ),
|
||||
'timeout' => 15,
|
||||
),
|
||||
null,
|
||||
'wpcom'
|
||||
);
|
||||
|
||||
return $this->relay_response( $response );
|
||||
}
|
||||
}
|
||||
+366
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
/**
|
||||
* Local Jetpack-side REST endpoint for the Posts to Podcast feature.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use Automattic\Jetpack\Connection\Client;
|
||||
use Jetpack_Options;
|
||||
use WP_Error;
|
||||
use WP_REST_Controller;
|
||||
use WP_REST_Request;
|
||||
use WP_REST_Response;
|
||||
use WP_REST_Server;
|
||||
|
||||
/**
|
||||
* Forwards `wp.apiFetch` calls from the wp-admin Create tab to the wpcom-side
|
||||
* endpoint as the current user (the upstream endpoint requires user identity).
|
||||
*/
|
||||
class Posts_To_Podcast_Endpoint extends WP_REST_Controller {
|
||||
|
||||
use Relay_Response;
|
||||
|
||||
const SUPPORTED_LENGTHS = array( 'short', 'medium', 'long' );
|
||||
const SUPPORTED_VOICE_PRESETS = array( 'witty', 'earnest', 'professional' );
|
||||
const REST_NAMESPACE = 'wpcom/v2';
|
||||
const REST_BASE = 'posts-to-podcast';
|
||||
const POST_PUBLISH_PROMO_DISMISS_REST_ROUTE = 'post-publish-promo/dismiss';
|
||||
|
||||
/**
|
||||
* Whether `init()` has wired its hooks.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $initialized = false;
|
||||
|
||||
/**
|
||||
* Wire up routes. Idempotent.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( self::$initialized ) {
|
||||
return;
|
||||
}
|
||||
self::$initialized = true;
|
||||
|
||||
$instance = new self();
|
||||
add_action( 'rest_api_init', array( $instance, 'register_routes' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the REST API path used by apiFetch for post-publish promo dismissal.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_post_publish_promo_dismiss_rest_path() {
|
||||
return '/' . self::REST_NAMESPACE . '/' . self::REST_BASE . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register feature info, enqueue, job-status, and promo dismissal routes.
|
||||
*/
|
||||
public function register_routes() {
|
||||
$this->namespace = self::REST_NAMESPACE;
|
||||
$this->rest_base = self::REST_BASE;
|
||||
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base,
|
||||
array(
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'read_feature_info' ),
|
||||
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
|
||||
),
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'enqueue_generation' ),
|
||||
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
|
||||
'args' => array(
|
||||
'window' => array(
|
||||
'type' => 'object',
|
||||
'required' => false,
|
||||
'description' => __( 'Either { unit: days|weeks|months, n: <positive int> } or { from, to } as ISO-8601 dates. Required when postIds is omitted.', 'jetpack-podcast' ),
|
||||
),
|
||||
'postIds' => array(
|
||||
'type' => 'array',
|
||||
'required' => false,
|
||||
'items' => array( 'type' => 'integer' ),
|
||||
'maxItems' => 25,
|
||||
'description' => __( 'Explicit list of published post IDs to draw from (up to 25). Required when window is omitted.', 'jetpack-podcast' ),
|
||||
),
|
||||
'length' => array(
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'enum' => self::SUPPORTED_LENGTHS,
|
||||
'description' => __( 'Length preset id.', 'jetpack-podcast' ),
|
||||
),
|
||||
'voicePreset' => array(
|
||||
'type' => 'string',
|
||||
'required' => true,
|
||||
'enum' => self::SUPPORTED_VOICE_PRESETS,
|
||||
'description' => __( 'Voice preset id.', 'jetpack-podcast' ),
|
||||
),
|
||||
'prompt' => array(
|
||||
'type' => 'string',
|
||||
'required' => false,
|
||||
'description' => __( 'Optional free-form instructions appended to the generation prompt.', 'jetpack-podcast' ),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base . '/jobs/(?P<job_id>\d+)',
|
||||
array(
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'read_job_status' ),
|
||||
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
|
||||
'args' => array(
|
||||
'job_id' => array(
|
||||
'type' => 'integer',
|
||||
'required' => true,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base . '/episodes',
|
||||
array(
|
||||
array(
|
||||
'methods' => WP_REST_Server::READABLE,
|
||||
'callback' => array( $this, 'read_episodes' ),
|
||||
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
|
||||
'args' => array(
|
||||
'page' => array(
|
||||
'type' => 'integer',
|
||||
'default' => 1,
|
||||
'minimum' => 1,
|
||||
),
|
||||
'per_page' => array(
|
||||
'type' => 'integer',
|
||||
'default' => 5,
|
||||
'minimum' => 1,
|
||||
'maximum' => 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
$this->namespace,
|
||||
$this->rest_base . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE,
|
||||
array(
|
||||
'methods' => WP_REST_Server::CREATABLE,
|
||||
'callback' => array( $this, 'dismiss_post_publish_promo' ),
|
||||
'permission_callback' => function () {
|
||||
return current_user_can( 'edit_posts' );
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return posts that embed a `jetpack/podcast-episode` block — the surface
|
||||
* this feature creates on success — newest first. Drafts and published
|
||||
* posts only; trashed/auto-drafts are excluded.
|
||||
*
|
||||
* @param WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function read_episodes( WP_REST_Request $request ) {
|
||||
$page = max( 1, (int) $request->get_param( 'page' ) );
|
||||
$per_page = max( 1, min( 50, (int) $request->get_param( 'per_page' ) ) );
|
||||
|
||||
$query = new \WP_Query(
|
||||
array(
|
||||
'post_type' => 'post',
|
||||
'post_status' => array( 'draft', 'publish' ),
|
||||
'posts_per_page' => $per_page,
|
||||
'paged' => $page,
|
||||
'orderby' => 'date',
|
||||
'order' => 'DESC',
|
||||
'update_post_term_cache' => false,
|
||||
'meta_query' => array(
|
||||
array(
|
||||
'key' => 'posts_to_podcast_metadata',
|
||||
'compare' => 'EXISTS',
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$items = array();
|
||||
foreach ( $query->posts as $post ) {
|
||||
$raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
|
||||
$meta = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
|
||||
$audio = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
|
||||
$title = wp_strip_all_tags(
|
||||
html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
|
||||
);
|
||||
if ( '' === trim( $title ) ) {
|
||||
// translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
|
||||
$title = __( '(no title)', 'jetpack-podcast' );
|
||||
}
|
||||
|
||||
$items[] = array(
|
||||
'id' => $post->ID,
|
||||
'title' => $title,
|
||||
'status' => $post->post_status,
|
||||
'date' => mysql2date( 'c', $post->post_date_gmt, false ),
|
||||
'editUrl' => get_edit_post_link( $post->ID, 'raw' ),
|
||||
'mediaUrl' => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
|
||||
'mediaType' => 'audio',
|
||||
'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
|
||||
'duration' => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
|
||||
);
|
||||
}
|
||||
|
||||
$total = (int) $query->found_posts;
|
||||
$total_pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 0;
|
||||
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'perPage' => $per_page,
|
||||
'totalPages' => $total_pages,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist post-publish promo dismissal for the current user and site.
|
||||
*
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function dismiss_post_publish_promo() {
|
||||
update_user_option( get_current_user_id(), Create_AI_Podcast_Page::POST_PUBLISH_PROMO_DISMISSED_OPTION, 1 );
|
||||
|
||||
return rest_ensure_response(
|
||||
array(
|
||||
'dismissed' => true,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward GET to the wpcom-side endpoint and return feature info
|
||||
* (remaining credits, plan, supported presets).
|
||||
*
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function read_feature_info() {
|
||||
$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-podcast' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_user(
|
||||
sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
|
||||
'2',
|
||||
array(
|
||||
'method' => 'GET',
|
||||
'headers' => array( 'content-type' => 'application/json' ),
|
||||
'timeout' => 15,
|
||||
),
|
||||
null,
|
||||
'wpcom'
|
||||
);
|
||||
|
||||
return $this->relay_response( $response );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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-podcast' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$body_payload = array(
|
||||
'length' => $request->get_param( 'length' ),
|
||||
'voicePreset' => $request->get_param( 'voicePreset' ),
|
||||
);
|
||||
|
||||
$window = $request->get_param( 'window' );
|
||||
if ( null !== $window ) {
|
||||
$body_payload['window'] = $window;
|
||||
}
|
||||
|
||||
$post_ids = $request->get_param( 'postIds' );
|
||||
if ( is_array( $post_ids ) && ! empty( $post_ids ) ) {
|
||||
$body_payload['postIds'] = array_values( array_map( 'intval', $post_ids ) );
|
||||
}
|
||||
|
||||
$prompt = $request->get_param( 'prompt' );
|
||||
if ( is_string( $prompt ) && '' !== $prompt ) {
|
||||
$body_payload['prompt'] = $prompt;
|
||||
}
|
||||
|
||||
if ( ! isset( $body_payload['window'] ) && ! isset( $body_payload['postIds'] ) ) {
|
||||
return new WP_Error( 'missing-source', __( 'One of window or postIds is required.', 'jetpack-podcast' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_user(
|
||||
sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
|
||||
'2',
|
||||
array(
|
||||
'method' => 'POST',
|
||||
'headers' => array( 'content-type' => 'application/json' ),
|
||||
'timeout' => 30,
|
||||
),
|
||||
wp_json_encode( $body_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
|
||||
'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-podcast' ), array( 'status' => 400 ) );
|
||||
}
|
||||
|
||||
$job_id = (int) $request['job_id'];
|
||||
|
||||
$response = Client::wpcom_json_api_request_as_user(
|
||||
sprintf( '/sites/%d/posts-to-podcast/jobs/%d', $blog_id, $job_id ),
|
||||
'2',
|
||||
array(
|
||||
'method' => 'GET',
|
||||
'headers' => array( 'content-type' => 'application/json' ),
|
||||
'timeout' => 15,
|
||||
),
|
||||
null,
|
||||
'wpcom'
|
||||
);
|
||||
|
||||
return $this->relay_response( $response );
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
/**
|
||||
* Helper for the Posts to Podcast feature.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
/**
|
||||
* Gating + permission helpers for the Posts to Podcast REST endpoint.
|
||||
*/
|
||||
class Posts_To_Podcast_Helper {
|
||||
|
||||
/**
|
||||
* Whether the Posts to Podcast feature is active for the current request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
/**
|
||||
* Filter to allow disabling the Posts to Podcast feature on a per-site basis.
|
||||
* Defaults to true wherever the podcast package is active; flip this to false
|
||||
* to hide the section during a staged rollout without disabling the package.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*
|
||||
* @param bool $enabled Whether the feature is enabled. Default true.
|
||||
*/
|
||||
return (bool) apply_filters( 'jetpack_posts_to_podcast_is_enabled', true );
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission callback for the local proxy REST endpoint.
|
||||
*
|
||||
* @param \WP_REST_Request $request Full details about the request.
|
||||
*
|
||||
* @return true|\WP_Error
|
||||
*/
|
||||
public static function get_status_permission_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||||
if ( ! current_user_can( 'edit_posts' ) ) {
|
||||
return new \WP_Error(
|
||||
'rest_forbidden',
|
||||
__( 'Sorry, you are not allowed to use this feature on this site.', 'jetpack-podcast' ),
|
||||
array( 'status' => rest_authorization_required_code() )
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
/**
|
||||
* Shared helper for the podcast REST proxy endpoints: relays a
|
||||
* `Connection\Client` response back to the local REST client.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack\Podcast;
|
||||
|
||||
use WP_Error;
|
||||
use WP_REST_Response;
|
||||
|
||||
/**
|
||||
* Used by the wpcom/v2 proxy controllers (Podcast_Stats_Endpoint,
|
||||
* Podcast_Distribution_Endpoint, Posts_To_Podcast_Endpoint) to round-trip the
|
||||
* upstream wpcom response: preserves status code and decodes the JSON body so
|
||||
* `apiFetch` on the client surfaces 4xx/5xx errors with their `{code, message}`
|
||||
* payloads intact.
|
||||
*/
|
||||
trait Relay_Response {
|
||||
|
||||
/**
|
||||
* Relay an upstream Connection\Client response back to the local REST client.
|
||||
* Preserves the upstream HTTP status code so 4xx/5xx mappings flow through.
|
||||
*
|
||||
* @param array|WP_Error $response The raw response from Connection\Client.
|
||||
*
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
+489
@@ -0,0 +1,489 @@
|
||||
<?php
|
||||
/**
|
||||
* Adds podcast tags + tracked enclosure URLs to the RSS feed for the
|
||||
* configured podcast category.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\Jetpack\Podcast\Feed;
|
||||
|
||||
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
|
||||
use Automattic\Jetpack\Podcast\Settings;
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* Hooks into RSS2 rendering when the current request is the podcast category
|
||||
* feed, adding `<itunes:*>` + `<podcast:*>` tags at channel and item level
|
||||
* and rewriting `<enclosure>` URLs through the WPCOM stats endpoint.
|
||||
*/
|
||||
class Customize_Feed {
|
||||
|
||||
/**
|
||||
* Whether `init()` has wired its hooks.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private static $registered = false;
|
||||
|
||||
/**
|
||||
* Wire the late-binding `wp` action that decides whether to register the
|
||||
* feed-modification hooks for this request. Idempotent.
|
||||
*/
|
||||
public static function init() {
|
||||
if ( self::$registered ) {
|
||||
return;
|
||||
}
|
||||
self::$registered = true;
|
||||
|
||||
add_action( 'wp', array( __CLASS__, 'maybe_register_feed_hooks' ) );
|
||||
|
||||
// `the_posts` fires during query execution — before the `wp` action —
|
||||
// so it has to be registered up-front and self-gated to the podcast
|
||||
// feed query, rather than wired conditionally in `maybe_register_feed_hooks`.
|
||||
add_filter( 'the_posts', array( __CLASS__, 'filter_posts_with_enclosure' ), 10, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the RSS2 hooks if this request is the configured podcast feed.
|
||||
* Also fires `Feed_Detection` while we're here — same gating, no need to
|
||||
* walk the post query twice.
|
||||
*/
|
||||
public static function maybe_register_feed_hooks() {
|
||||
if ( ! is_feed() ) {
|
||||
return;
|
||||
}
|
||||
$category_id = self::resolve_category_id();
|
||||
if ( 0 === $category_id || ! is_category( $category_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip channel-level tags that conflict with the iTunes-compliant
|
||||
// header: blavatar / site-icon `<image>` duplicates `<itunes:image>`,
|
||||
// and `<cloud …/>` from rsscloud isn't part of the podcast spec.
|
||||
remove_action( 'rss2_head', 'rss2_blavatar' );
|
||||
remove_action( 'rss2_head', 'rss2_site_icon' );
|
||||
remove_action( 'rss2_head', 'rsscloud_add_rss_cloud_element' );
|
||||
|
||||
add_action( 'rss2_ns', array( __CLASS__, 'output_namespaces' ) );
|
||||
add_filter( 'wp_title_rss', array( __CLASS__, 'feed_title' ) );
|
||||
add_filter( 'bloginfo_rss', array( __CLASS__, 'feed_description' ), 10, 2 );
|
||||
add_action( 'rss2_head', array( __CLASS__, 'output_channel_tags' ) );
|
||||
add_action( 'rss2_item', array( __CLASS__, 'output_item_tags' ) );
|
||||
add_filter( 'rss_enclosure', array( __CLASS__, 'rewrite_enclosure' ) );
|
||||
|
||||
// Prune RSS chrome that podcatchers don't read. Cuts payload size and
|
||||
// keeps incidental post data (body content, gravatar URLs, image EXIF,
|
||||
// comments metadata) out of a feed whose only job is to deliver
|
||||
// podcast episode metadata + the audio enclosure.
|
||||
//
|
||||
// - option_rss_use_excerpt -> suppresses content:encoded (full post body, incl. EXIF in image attrs).
|
||||
// - comments_open + get_comments_number -> together suppress per-item comments / wfw:commentRss / slash:comments.
|
||||
// - the_category_rss -> suppresses per-item category tags (channel itunes:category is the podcatcher signal).
|
||||
// - removing wpcom mrss.php hooks -> suppresses media:content for author gravatar + post images.
|
||||
add_filter( 'option_rss_use_excerpt', '__return_true' );
|
||||
add_filter( 'comments_open', '__return_false' );
|
||||
add_filter( 'get_comments_number', '__return_zero' );
|
||||
add_filter( 'the_category_rss', '__return_empty_string' );
|
||||
remove_action( 'rss2_item', 'mrss_item', 10 );
|
||||
remove_action( 'rss2_item', 'mrss_news_item' );
|
||||
|
||||
Feed_Detection::detect_and_record();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add iTunes and Podcasting 2.0 XML namespaces to the `<rss>` open tag.
|
||||
*/
|
||||
public static function output_namespaces() {
|
||||
echo "\n\t" . 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"' . "\n";
|
||||
echo "\t" . 'xmlns:podcast="https://podcastindex.org/namespace/1.0"' . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Override the feed title with `podcasting_title`, falling back to
|
||||
* `Blog Name » Category Name`.
|
||||
*
|
||||
* @param string $title Existing title.
|
||||
* @return string
|
||||
*/
|
||||
public static function feed_title( $title ) {
|
||||
$override = (string) get_option( 'podcasting_title', '' );
|
||||
if ( '' !== $override ) {
|
||||
return esc_xml( $override );
|
||||
}
|
||||
|
||||
$category = get_category( self::resolve_category_id() );
|
||||
if ( $category && ! is_wp_error( $category ) ) {
|
||||
return esc_xml( get_bloginfo( 'name' ) ) . ' » ' . esc_xml( $category->name );
|
||||
}
|
||||
return esc_xml( $title );
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the `bloginfo_rss('description')` value with `podcasting_summary`.
|
||||
*
|
||||
* `bloginfo_rss()` echoes the filter return value directly, so we strip and
|
||||
* escape here — matches the channel-level `<itunes:summary>` treatment and
|
||||
* keeps stray markup in the option from leaking into `<description>`.
|
||||
*
|
||||
* @param string $value Existing value.
|
||||
* @param string $field Field being requested.
|
||||
* @return string
|
||||
*/
|
||||
public static function feed_description( $value, $field ) {
|
||||
if ( 'description' !== $field ) {
|
||||
return $value;
|
||||
}
|
||||
return esc_xml( wp_strip_all_tags( (string) get_option( 'podcasting_summary', '' ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel-level podcast tags (rss2_head).
|
||||
*/
|
||||
public static function output_channel_tags() {
|
||||
$summary = (string) get_option( 'podcasting_summary', '' );
|
||||
if ( '' !== $summary ) {
|
||||
echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $summary ) ) . "</itunes:summary>\n";
|
||||
}
|
||||
|
||||
$author = (string) get_option( 'podcasting_talent_name', '' );
|
||||
if ( '' !== $author ) {
|
||||
echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
|
||||
}
|
||||
|
||||
$email = wp_strip_all_tags( (string) get_option( 'podcasting_email', '' ) );
|
||||
if ( '' !== $email ) {
|
||||
echo '<itunes:owner><itunes:email>' . esc_xml( $email ) . "</itunes:email></itunes:owner>\n";
|
||||
}
|
||||
|
||||
$copyright = (string) get_option( 'podcasting_copyright', '' );
|
||||
if ( '' !== $copyright ) {
|
||||
echo '<copyright>' . esc_xml( wp_strip_all_tags( $copyright ) ) . "</copyright>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit content flag
|
||||
*/
|
||||
echo '<itunes:explicit>' . esc_html( self::explicit_string() ) . "</itunes:explicit>\n";
|
||||
|
||||
$image = self::show_image_url();
|
||||
if ( '' !== $image ) {
|
||||
echo '<itunes:image href="' . esc_url( $image ) . '" />' . "\n";
|
||||
}
|
||||
|
||||
echo self::category_tag( (string) get_option( 'podcasting_category_1', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
|
||||
echo self::category_tag( (string) get_option( 'podcasting_category_2', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
|
||||
echo self::category_tag( (string) get_option( 'podcasting_category_3', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
|
||||
}
|
||||
|
||||
/**
|
||||
* Item-level podcast tags (rss2_item).
|
||||
*/
|
||||
public static function output_item_tags() {
|
||||
global $post;
|
||||
|
||||
if ( ! $post instanceof WP_Post ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$author = get_the_author();
|
||||
if ( '' === $author ) {
|
||||
$author = (string) get_option( 'podcasting_talent_name', '' );
|
||||
}
|
||||
if ( '' !== $author ) {
|
||||
echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
|
||||
}
|
||||
|
||||
// Re-applying `the_excerpt_rss` so `<itunes:summary>` matches whatever
|
||||
// the item's `<description>` ends up emitting — `get_the_excerpt()`
|
||||
// doesn't run the filter chain itself.
|
||||
$excerpt = (string) apply_filters( 'the_excerpt_rss', get_the_excerpt() );
|
||||
if ( '' !== $excerpt ) {
|
||||
echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $excerpt ) ) . "</itunes:summary>\n";
|
||||
}
|
||||
|
||||
// Per-item cover art: prefer the block's `coverArt`, fall back to the
|
||||
// post's featured image. Either way, photon-resize to 3000×3000 to
|
||||
// honour Apple's square-cover requirement. When neither is present
|
||||
// the channel-level `<itunes:image>` applies as default per spec.
|
||||
$attrs = Episode_Block_Tags::get_block_attrs( $post );
|
||||
$cover_url = isset( $attrs['coverArt']['url'] ) ? trim( (string) $attrs['coverArt']['url'] ) : '';
|
||||
$item_image = '' !== $cover_url ? self::maybe_photon( $cover_url ) : self::episode_image_url( $post->ID );
|
||||
if ( '' !== $item_image ) {
|
||||
echo '<itunes:image href="' . esc_url( $item_image ) . '" />' . "\n";
|
||||
}
|
||||
|
||||
// Block-driven iTunes + Podcasting 2.0 tags. Legacy audio posts
|
||||
// without the block contribute nothing — they keep their pre-block
|
||||
// behavior intact aside from the cover art handled above.
|
||||
if ( ! empty( $attrs ) ) {
|
||||
Episode_Block_Tags::render_from_attrs( $attrs );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the enclosure URL through the WPCOM stats endpoint and append
|
||||
* `<itunes:duration>` when resolvable. Duration is looked up against the
|
||||
* *original* attachment URL — the stats URL is synthetic.
|
||||
*
|
||||
* @param string $enclosure Generated enclosure markup.
|
||||
* @return string
|
||||
*/
|
||||
public static function rewrite_enclosure( $enclosure ) {
|
||||
global $post;
|
||||
|
||||
if ( ! preg_match( '/url="([^"]*)"/i', $enclosure, $match ) ) {
|
||||
return $enclosure;
|
||||
}
|
||||
|
||||
$original_url = $match[1];
|
||||
$post_obj = $post instanceof WP_Post ? $post : null;
|
||||
|
||||
/**
|
||||
* Whether to rewrite the enclosure through the WPCOM stats endpoint.
|
||||
* Token-gated feeds (notably WPCOM's `private-podcasts.php`) opt out
|
||||
* — the stats URL is a deterministic public endpoint that would
|
||||
* bypass any token gating on the feed itself.
|
||||
*
|
||||
* @param bool $enable Default true.
|
||||
* @param WP_Post|null $post The post being rendered.
|
||||
*/
|
||||
$enable = (bool) apply_filters( 'wpcom_podcasting_enable_play_tracking', true, $post_obj );
|
||||
|
||||
// Skip rewrite for externally hosted enclosures — the stats endpoint 404s anything that isn't a local attachment.
|
||||
$attachment_id = attachment_url_to_postid( $original_url );
|
||||
|
||||
if ( null !== $post_obj && $enable && $attachment_id > 0 ) {
|
||||
// `null` when the site isn't connected; passed through so the filter can still inject a value.
|
||||
$default_blog_id = Connection_Manager::get_site_id( true );
|
||||
|
||||
/**
|
||||
* Override the blog ID baked into the stats URL.
|
||||
*
|
||||
* @param int|null $blog_id Default Jetpack connection site ID, or null when unavailable.
|
||||
* @param WP_Post $post The post being rendered.
|
||||
*/
|
||||
$blog_id = (int) apply_filters( 'wpcom_podcasting_tracked_blog_id', $default_blog_id, $post_obj );
|
||||
|
||||
// Bail when we can't resolve a real blog ID — emit the original URL rather than a guaranteed-404 stats URL.
|
||||
if ( $blog_id > 0 ) {
|
||||
$stats_url = self::build_stats_url( $blog_id, (int) $post_obj->ID, $original_url );
|
||||
$enclosure = preg_replace_callback(
|
||||
'/url="[^"]*"/i',
|
||||
/**
|
||||
* Replace the matched `url="…"` attribute with the stats URL.
|
||||
* `$matches` is required by `preg_replace_callback`'s callable
|
||||
* signature but ignored — we always emit the same value.
|
||||
*
|
||||
* @param array $matches Regex matches.
|
||||
* @return string
|
||||
*/
|
||||
static function ( array $matches ) use ( $stats_url ) {
|
||||
unset( $matches );
|
||||
return 'url="' . esc_url( $stats_url ) . '"';
|
||||
},
|
||||
$enclosure,
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ( 0 === $attachment_id ) {
|
||||
return $enclosure;
|
||||
}
|
||||
|
||||
$metadata = wp_get_attachment_metadata( $attachment_id );
|
||||
$duration = is_array( $metadata ) ? absint( $metadata['length'] ?? 0 ) : 0;
|
||||
|
||||
return 0 === $duration
|
||||
? $enclosure
|
||||
: $enclosure . '<itunes:duration>' . $duration . "</itunes:duration>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* A podcast item without an enclosure is invalid per Apple's spec and can
|
||||
* take down the whole submission. The `enclosure` post meta is what
|
||||
* `rss_enclosure()` reads, so it's the authoritative signal here too.
|
||||
*
|
||||
* @param WP_Post[] $posts Posts about to be looped over.
|
||||
* @param \WP_Query $query Query that produced them.
|
||||
* @return WP_Post[]
|
||||
*/
|
||||
public static function filter_posts_with_enclosure( $posts, $query ) {
|
||||
if ( ! $query->is_main_query() || ! $query->is_feed() || ! $query->is_category() ) {
|
||||
return $posts;
|
||||
}
|
||||
$category_id = self::resolve_category_id();
|
||||
if ( 0 === $category_id ) {
|
||||
return $posts;
|
||||
}
|
||||
$queried = $query->get_queried_object();
|
||||
if ( ! $queried || ! isset( $queried->term_id ) || (int) $queried->term_id !== $category_id ) {
|
||||
return $posts;
|
||||
}
|
||||
return array_values(
|
||||
array_filter(
|
||||
$posts,
|
||||
static function ( $post ) {
|
||||
return $post instanceof WP_Post
|
||||
&& ! empty( get_post_meta( $post->ID, 'enclosure', false ) );
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored explicit value, normalized to the `'true'`/`'false'` strings the
|
||||
* iTunes spec requires. Reuses `Settings::sanitize_explicit`
|
||||
* so legacy `'yes'`/`'no'`/`'clean'` and modern boolean storage both work.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function explicit_string(): string {
|
||||
return Settings::sanitize_explicit( get_option( 'podcasting_explicit', false ) ) ? 'true' : 'false';
|
||||
}
|
||||
|
||||
/**
|
||||
* Show-level cover image URL — `Settings::raw_show_image_url()` routed
|
||||
* through Photon at 3000×3000 when available.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function show_image_url(): string {
|
||||
$url = Settings::raw_show_image_url();
|
||||
return '' === $url ? '' : self::maybe_photon( $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WPCOM stats URL for a given episode. The endpoint redirects
|
||||
* to the audio file after recording the play — the package never serves
|
||||
* it, only points at it. Audio extensions outside the recognized set
|
||||
* fall back to `mp3` to keep the URL shape uniform (matches the Podtrac
|
||||
* / Megaphone / Art19 convention).
|
||||
*
|
||||
* @param int $blog_id WPCOM blog ID (Atomic should override via the
|
||||
* `wpcom_podcasting_tracked_blog_id` filter).
|
||||
* @param int $post_id Episode post ID.
|
||||
* @param string $original_url Original enclosure URL — extension is pulled from here.
|
||||
* @return string
|
||||
*/
|
||||
private static function build_stats_url( int $blog_id, int $post_id, string $original_url ): string {
|
||||
$path = (string) wp_parse_url( $original_url, PHP_URL_PATH );
|
||||
$ext = (string) preg_replace( '/[^a-z0-9]/', '', strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ) );
|
||||
if ( ! in_array( $ext, array( 'mp3', 'm4a', 'm4b', 'aac', 'ogg', 'oga', 'opus', 'wav', 'flac', 'mp4', 'm4v', 'mov' ), true ) ) {
|
||||
$ext = 'mp3';
|
||||
}
|
||||
return sprintf(
|
||||
'https://public-api.wordpress.com/wpcom/v2/sites/%d/podcast-play/%d.%s',
|
||||
$blog_id,
|
||||
$post_id,
|
||||
$ext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the configured podcast category ID. Prefers the numeric
|
||||
* `podcasting_category_id`, falling back to a slug lookup against the
|
||||
* legacy `podcasting_archive` option — older sites pre-date numeric
|
||||
* storage and only have the slug. Returns 0 when neither resolves.
|
||||
*
|
||||
* A numeric ID whose term was deleted means "not configured" — the slug
|
||||
* is not consulted in that case.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public static function resolve_category_id(): int {
|
||||
$category_id = (int) get_option( 'podcasting_category_id', 0 );
|
||||
if ( $category_id > 0 ) {
|
||||
$category = get_category( $category_id );
|
||||
return ( $category && ! is_wp_error( $category ) && isset( $category->term_id ) ) ? (int) $category->term_id : 0;
|
||||
}
|
||||
|
||||
$slug = (string) get_option( 'podcasting_archive', '' );
|
||||
if ( '' === $slug ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$term = get_term_by( 'slug', $slug, 'category' );
|
||||
return ( $term && ! is_wp_error( $term ) && isset( $term->term_id ) ) ? (int) $term->term_id : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode-level image URL — the post's featured image, Photon-resized,
|
||||
* or `''` when no featured image is set. Used as the fallback per-item
|
||||
* cover when the block doesn't supply its own.
|
||||
*
|
||||
* @param int $post_id Episode post ID.
|
||||
* @return string
|
||||
*/
|
||||
private static function episode_image_url( int $post_id ): string {
|
||||
if ( ! has_post_thumbnail( $post_id ) ) {
|
||||
return '';
|
||||
}
|
||||
$src = wp_get_attachment_image_src( get_post_thumbnail_id( $post_id ), 'full' );
|
||||
if ( ! is_array( $src ) || empty( $src[0] ) ) {
|
||||
return '';
|
||||
}
|
||||
return self::maybe_photon( $src[0] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Route through Photon at exactly 3000×3000 so the feed always serves a
|
||||
* square cover, regardless of the source aspect ratio. `resize` center-crops
|
||||
* (unlike `fit`, which only constrains within the box); Apple's spec wants
|
||||
* 1400–3000 px square art and rejects non-square covers.
|
||||
*
|
||||
* @param string $url Image URL.
|
||||
* @return string
|
||||
*/
|
||||
public static function maybe_photon( string $url ): string {
|
||||
if ( ! function_exists( 'jetpack_photon_url' ) ) {
|
||||
return $url;
|
||||
}
|
||||
// @phan-suppress-next-line PhanUndeclaredFunction -- Provided by Jetpack's Photon module at runtime; guarded by `function_exists` above.
|
||||
return (string) jetpack_photon_url( $url, array( 'resize' => '3000,3000' ), 'https' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single `<itunes:category>` tag from a stored option value. The
|
||||
* stored format is one of:
|
||||
* - `''` (no category)
|
||||
* - `'Foo'` → single category
|
||||
* - `'Foo,Bar'` → category Foo with subcategory Bar
|
||||
*
|
||||
* Includes a back-compat translation pass for a few legacy values that were
|
||||
* stored in non-canonical shapes before validation tightened.
|
||||
*
|
||||
* @param string $stored Raw option value.
|
||||
* @return string Empty string if no category, otherwise an XML fragment.
|
||||
*/
|
||||
public static function category_tag( string $stored ): string {
|
||||
static $legacy_aliases = array(
|
||||
'Education,Education' => 'Education',
|
||||
'Education,Education Technology' => 'Education,Educational Technology',
|
||||
'Tech News' => 'Technology,Tech News',
|
||||
'Sports & Recreation,Technology' => 'Technology',
|
||||
'Sports & Recreation,Gadgets' => 'Technology,Gadgets',
|
||||
'Sports,Football' => 'Sports,American Football',
|
||||
'Sports,Soccer' => 'Sports,Football (Soccer)',
|
||||
);
|
||||
$category = $legacy_aliases[ $stored ] ?? $stored;
|
||||
|
||||
if ( '' === $category ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// `ent2ncr()` normalises named HTML entities (e.g. ` `, `©`) into
|
||||
// numeric character references so an attribute value containing them stays
|
||||
// well-formed XML after esc_attr().
|
||||
$splits = explode( ',', $category );
|
||||
if ( 2 === count( $splits ) ) {
|
||||
return '<itunes:category text="' . ent2ncr( esc_attr( $splits[0] ) ) . '">' . "\n"
|
||||
. "\t" . '<itunes:category text="' . ent2ncr( esc_attr( $splits[1] ) ) . '" />' . "\n"
|
||||
. "</itunes:category>\n";
|
||||
}
|
||||
return '<itunes:category text="' . ent2ncr( esc_attr( $category ) ) . '" />' . "\n";
|
||||
}
|
||||
}
|
||||
+340
@@ -0,0 +1,340 @@
|
||||
<?php
|
||||
/**
|
||||
* Emits iTunes + Podcasting 2.0 item-level tags sourced from the
|
||||
* `jetpack/podcast-episode` block's attrs.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\Jetpack\Podcast\Feed;
|
||||
|
||||
use WP_Post;
|
||||
|
||||
/**
|
||||
* Block-attr → XML translation for a single episode. Kept separate from
|
||||
* `Customize_Feed` (which only wires rss2_* hooks) so the mapping logic stays
|
||||
* focused and independently testable.
|
||||
*/
|
||||
class Episode_Block_Tags {
|
||||
|
||||
/**
|
||||
* Emit item-level tags for a post if it contains a podcast-episode block.
|
||||
* Posts without the block contribute nothing — legacy audio items keep
|
||||
* their pre-block behavior intact.
|
||||
*
|
||||
* @param WP_Post $post Episode post.
|
||||
*/
|
||||
public static function render( WP_Post $post ): void {
|
||||
$attrs = self::get_block_attrs( $post );
|
||||
if ( empty( $attrs ) ) {
|
||||
return;
|
||||
}
|
||||
self::render_from_attrs( $attrs );
|
||||
}
|
||||
|
||||
/**
|
||||
* Testable seam — emit tags for a literal attrs array, skipping the block
|
||||
* parse. Each emit is independent and no-ops on missing/blank values.
|
||||
*
|
||||
* @param array<string, mixed> $attrs Block attrs.
|
||||
*/
|
||||
public static function render_from_attrs( array $attrs ): void {
|
||||
self::emit_episode_number( $attrs );
|
||||
self::emit_season_number( $attrs );
|
||||
self::emit_episode_type( $attrs );
|
||||
self::emit_explicit_override( $attrs );
|
||||
self::emit_transcript( $attrs );
|
||||
self::emit_chapters( $attrs );
|
||||
self::emit_location( $attrs );
|
||||
self::emit_license( $attrs );
|
||||
self::emit_people( $attrs );
|
||||
self::emit_soundbites( $attrs );
|
||||
self::emit_alternate_enclosures( $attrs );
|
||||
}
|
||||
|
||||
/**
|
||||
* `<podcast:chapters url="…" type="…" />` from the block's `chaptersUrl`
|
||||
* / `chaptersType` attrs. Skip if no URL is set.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_chapters( array $attrs ): void {
|
||||
$url = isset( $attrs['chaptersUrl'] ) ? trim( (string) $attrs['chaptersUrl'] ) : '';
|
||||
if ( '' === $url ) {
|
||||
return;
|
||||
}
|
||||
$type = isset( $attrs['chaptersType'] ) ? (string) $attrs['chaptersType'] : 'application/json+chapters';
|
||||
echo '<podcast:chapters url="' . esc_url( $url ) . '" type="' . esc_attr( $type ) . '" />' . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract attrs from the first `jetpack/podcast-episode` block in the
|
||||
* post's content. Returns an empty array if no such block exists.
|
||||
*
|
||||
* First-wins: a post containing multiple `jetpack/podcast-episode` blocks
|
||||
* is semantically odd (one item = one episode) so we don't try to merge.
|
||||
*
|
||||
* @param WP_Post $post Episode post.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function get_block_attrs( WP_Post $post ): array {
|
||||
// Skip the parse_blocks() regex pass entirely if our specific block
|
||||
// marker isn't even in the content — tighter than has_blocks(), which
|
||||
// only checks for `<!-- wp:`.
|
||||
if ( false === strpos( $post->post_content, '<!-- wp:jetpack/podcast-episode' ) ) {
|
||||
return array();
|
||||
}
|
||||
foreach ( parse_blocks( $post->post_content ) as $block ) {
|
||||
if ( isset( $block['blockName'] ) && 'jetpack/podcast-episode' === $block['blockName'] ) {
|
||||
return isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
|
||||
}
|
||||
}
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* `<itunes:episode>` + `<podcast:episode>` for positive integer values.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_episode_number( array $attrs ): void {
|
||||
$value = $attrs['episodeNumber'] ?? 0;
|
||||
if ( (int) $value <= 0 ) {
|
||||
return;
|
||||
}
|
||||
echo '<itunes:episode>' . (int) $value . "</itunes:episode>\n";
|
||||
echo '<podcast:episode>' . (int) $value . "</podcast:episode>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* `<itunes:season>` + `<podcast:season>` for positive integer values.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_season_number( array $attrs ): void {
|
||||
$value = $attrs['seasonNumber'] ?? 0;
|
||||
if ( (int) $value <= 0 ) {
|
||||
return;
|
||||
}
|
||||
echo '<itunes:season>' . (int) $value . "</itunes:season>\n";
|
||||
echo '<podcast:season>' . (int) $value . "</podcast:season>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* `<itunes:episodeType>` for `trailer` / `bonus`. `full` is Apple's
|
||||
* default and emitting it for every item would be noise.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_episode_type( array $attrs ): void {
|
||||
$value = isset( $attrs['episodeType'] ) ? (string) $attrs['episodeType'] : '';
|
||||
if ( ! in_array( $value, array( 'trailer', 'bonus' ), true ) ) {
|
||||
return;
|
||||
}
|
||||
echo '<itunes:episodeType>' . esc_xml( $value ) . "</itunes:episodeType>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* `<itunes:explicit>` only when the per-episode value differs from the
|
||||
* channel default — emitting a matching value on every item is noisy
|
||||
* and redundant per Apple's spec.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_explicit_override( array $attrs ): void {
|
||||
if ( ! array_key_exists( 'explicit', $attrs ) ) {
|
||||
return;
|
||||
}
|
||||
$item_value = (bool) $attrs['explicit'] ? 'true' : 'false';
|
||||
$channel_value = Customize_Feed::explicit_string();
|
||||
if ( $item_value === $channel_value ) {
|
||||
return;
|
||||
}
|
||||
echo '<itunes:explicit>' . esc_html( $item_value ) . "</itunes:explicit>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* `<podcast:transcript url="…" type="…" />`. Type is validated against
|
||||
* the enum from `block.json` so a hand-edited attr can't smuggle in an
|
||||
* arbitrary MIME type.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_transcript( array $attrs ): void {
|
||||
$url = isset( $attrs['transcriptUrl'] ) ? (string) $attrs['transcriptUrl'] : '';
|
||||
if ( '' === trim( $url ) ) {
|
||||
return;
|
||||
}
|
||||
$type = isset( $attrs['transcriptType'] ) ? (string) $attrs['transcriptType'] : 'text/vtt';
|
||||
$allowed_types = array( 'text/vtt', 'text/html', 'application/srt', 'application/json' );
|
||||
if ( ! in_array( $type, $allowed_types, true ) ) {
|
||||
$type = 'text/vtt';
|
||||
}
|
||||
echo '<podcast:transcript url="' . esc_url( $url ) . '" type="' . esc_attr( $type ) . "\" />\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* `<podcast:location name="…" />`. Geo coordinates aren't captured by
|
||||
* the block yet, so we emit name-only for v1.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_location( array $attrs ): void {
|
||||
$name = isset( $attrs['locationName'] ) ? trim( (string) $attrs['locationName'] ) : '';
|
||||
if ( '' === $name ) {
|
||||
return;
|
||||
}
|
||||
echo '<podcast:location>' . esc_xml( $name ) . "</podcast:location>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* `<podcast:license>` — name as text content, optional `url` attribute.
|
||||
* If only the URL is set we still need a name for valid markup, so skip.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_license( array $attrs ): void {
|
||||
$name = isset( $attrs['license'] ) ? trim( (string) $attrs['license'] ) : '';
|
||||
$url = isset( $attrs['licenseUrl'] ) ? trim( (string) $attrs['licenseUrl'] ) : '';
|
||||
if ( '' === $name ) {
|
||||
return;
|
||||
}
|
||||
if ( '' === $url ) {
|
||||
echo '<podcast:license>' . esc_xml( $name ) . "</podcast:license>\n";
|
||||
return;
|
||||
}
|
||||
echo '<podcast:license url="' . esc_url( $url ) . '">' . esc_xml( $name ) . "</podcast:license>\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* One `<podcast:person>` per entry. Lenient on role/group — the spec
|
||||
* defines a taxonomy but most validators only warn on unknown values;
|
||||
* passing them through keeps editor freedom and matches the v1 plan.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_people( array $attrs ): void {
|
||||
if ( empty( $attrs['people'] ) || ! is_array( $attrs['people'] ) ) {
|
||||
return;
|
||||
}
|
||||
foreach ( $attrs['people'] as $person ) {
|
||||
if ( ! is_array( $person ) ) {
|
||||
continue;
|
||||
}
|
||||
$name = isset( $person['name'] ) ? trim( (string) $person['name'] ) : '';
|
||||
if ( '' === $name ) {
|
||||
continue;
|
||||
}
|
||||
$tag = '<podcast:person';
|
||||
if ( ! empty( $person['role'] ) ) {
|
||||
$tag .= ' role="' . esc_attr( (string) $person['role'] ) . '"';
|
||||
}
|
||||
if ( ! empty( $person['group'] ) ) {
|
||||
$tag .= ' group="' . esc_attr( (string) $person['group'] ) . '"';
|
||||
}
|
||||
if ( ! empty( $person['href'] ) ) {
|
||||
$tag .= ' href="' . esc_url( (string) $person['href'] ) . '"';
|
||||
}
|
||||
if ( ! empty( $person['img'] ) ) {
|
||||
$tag .= ' img="' . esc_url( (string) $person['img'] ) . '"';
|
||||
}
|
||||
$tag .= '>' . esc_xml( $name ) . "</podcast:person>\n";
|
||||
echo $tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One `<podcast:soundbite>` per entry. Order is preserved — the P2.0
|
||||
* spec doesn't mandate sorting and editors might intend a specific
|
||||
* sequence.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_soundbites( array $attrs ): void {
|
||||
if ( empty( $attrs['soundbites'] ) || ! is_array( $attrs['soundbites'] ) ) {
|
||||
return;
|
||||
}
|
||||
foreach ( $attrs['soundbites'] as $soundbite ) {
|
||||
if ( ! is_array( $soundbite )
|
||||
|| ! isset( $soundbite['startTime'] )
|
||||
|| ! isset( $soundbite['duration'] )
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
$start = (float) $soundbite['startTime'];
|
||||
$duration = (float) $soundbite['duration'];
|
||||
if ( $duration <= 0 ) {
|
||||
continue;
|
||||
}
|
||||
$title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
|
||||
$tag = '<podcast:soundbite startTime="' . esc_attr( (string) $start )
|
||||
. '" duration="' . esc_attr( (string) $duration ) . '"';
|
||||
if ( '' === $title ) {
|
||||
$tag .= " />\n";
|
||||
} else {
|
||||
$tag .= '>' . esc_xml( $title ) . "</podcast:soundbite>\n";
|
||||
}
|
||||
echo $tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One `<podcast:alternateEnclosure>` per entry, wrapping nested
|
||||
* `<podcast:source>` children. The block currently stores each entry as a
|
||||
* single URL with type/bitrate metadata, but the spec models a list of
|
||||
* sources — accept both shapes so a future block change doesn't need a
|
||||
* second pass here.
|
||||
*
|
||||
* @param array $attrs Block attrs.
|
||||
*/
|
||||
private static function emit_alternate_enclosures( array $attrs ): void {
|
||||
if ( empty( $attrs['alternateEnclosures'] ) || ! is_array( $attrs['alternateEnclosures'] ) ) {
|
||||
return;
|
||||
}
|
||||
foreach ( $attrs['alternateEnclosures'] as $alt ) {
|
||||
if ( ! is_array( $alt ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// `type` is required per spec; skip the entry rather than emit invalid markup.
|
||||
$type = isset( $alt['type'] ) ? trim( (string) $alt['type'] ) : '';
|
||||
if ( '' === $type ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sources = array();
|
||||
if ( ! empty( $alt['sources'] ) && is_array( $alt['sources'] ) ) {
|
||||
foreach ( $alt['sources'] as $src ) {
|
||||
if ( is_array( $src ) && ! empty( $src['uri'] ) ) {
|
||||
$sources[] = (string) $src['uri'];
|
||||
} elseif ( is_string( $src ) && '' !== $src ) {
|
||||
$sources[] = $src;
|
||||
}
|
||||
}
|
||||
} elseif ( ! empty( $alt['url'] ) ) {
|
||||
$sources[] = (string) $alt['url'];
|
||||
}
|
||||
if ( empty( $sources ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$opener = '<podcast:alternateEnclosure type="' . esc_attr( $type ) . '"';
|
||||
if ( ! empty( $alt['length'] ) ) {
|
||||
$opener .= ' length="' . (int) $alt['length'] . '"';
|
||||
}
|
||||
if ( ! empty( $alt['bitrate'] ) ) {
|
||||
$opener .= ' bitrate="' . (int) $alt['bitrate'] . '"';
|
||||
}
|
||||
$opener .= ">\n";
|
||||
echo $opener; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
|
||||
|
||||
foreach ( $sources as $uri ) {
|
||||
echo "\t<podcast:source uri=\"" . esc_url( $uri ) . "\" />\n";
|
||||
}
|
||||
|
||||
echo "</podcast:alternateEnclosure>\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
/**
|
||||
* Records when podcast directory crawlers fetch the feed.
|
||||
*
|
||||
* @package automattic/jetpack-podcast
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\Jetpack\Podcast\Feed;
|
||||
|
||||
use Automattic\Jetpack\Podcast\Settings;
|
||||
|
||||
/**
|
||||
* Promotes an existing pending podcatcher `podcasting_show_states` entry to
|
||||
* `'active'` the first time we see its UA fetch the feed. Idempotent
|
||||
* thereafter.
|
||||
*/
|
||||
class Feed_Detection {
|
||||
|
||||
/**
|
||||
* UA needle → podcatcher slug. First match wins, so order matters: more
|
||||
* specific brand needles (e.g. `GooglePodcasts`, `YouTubeMusic`) come
|
||||
* before generic ones (e.g. Apple's `Podcasts/`) that they share a
|
||||
* substring with.
|
||||
*
|
||||
* Cross-referenced against opawg/user-agents-v2 (the OPAWG community
|
||||
* dataset used by Podtrac, Megaphone, Art19, Chartable). When directories
|
||||
* change UAs or new ones emerge, sync the relevant entries here:
|
||||
* https://github.com/opawg/user-agents-v2/blob/master/src/{apps,bots}.json
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const NEEDLES = array(
|
||||
// YouTube / Google Podcasts — listed first so `GooglePodcasts/`
|
||||
// doesn't get caught by Apple's broader `Podcasts/` needle below.
|
||||
'Google-Podcast' => 'youtube',
|
||||
'YouTube-Podcast' => 'youtube',
|
||||
'GooglePodcasts' => 'youtube',
|
||||
'GoogleChirp' => 'youtube',
|
||||
'YouTubeMusic' => 'youtube',
|
||||
|
||||
// Apple Podcasts (iOS app, Mac app, automated checks, HomePod, Apple TV).
|
||||
'iTMS' => 'apple',
|
||||
'AppleCoreMedia' => 'apple',
|
||||
'Podcasts/' => 'apple',
|
||||
'iTunes' => 'apple',
|
||||
'AirPodcasts/' => 'apple',
|
||||
|
||||
// Spotify — substring catches `Spotify/…`, `spotify-rss-…`, all variants.
|
||||
'Spotify' => 'spotify',
|
||||
|
||||
// Pocket Casts.
|
||||
'Pocket Casts' => 'pocketcasts',
|
||||
'PocketCasts' => 'pocketcasts',
|
||||
|
||||
// Amazon — `AmazonMusic` is the listening app; `Amazon Music Podcast`
|
||||
// (with spaces) is the actual feed crawler. Keep both.
|
||||
'AmazonMusic' => 'amazon',
|
||||
'Amazon Music Podcast' => 'amazon',
|
||||
|
||||
// Podcast Index — substring `PodcastIndex` catches `Podcastindex.org/`,
|
||||
// `PodcastIndexer/`, `PodcastIndexManager/`, `PodcastIndex Classifier/`.
|
||||
'PodcastIndex' => 'podcastindex',
|
||||
);
|
||||
|
||||
/**
|
||||
* Inspect the current request's User-Agent and, if it's a directory
|
||||
* crawler with an existing pending state, mark its state `'active'`.
|
||||
* No-op if the UA is missing, not in the directory allowlist, or the
|
||||
* matched directory has no pending state.
|
||||
*/
|
||||
public static function detect_and_record(): void {
|
||||
$ua = isset( $_SERVER['HTTP_USER_AGENT'] )
|
||||
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
|
||||
: '';
|
||||
if ( '' === $ua ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$slug = null;
|
||||
foreach ( self::NEEDLES as $needle => $candidate ) {
|
||||
if ( false !== stripos( $ua, $needle ) ) {
|
||||
$slug = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( null === $slug || ! isset( Settings::SHOW_URL_HOSTS[ $slug ] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$states = get_option( 'podcasting_show_states', array() );
|
||||
if ( ! is_array( $states ) ) {
|
||||
$states = array();
|
||||
}
|
||||
|
||||
if ( ! isset( $states[ $slug ] ) || 'pending' !== $states[ $slug ] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Concurrent first-fetches from different apps can race the read-modify-write.
|
||||
// Benign: the loser's next poll (multi-hour cadence) re-writes its key. Worst
|
||||
// case is one app sitting in `pending` for one extra poll cycle.
|
||||
$states[ $slug ] = 'active';
|
||||
update_option( 'podcasting_show_states', $states );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user