feature: Initial commit

This commit is contained in:
Keith Solomon
2026-08-24 08:46:51 -05:00
commit 158f978021
24 changed files with 2200 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
<?php
/**
* Read-only imported episode details.
*
* @package RSS2CPT
*/
namespace RSS2CPT\Admin;
use RSS2CPT\Import\Importer;
use RSS2CPT\Options;
use WP_Post;
defined( 'ABSPATH' ) || exit;
/**
* Shows imported podcast fields in the post editor.
*/
final class EpisodeMetaBox {
/**
* Register admin hooks.
*/
public function register(): void {
add_action( 'add_meta_boxes', array( $this, 'add_meta_box' ), 10, 2 );
}
/**
* Add the details box to the configured destination post type.
*
* @param string $post_type Current post type.
* @param WP_Post $post Current post.
*/
public function add_meta_box( string $post_type, WP_Post $post ): void {
$settings = Options::get();
$configured = sanitize_key( (string) $settings['post_type'] );
$imported = (string) get_post_meta( $post->ID, Importer::META_SOURCE_KEY, true );
if ( ! post_type_exists( $post_type ) || ( $post_type !== $configured && '' === $imported ) ) {
return;
}
add_meta_box(
'rss2cpt_episode_details',
__( 'Podcast episode details', 'rss2cpt' ),
array( $this, 'render' ),
$post_type,
'side',
'default'
);
}
/**
* Render safely escaped, read-only source data.
*
* @param WP_Post $post Current post.
*/
public function render( WP_Post $post ): void {
$episode_url = (string) get_post_meta( $post->ID, Importer::META_EPISODE_LINK, true );
$audio_url = (string) get_post_meta( $post->ID, Importer::META_AUDIO_URL, true );
$duration = (string) get_post_meta( $post->ID, Importer::META_DURATION, true );
$season = (string) get_post_meta( $post->ID, Importer::META_SEASON, true );
$episode = (string) get_post_meta( $post->ID, Importer::META_EPISODE, true );
$creator = (string) get_post_meta( $post->ID, Importer::META_CREATOR, true );
if ( '' === $episode_url && '' === $audio_url ) {
echo '<p>' . esc_html__( 'No imported podcast details are available for this post.', 'rss2cpt' ) . '</p>';
return;
}
if ( $episode_url ) {
printf(
'<p><a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a></p>',
esc_url( $episode_url ),
esc_html__( 'View original episode', 'rss2cpt' )
);
}
if ( $audio_url ) {
printf(
'<audio controls preload="none" src="%1$s" style="width:100%%"></audio><p><a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a></p>',
esc_url( $audio_url ),
esc_html__( 'Open audio file', 'rss2cpt' )
);
}
$details = array_filter(
array(
__( 'Duration', 'rss2cpt' ) => $duration,
__( 'Season', 'rss2cpt' ) => $season,
__( 'Episode', 'rss2cpt' ) => $episode,
__( 'Creator', 'rss2cpt' ) => $creator,
)
);
if ( $details ) {
echo '<dl>';
foreach ( $details as $label => $value ) {
printf( '<dt><strong>%1$s</strong></dt><dd>%2$s</dd>', esc_html( $label ), esc_html( $value ) );
}
echo '</dl>';
}
}
}
+567
View File
@@ -0,0 +1,567 @@
<?php
/**
* Podcast importer settings screen.
*
* @package RSS2CPT
*/
namespace RSS2CPT\Admin;
use RSS2CPT\Import\Importer;
use RSS2CPT\Options;
use RSS2CPT\Scheduler;
use Throwable;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Registers and renders the podcast importer settings screen.
*
* Bootstrap instantiates this class with the importer and scheduler, then calls
* register(). Manual imports pass the normalized Options::get() array to the
* importer service.
*/
final class Settings {
/** Settings page slug. */
public const PAGE_SLUG = 'rss2cpt-settings';
/** Manual import admin-post action. */
public const MANUAL_IMPORT_ACTION = 'rss2cpt_manual_import';
/** Manual import nonce action. */
private const NONCE_ACTION = 'rss2cpt_run_manual_import';
/**
* Import service.
*
* @var Importer
*/
private $importer;
/**
* Scheduler service.
*
* Retained as an explicit dependency so admin composition and scheduling
* remain coupled at the application boundary.
*
* @var Scheduler
*/
private $scheduler;
/**
* Constructor.
*
* @param Importer $importer Podcast importer.
* @param Scheduler $scheduler Import scheduler.
*/
public function __construct( Importer $importer, Scheduler $scheduler ) {
$this->importer = $importer;
$this->scheduler = $scheduler;
}
/**
* Register WordPress hooks.
*
* @return void
*/
public function register(): void {
add_action( 'admin_menu', array( $this, 'register_page' ) );
add_action( 'admin_init', array( $this, 'register_settings' ) );
add_action( 'admin_post_' . self::MANUAL_IMPORT_ACTION, array( $this, 'handle_manual_import' ) );
add_action( 'admin_notices', array( $this, 'render_notices' ) );
$this->scheduler->ensure_scheduled();
}
/**
* Register the settings page beneath Settings.
*
* @return void
*/
public function register_page(): void {
add_options_page(
esc_html__( 'Podcast RSS Import', 'rss2cpt' ),
esc_html__( 'Podcast RSS Import', 'rss2cpt' ),
'manage_options',
self::PAGE_SLUG,
array( $this, 'render_page' )
);
}
/**
* Register the option, section, and fields.
*
* @return void
*/
public function register_settings(): void {
register_setting(
'rss2cpt_settings_group',
Options::KEY,
array(
'type' => 'array',
'default' => Options::get(),
'sanitize_callback' => array( $this, 'sanitize_settings' ),
)
);
add_settings_section(
'rss2cpt_source_section',
esc_html__( 'Import configuration', 'rss2cpt' ),
array( $this, 'render_section_description' ),
self::PAGE_SLUG
);
$fields = array(
'feed_url' => __( 'Podcast feed URL', 'rss2cpt' ),
'post_type' => __( 'Target post type', 'rss2cpt' ),
'schedule' => __( 'Import schedule', 'rss2cpt' ),
'post_status' => __( 'Imported post status', 'rss2cpt' ),
'author_id' => __( 'Post author', 'rss2cpt' ),
'import_image' => __( 'Episode images', 'rss2cpt' ),
'update_existing' => __( 'Existing episodes', 'rss2cpt' ),
'item_limit' => __( 'Per-run import limit', 'rss2cpt' ),
);
foreach ( $fields as $field => $label ) {
add_settings_field(
'rss2cpt_' . $field,
esc_html( $label ),
array( $this, 'render_' . $field . '_field' ),
self::PAGE_SLUG,
'rss2cpt_source_section'
);
}
}
/**
* Return normalized saved settings.
*
* @return array<string, int|string> Settings values.
*/
public function get_settings(): array {
return Options::get();
}
/**
* Sanitize settings before storage.
*
* @param mixed $input Submitted settings.
* @return array<string, int|string> Sanitized settings.
*/
public function sanitize_settings( $input ): array {
$current = $this->get_settings();
$input = is_array( $input ) ? $input : array();
$output = Options::get();
$feed_url = isset( $input['feed_url'] ) ? esc_url_raw( wp_unslash( $input['feed_url'] ), array( 'http', 'https' ) ) : '';
if ( '' !== $feed_url && ! wp_http_validate_url( $feed_url ) ) {
add_settings_error(
Options::KEY,
'rss2cpt_invalid_feed_url',
esc_html__( 'Enter a valid HTTP or HTTPS podcast feed URL.', 'rss2cpt' )
);
$feed_url = (string) $current['feed_url'];
}
$output['feed_url'] = $feed_url;
$post_type = isset( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : '';
if ( ! $this->is_allowed_post_type( $post_type ) ) {
add_settings_error(
Options::KEY,
'rss2cpt_invalid_post_type',
esc_html__( 'Select a post type that is available in the WordPress admin.', 'rss2cpt' )
);
$post_type = $this->is_allowed_post_type( (string) $current['post_type'] ) ? (string) $current['post_type'] : 'podcast_episode';
}
$output['post_type'] = $post_type;
$schedules = wp_get_schedules();
$schedule = isset( $input['schedule'] ) ? sanitize_key( $input['schedule'] ) : '';
if ( 'disabled' !== $schedule && ! isset( $schedules[ $schedule ] ) ) {
$schedule = ( 'disabled' === $current['schedule'] || isset( $schedules[ $current['schedule'] ] ) ) ? (string) $current['schedule'] : 'hourly';
}
$output['schedule'] = $schedule;
$allowed_statuses = array( 'draft', 'pending', 'private', 'publish' );
$post_status = isset( $input['post_status'] ) ? sanitize_key( $input['post_status'] ) : '';
$output['post_status'] = in_array( $post_status, $allowed_statuses, true ) ? $post_status : 'draft';
$author_id = isset( $input['author_id'] ) ? absint( $input['author_id'] ) : 0;
if ( 0 === $author_id ) {
$author_id = get_current_user_id();
}
if ( 0 !== $author_id && ! get_user_by( 'id', $author_id ) ) {
$author_id = 0;
}
$output['author_id'] = $author_id;
$output['import_image'] = empty( $input['import_image'] ) ? 0 : 1;
$output['update_existing'] = empty( $input['update_existing'] ) ? 0 : 1;
$output['item_limit'] = isset( $input['item_limit'] ) ? min( 500, absint( $input['item_limit'] ) ) : 0;
return $output;
}
/**
* Render the settings page.
*
* @return void
*/
public function render_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
return;
}
?>
<div class="wrap">
<h1><?php echo esc_html( get_admin_page_title() ); ?></h1>
<?php $this->render_last_import(); ?>
<form action="<?php echo esc_url( admin_url( 'options.php' ) ); ?>" method="post">
<?php
settings_fields( 'rss2cpt_settings_group' );
do_settings_sections( self::PAGE_SLUG );
submit_button();
?>
</form>
<hr>
<h2><?php esc_html_e( 'Manual import', 'rss2cpt' ); ?></h2>
<p><?php esc_html_e( 'Import the configured feed now. Existing episodes are skipped or updated by the importer.', 'rss2cpt' ); ?></p>
<form action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" method="post">
<input type="hidden" name="action" value="<?php echo esc_attr( self::MANUAL_IMPORT_ACTION ); ?>">
<?php wp_nonce_field( self::NONCE_ACTION ); ?>
<?php submit_button( __( 'Import now', 'rss2cpt' ), 'secondary', 'submit', false ); ?>
</form>
</div>
<?php
}
/**
* Render the persisted last-run summary.
*
* @return void
*/
private function render_last_import(): void {
$summary = get_option( 'rss2cpt_last_import', array() );
if ( ! is_array( $summary ) || empty( $summary['timestamp'] ) ) {
return;
}
$when = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), absint( $summary['timestamp'] ) );
if ( empty( $summary['success'] ) ) {
$message = sprintf(
/* translators: 1: last import date/time, 2: error message. */
__( 'Last import at %1$s failed: %2$s', 'rss2cpt' ),
$when,
isset( $summary['message'] ) ? (string) $summary['message'] : __( 'Unknown error', 'rss2cpt' )
);
} else {
$message = sprintf(
/* translators: 1: last import date/time, 2: created count, 3: updated count, 4: failed count, 5: warning count. */
__( 'Last import at %1$s: %2$d created, %3$d updated, %4$d failed, %5$d warnings.', 'rss2cpt' ),
$when,
isset( $summary['created'] ) ? absint( $summary['created'] ) : 0,
isset( $summary['updated'] ) ? absint( $summary['updated'] ) : 0,
isset( $summary['failed'] ) ? absint( $summary['failed'] ) : 0,
isset( $summary['warnings'] ) ? absint( $summary['warnings'] ) : 0
);
}
echo '<p class="description">' . esc_html( $message ) . '</p>';
}
/**
* Handle a verified manual import request.
*
* @return void
*/
public function handle_manual_import(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die(
esc_html__( 'You are not allowed to run podcast imports.', 'rss2cpt' ),
esc_html__( 'Forbidden', 'rss2cpt' ),
array( 'response' => 403 )
);
}
check_admin_referer( self::NONCE_ACTION );
$result = array();
try {
$result = $this->importer->import( Options::get() );
} catch ( Throwable $exception ) {
// Expose the exception to opt-in logging without leaking details in the URL.
do_action( 'rss2cpt_manual_import_exception', $exception );
$this->scheduler->record_result( new \WP_Error( 'rss2cpt_import_exception', __( 'The importer stopped unexpectedly.', 'rss2cpt' ) ) );
$this->redirect_with_notice( 'error' );
}
$this->scheduler->record_result( $result );
if ( is_wp_error( $result ) ) {
$this->redirect_with_notice( 'error' );
}
if ( is_array( $result ) && ! empty( $result['has_more'] ) ) {
$this->scheduler->schedule_continuation();
}
$counts = $this->normalize_import_result( $result );
$this->redirect_with_notice( 'success', $counts );
}
/**
* Render a manual-import result notice.
*
* @return void
*/
public function render_notices(): void {
if ( ! current_user_can( 'manage_options' ) || ! $this->is_settings_screen() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
$notice = isset( $_GET['rss2cpt_notice'] ) ? sanitize_key( wp_unslash( $_GET['rss2cpt_notice'] ) ) : '';
if ( '' === $notice ) {
return;
}
$class = 'notice notice-error';
$message = __( 'The podcast import could not be completed. Review the last import status above.', 'rss2cpt' );
if ( 'success' === $notice ) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
$imported = isset( $_GET['imported'] ) ? absint( $_GET['imported'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
$updated = isset( $_GET['updated'] ) ? absint( $_GET['updated'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
$skipped = isset( $_GET['skipped'] ) ? absint( $_GET['skipped'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
$failed = isset( $_GET['failed'] ) ? absint( $_GET['failed'] ) : 0;
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display state from our redirect.
$warnings = isset( $_GET['warnings'] ) ? absint( $_GET['warnings'] ) : 0;
$class = 0 < ( $failed + $warnings ) ? 'notice notice-warning' : 'notice notice-success';
$message = sprintf(
/* translators: 1: imported count, 2: updated count, 3: skipped count, 4: failed count, 5: warning count. */
__( 'Podcast import finished: %1$d imported, %2$d updated, %3$d skipped, %4$d failed, %5$d warnings.', 'rss2cpt' ),
$imported,
$updated,
$skipped,
$failed,
$warnings
);
}
?>
<div class="<?php echo esc_attr( $class ); ?> is-dismissible"><p><?php echo esc_html( $message ); ?></p></div>
<?php
}
/** Render settings section help text. */
public function render_section_description(): void {
echo '<p>' . esc_html__( 'Choose the feed source and how episodes should be created. Scheduled changes take effect when the scheduler next synchronizes.', 'rss2cpt' ) . '</p>';
}
/** Render feed URL field. */
public function render_feed_url_field(): void {
$value = $this->get_settings()['feed_url'];
printf(
'<input class="regular-text code" type="url" id="rss2cpt_feed_url" name="%1$s[feed_url]" value="%2$s" placeholder="https://example.com/podcast/feed.xml" required aria-describedby="rss2cpt_feed_url_description"><p class="description" id="rss2cpt_feed_url_description">%3$s</p>',
esc_attr( Options::KEY ),
esc_attr( $value ),
esc_html__( 'The public HTTP or HTTPS RSS feed. Only episodes retained in the publishers feed can be imported.', 'rss2cpt' )
);
}
/** Render target post type field. */
public function render_post_type_field(): void {
$value = (string) $this->get_settings()['post_type'];
$post_types = get_post_types( array( 'show_ui' => true ), 'objects' );
echo '<select id="rss2cpt_post_type" name="' . esc_attr( Options::KEY ) . '[post_type]">';
foreach ( $post_types as $post_type ) {
if ( ! $this->is_allowed_post_type( $post_type->name ) ) {
continue;
}
printf(
'<option value="%1$s"%2$s>%3$s</option>',
esc_attr( $post_type->name ),
selected( $value, $post_type->name, false ),
esc_html( $post_type->labels->singular_name )
);
}
echo '</select>';
}
/** Render schedule field. */
public function render_schedule_field(): void {
$value = (string) $this->get_settings()['schedule'];
$schedules = wp_get_schedules();
echo '<select id="rss2cpt_schedule" name="' . esc_attr( Options::KEY ) . '[schedule]">';
printf(
'<option value="disabled"%1$s>%2$s</option>',
selected( $value, 'disabled', false ),
esc_html__( 'Disabled (manual imports only)', 'rss2cpt' )
);
foreach ( $schedules as $key => $schedule ) {
printf(
'<option value="%1$s"%2$s>%3$s</option>',
esc_attr( $key ),
selected( $value, $key, false ),
esc_html( $schedule['display'] )
);
}
echo '</select>';
}
/** Render post status field. */
public function render_post_status_field(): void {
$value = (string) $this->get_settings()['post_status'];
$statuses = array(
'draft' => __( 'Draft', 'rss2cpt' ),
'pending' => __( 'Pending review', 'rss2cpt' ),
'private' => __( 'Private', 'rss2cpt' ),
'publish' => __( 'Published', 'rss2cpt' ),
);
echo '<select id="rss2cpt_post_status" name="' . esc_attr( Options::KEY ) . '[post_status]">';
foreach ( $statuses as $key => $label ) {
printf(
'<option value="%1$s"%2$s>%3$s</option>',
esc_attr( $key ),
selected( $value, $key, false ),
esc_html( $label )
);
}
echo '</select>';
}
/** Render author field. */
public function render_author_id_field(): void {
wp_dropdown_users(
array(
'id' => 'rss2cpt_author_id',
'name' => Options::KEY . '[author_id]',
'selected' => absint( $this->get_settings()['author_id'] ),
'show_option_none' => __( 'Current administrator', 'rss2cpt' ),
'option_none_value' => 0,
'who' => 'authors',
)
);
}
/** Render image-import field. */
public function render_import_image_field(): void {
$value = absint( $this->get_settings()['import_image'] );
printf(
'<label for="rss2cpt_import_image"><input type="checkbox" id="rss2cpt_import_image" name="%1$s[import_image]" value="1"%2$s> %3$s</label>',
esc_attr( Options::KEY ),
checked( 1, $value, false ),
esc_html__( 'Download episode artwork to the Media Library and use it as the featured image.', 'rss2cpt' )
);
}
/** Render existing-episode update field. */
public function render_update_existing_field(): void {
$value = absint( $this->get_settings()['update_existing'] );
printf(
'<label for="rss2cpt_update_existing"><input type="checkbox" id="rss2cpt_update_existing" name="%1$s[update_existing]" value="1"%2$s> %3$s</label>',
esc_attr( Options::KEY ),
checked( 1, $value, false ),
esc_html__( 'Refresh feed-owned title, synopsis, date, status, author, metadata, and changed artwork when an episode changes.', 'rss2cpt' )
);
}
/** Render per-run item limit field. */
public function render_item_limit_field(): void {
$value = absint( $this->get_settings()['item_limit'] );
printf(
'<input class="small-text" type="number" id="rss2cpt_item_limit" name="%1$s[item_limit]" value="%2$s" min="0" max="500" step="1" aria-describedby="rss2cpt_item_limit_description"><p class="description" id="rss2cpt_item_limit_description">%3$s</p>',
esc_attr( Options::KEY ),
esc_attr( (string) $value ),
esc_html__( 'Maximum new or changed episodes saved per run (0500). Enter 0 to process all episodes currently available in the feed.', 'rss2cpt' )
);
}
/**
* Determine whether a post type is a valid import target.
*
* @param string $post_type Post type name.
* @return bool
*/
private function is_allowed_post_type( string $post_type ): bool {
$object = get_post_type_object( $post_type );
return null !== $object
&& ! empty( $object->show_ui )
&& isset( $object->cap->edit_posts )
&& current_user_can( $object->cap->edit_posts );
}
/**
* Normalize supported importer return values into notice counts.
*
* @param mixed $result Importer result.
* @return array<string, int> Import counters.
*/
private function normalize_import_result( $result ): array {
$counts = array(
'imported' => 0,
'updated' => 0,
'skipped' => 0,
'failed' => 0,
'warnings' => 0,
);
if ( is_numeric( $result ) ) {
$counts['imported'] = absint( $result );
return $counts;
}
if ( is_array( $result ) ) {
if ( isset( $result['created'] ) ) {
$counts['imported'] = absint( $result['created'] );
}
foreach ( array_keys( $counts ) as $key ) {
if ( isset( $result[ $key ] ) ) {
$counts[ $key ] = absint( $result[ $key ] );
}
}
}
return $counts;
}
/**
* Redirect to the settings screen with a result notice.
*
* @param string $notice Notice key.
* @param array<string, int> $counts Optional import counters.
* @return void
*/
private function redirect_with_notice( string $notice, array $counts = array() ): void {
$query = array_merge(
array(
'page' => self::PAGE_SLUG,
'rss2cpt_notice' => sanitize_key( $notice ),
),
array_map( 'absint', $counts )
);
wp_safe_redirect( add_query_arg( $query, admin_url( 'options-general.php' ) ) );
exit;
}
/**
* Check whether the current request is for this settings screen.
*
* @return bool
*/
private function is_settings_screen(): bool {
$screen = get_current_screen();
return null !== $screen && 'settings_page_' . self::PAGE_SLUG === $screen->id;
}
}
+804
View File
@@ -0,0 +1,804 @@
<?php
/**
* Podcast RSS feed importer.
*
* @package RSS2CPT
*/
namespace RSS2CPT\Import;
use WP_Error;
use WP_Query;
/**
* Imports podcast feed items into a configured post type.
*/
final class Importer {
/** Maximum feed XML response size (five MiB). */
private const MAX_FEED_BYTES = 5242880;
/** Maximum artwork response size (ten MiB). */
private const MAX_IMAGE_BYTES = 10485760;
/** ITunes podcast namespace. */
private const ITUNES_NAMESPACE = 'http://www.itunes.com/dtds/podcast-1.0.dtd';
/** Media RSS namespace. */
private const MEDIA_NAMESPACE = 'http://search.yahoo.com/mrss/';
/** Dublin Core namespace. */
private const DC_NAMESPACE = 'http://purl.org/dc/elements/1.1/';
/** Meta key used to locate an item on subsequent imports. */
public const META_SOURCE_KEY = '_rss2cpt_source_key';
/** Meta key containing the originating feed URL. */
public const META_FEED_URL = '_rss2cpt_feed_url';
/** Meta key containing the feed item's original identifier. */
public const META_SOURCE_ID = '_rss2cpt_source_id';
/** Meta key containing the episode page URL. */
public const META_EPISODE_LINK = '_rss2cpt_episode_url';
/** Meta key containing the audio enclosure URL. */
public const META_AUDIO_URL = '_rss2cpt_audio_url';
/** Meta key containing the enclosure MIME type. */
public const META_AUDIO_TYPE = '_rss2cpt_audio_type';
/** Meta key containing the enclosure size in bytes. */
public const META_AUDIO_LENGTH = '_rss2cpt_audio_length';
/** Meta key containing the feed's human-readable duration value. */
public const META_DURATION = '_rss2cpt_duration';
/** Meta key containing the normalized duration in seconds. */
public const META_DURATION_SECONDS = '_rss2cpt_duration_seconds';
/** Meta key containing the podcast season number. */
public const META_SEASON = '_rss2cpt_season';
/** Meta key containing the episode number. */
public const META_EPISODE = '_rss2cpt_episode_number';
/** Meta key containing the iTunes episode type. */
public const META_EPISODE_TYPE = '_rss2cpt_episode_type';
/** Meta key containing the normalized explicit-content value. */
public const META_EXPLICIT = '_rss2cpt_explicit';
/** Meta key containing the remote episode image URL. */
public const META_IMAGE_URL = '_rss2cpt_image_url';
/** Meta key containing a hash of the last imported item values. */
public const META_FINGERPRINT = '_rss2cpt_fingerprint';
/** Meta key containing the feed-provided episode creator. */
public const META_CREATOR = '_rss2cpt_creator';
/**
* Destination post type.
*
* @var string
*/
private $post_type;
/**
* Response-size limit applied during the current remote request.
*
* @var int
*/
private $remote_size_limit = 0;
/**
* Import all currently available items from a podcast feed.
*
* Supported settings are feed_url, post_type, post_status, author_id,
* import_image, update_existing, and item_limit (zero means all available items).
*
* @param array<string,mixed> $settings Import settings.
* @return array<string,mixed>|WP_Error Structured import result or a feed/configuration error.
*/
public function import( array $settings ) {
$settings = wp_parse_args(
$settings,
array(
'feed_url' => '',
'post_type' => '',
'post_status' => 'publish',
'author_id' => 0,
'import_image' => true,
'update_existing' => false,
'item_limit' => 0,
)
);
$feed_url = esc_url_raw( (string) $settings['feed_url'] );
$this->post_type = sanitize_key( (string) $settings['post_type'] );
$result = $this->new_result( $feed_url );
if ( empty( $feed_url ) || ! wp_http_validate_url( $feed_url ) ) {
return new WP_Error( 'rss2cpt_invalid_feed_url', __( 'The podcast feed URL is invalid.', 'rss2cpt' ) );
}
if ( empty( $this->post_type ) || ! post_type_exists( $this->post_type ) ) {
return new WP_Error( 'rss2cpt_invalid_post_type', __( 'The configured destination post type does not exist.', 'rss2cpt' ) );
}
/**
* Filters options immediately before a podcast feed import.
*
* @param array $options Import options.
* @param string $feed_url Feed URL.
* @param string $post_type Destination post type.
*/
$settings = (array) apply_filters( 'rss2cpt_import_options', $settings, $feed_url, $this->post_type );
$options = $this->sanitize_options( $settings );
$lock_key = 'rss2cpt_lock_' . md5( $feed_url . '|' . $this->post_type );
$lock_token = $this->acquire_lock( $lock_key );
if ( false === $lock_token ) {
return new WP_Error( 'rss2cpt_import_locked', __( 'This podcast feed is already being imported.', 'rss2cpt' ) );
}
try {
if ( ! function_exists( 'fetch_feed' ) ) {
require_once ABSPATH . WPINC . '/feed.php';
}
$this->remote_size_limit = self::MAX_FEED_BYTES;
add_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10, 2 );
add_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_feed_cache_lifetime' ), 10, 2 );
try {
$feed = fetch_feed( $feed_url );
} finally {
remove_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_feed_cache_lifetime' ), 10 );
remove_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10 );
$this->remote_size_limit = 0;
}
if ( is_wp_error( $feed ) ) {
return $feed;
}
$items = $feed->get_items( 0, $feed->get_item_quantity() );
$result['total'] = count( $items );
foreach ( $items as $index => $item ) {
$this->renew_lock( $lock_key, $lock_token );
$item_result = $this->import_item( $item, $feed, $feed_url, $options );
$status = $item_result['status'];
if ( isset( $result[ $status ] ) ) {
++$result[ $status ];
}
if ( ! empty( $item_result['post_id'] ) ) {
$result['post_ids'][ $status ][] = $item_result['post_id'];
}
if ( ! empty( $item_result['error'] ) ) {
$result['errors'][] = $item_result['error'];
if ( isset( $item_result['error']['severity'] ) && 'warning' === $item_result['error']['severity'] ) {
++$result['warnings'];
}
}
$mutated = $result['created'] + $result['updated'];
if ( 0 < $options['item_limit'] && $mutated >= $options['item_limit'] ) {
$result['has_more'] = ( $index + 1 ) < count( $items );
break;
}
}
/**
* Filters the completed podcast import result.
*
* @param array $result Structured import result.
* @param string $feed_url Feed URL.
* @param string $post_type Destination post type.
*/
return (array) apply_filters( 'rss2cpt_import_result', $result, $feed_url, $this->post_type );
} finally {
$this->release_lock( $lock_key, $lock_token );
}
}
/**
* Add a byte cap to a plugin-scoped remote request.
*
* @param array<string,mixed> $args HTTP request arguments.
* @param string $url Requested URL.
* @return array<string,mixed> Filtered arguments.
*/
public function limit_remote_response_size( array $args, string $url ): array {
unset( $url );
if ( 0 < $this->remote_size_limit ) {
$args['limit_response_size'] = $this->remote_size_limit;
}
return $args;
}
/**
* Keep recurring imports fresher than WordPress's long default feed cache.
*
* @param int $lifetime Default cache lifetime.
* @param string $url Feed URL.
* @return int Cache lifetime in seconds.
*/
public function filter_feed_cache_lifetime( int $lifetime, string $url ): int {
unset( $lifetime, $url );
return 5 * MINUTE_IN_SECONDS;
}
/**
* Import one SimplePie item.
*
* @param object $item SimplePie item.
* @param object $feed SimplePie feed.
* @param string $feed_url Feed URL.
* @param array<string,mixed> $options Sanitized import options.
* @return array<string,mixed> Item result.
*/
private function import_item( $item, $feed, string $feed_url, array $options ): array {
$data = $this->map_item( $item, $feed );
/**
* Filters a mapped podcast item before it is persisted.
*
* @param array $data Mapped item data.
* @param object $item SimplePie item.
* @param string $feed_url Feed URL.
* @param string $post_type Destination post type.
*/
$data = (array) apply_filters( 'rss2cpt_import_item_data', $data, $item, $feed_url, $this->post_type );
unset( $data['fingerprint'] );
$data['fingerprint'] = hash( 'sha256', (string) wp_json_encode( $data ) );
if ( empty( $data['title'] ) || empty( $data['source_key'] ) ) {
return array(
'status' => 'failed',
'post_id' => 0,
'error' => $this->format_error(
new WP_Error( 'rss2cpt_invalid_item', __( 'A feed item is missing a title or stable identifier.', 'rss2cpt' ) ),
$data['source_id'] ?? ''
),
);
}
$existing_id = $this->find_existing_post( $data['source_key'] );
if ( $existing_id && ! $options['update_existing'] ) {
if ( $options['import_image'] && ! empty( $data['image_url'] ) && ! has_post_thumbnail( $existing_id ) ) {
$image_result = $this->set_episode_image( $existing_id, $data['image_url'], $data['title'] );
if ( is_wp_error( $image_result ) ) {
return array(
'status' => 'skipped',
'post_id' => $existing_id,
'error' => $this->format_error( $image_result, $data['source_id'], $existing_id, 'warning' ),
);
}
return array(
'status' => 'updated',
'post_id' => $existing_id,
'error' => null,
);
}
return array(
'status' => 'skipped',
'post_id' => $existing_id,
'error' => null,
);
}
$needs_image = $existing_id
&& $options['import_image']
&& ! empty( $data['image_url'] )
&& (
! has_post_thumbnail( $existing_id )
|| (string) get_post_meta( $existing_id, self::META_IMAGE_URL, true ) !== $data['image_url']
);
if ( $existing_id && ! $needs_image && hash_equals( (string) get_post_meta( $existing_id, self::META_FINGERPRINT, true ), $data['fingerprint'] ) ) {
return array(
'status' => 'skipped',
'post_id' => $existing_id,
'error' => null,
);
}
$postarr = array(
'post_type' => $this->post_type,
'post_status' => $options['post_status'],
'post_author' => $options['post_author'],
'post_title' => $data['title'],
'post_content' => $data['content'],
'post_excerpt' => $data['excerpt'],
'meta_input' => $this->build_meta_input( $data, $feed_url ),
);
if ( ! empty( $data['timestamp'] ) ) {
$postarr['post_date_gmt'] = gmdate( 'Y-m-d H:i:s', $data['timestamp'] );
$postarr['post_date'] = get_date_from_gmt( $postarr['post_date_gmt'] );
}
if ( $existing_id ) {
$postarr['ID'] = $existing_id;
$post_id = wp_update_post( wp_slash( $postarr ), true );
$status = 'updated';
} else {
// Recheck immediately before insertion to narrow cross-process races.
$existing_id = $this->find_existing_post( $data['source_key'] );
if ( $existing_id ) {
$postarr['ID'] = $existing_id;
$post_id = wp_update_post( wp_slash( $postarr ), true );
$status = 'updated';
} else {
$post_id = wp_insert_post( wp_slash( $postarr ), true );
$status = 'created';
}
}
if ( is_wp_error( $post_id ) ) {
return array(
'status' => 'failed',
'post_id' => 0,
'error' => $this->format_error( $post_id, $data['source_id'] ),
);
}
$error = null;
if ( $options['import_image'] && ! empty( $data['image_url'] ) ) {
$image_result = $this->set_episode_image( (int) $post_id, $data['image_url'], $data['title'] );
if ( is_wp_error( $image_result ) ) {
$error = $this->format_error( $image_result, $data['source_id'], (int) $post_id, 'warning' );
delete_post_meta( $post_id, self::META_FINGERPRINT );
}
} elseif ( ! empty( $data['image_url'] ) ) {
update_post_meta( $post_id, self::META_IMAGE_URL, $data['image_url'] );
}
/**
* Fires after a podcast item is successfully persisted.
*
* @param int $post_id Imported post ID.
* @param array $data Mapped item data.
* @param string $status Either created or updated.
* @param object $item SimplePie item.
*/
do_action( 'rss2cpt_imported_item', (int) $post_id, $data, $status, $item );
return array(
'status' => $status,
'post_id' => (int) $post_id,
'error' => $error,
);
}
/**
* Map SimplePie values to a normalized episode array.
*
* @param object $item SimplePie item.
* @param object $feed SimplePie feed.
* @return array<string,mixed> Normalized episode data.
*/
private function map_item( $item, $feed ): array {
$guid = trim( (string) $item->get_id() );
$link = esc_url_raw( (string) $item->get_link() );
$enclosure = $item->get_enclosure();
$audio_url = $enclosure ? esc_url_raw( (string) $enclosure->get_link() ) : '';
$source_id = $guid;
$timestamp = (int) $item->get_date( 'U' );
$title = sanitize_text_field( (string) $item->get_title() );
$content = (string) $item->get_content();
$summary = $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'summary' );
$subtitle = $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'subtitle' );
$description = (string) $item->get_description();
$image_url = $this->get_episode_image_url( $item );
if ( empty( $source_id ) ) {
$source_id = ! empty( $audio_url ) ? $audio_url : $link;
}
if ( empty( $source_id ) ) {
$source_id = hash( 'sha256', $title . '|' . $timestamp );
}
if ( empty( $title ) ) {
$title = $timestamp
? sprintf(
/* translators: %s: Episode publication date. */
__( 'Podcast episode from %s', 'rss2cpt' ),
wp_date( get_option( 'date_format' ), $timestamp )
)
: __( 'Untitled podcast episode', 'rss2cpt' );
}
if ( empty( $content ) ) {
$content = ! empty( $summary ) ? $summary : $description;
}
$excerpt = $summary;
if ( empty( $excerpt ) ) {
$excerpt = ! empty( $subtitle ) ? $subtitle : $description;
}
if ( empty( $image_url ) && method_exists( $feed, 'get_image_url' ) ) {
$image_url = esc_url_raw( (string) $feed->get_image_url() );
}
$duration = sanitize_text_field( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'duration' ) );
$explicit = strtolower( sanitize_text_field( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'explicit' ) ) );
$data = array(
'title' => $title,
'content' => wp_kses_post( $content ),
'excerpt' => sanitize_textarea_field( wp_strip_all_tags( $excerpt ) ),
'timestamp' => $timestamp,
'link' => $link,
'audio_url' => $audio_url,
'audio_type' => $enclosure ? sanitize_mime_type( (string) $enclosure->get_type() ) : '',
'audio_length' => $enclosure ? absint( $enclosure->get_length() ) : 0,
'duration' => $duration,
'duration_seconds' => $this->duration_to_seconds( $duration ),
'season' => absint( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'season' ) ),
'episode' => absint( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'episode' ) ),
'episode_type' => sanitize_key( $this->get_tag_value( $item, self::ITUNES_NAMESPACE, 'episodeType' ) ),
'explicit' => in_array( $explicit, array( 'yes', 'true', 'explicit' ), true ) ? '1' : '0',
'image_url' => $image_url,
'source_id' => sanitize_text_field( $source_id ),
'source_key' => $source_id ? hash( 'sha256', $source_id ) : '',
'creator' => sanitize_text_field( $this->get_tag_value( $item, self::DC_NAMESPACE, 'creator' ) ),
);
$data['fingerprint'] = hash( 'sha256', (string) wp_json_encode( $data ) );
return $data;
}
/**
* Build protected post metadata for an episode.
*
* @param array<string,mixed> $data Normalized item data.
* @param string $feed_url Feed URL.
* @return array<string,mixed> Post metadata.
*/
private function build_meta_input( array $data, string $feed_url ): array {
return array(
self::META_SOURCE_KEY => $data['source_key'],
self::META_FEED_URL => $feed_url,
self::META_SOURCE_ID => $data['source_id'],
self::META_EPISODE_LINK => $data['link'],
self::META_AUDIO_URL => $data['audio_url'],
self::META_AUDIO_TYPE => $data['audio_type'],
self::META_AUDIO_LENGTH => $data['audio_length'],
self::META_DURATION => $data['duration'],
self::META_DURATION_SECONDS => $data['duration_seconds'],
self::META_SEASON => $data['season'],
self::META_EPISODE => $data['episode'],
self::META_EPISODE_TYPE => $data['episode_type'],
self::META_EXPLICIT => $data['explicit'],
self::META_FINGERPRINT => $data['fingerprint'],
self::META_CREATOR => $data['creator'],
);
}
/**
* Find a previously imported episode by stable source key.
*
* @param string $source_key Stable source key.
* @return int Post ID, or zero when not found.
*/
private function find_existing_post( string $source_key ): int {
$query = new WP_Query(
array(
'post_type' => $this->post_type,
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
// A protected source key is the canonical cross-run identity.
'meta_key' => self::META_SOURCE_KEY, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
'meta_value' => $source_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value
)
);
return empty( $query->posts ) ? 0 : (int) $query->posts[0];
}
/**
* Sideload and attach a changed episode image.
*
* @param int $post_id Episode post ID.
* @param string $image_url Remote image URL.
* @param string $title Episode title.
* @return int|WP_Error Attachment ID or error.
*/
private function set_episode_image( int $post_id, string $image_url, string $title ) {
$current_url = (string) get_post_meta( $post_id, self::META_IMAGE_URL, true );
if ( has_post_thumbnail( $post_id ) && $current_url === $image_url ) {
return (int) get_post_thumbnail_id( $post_id );
}
if ( ! function_exists( 'media_handle_sideload' ) ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
}
$this->remote_size_limit = self::MAX_IMAGE_BYTES;
add_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10, 2 );
try {
$temp_file = download_url( $image_url, 30 );
} finally {
remove_filter( 'http_request_args', array( $this, 'limit_remote_response_size' ), 10 );
$this->remote_size_limit = 0;
}
if ( is_wp_error( $temp_file ) ) {
return $temp_file;
}
if ( ! file_exists( $temp_file ) || filesize( $temp_file ) >= self::MAX_IMAGE_BYTES ) {
wp_delete_file( $temp_file );
return new WP_Error( 'rss2cpt_image_too_large', __( 'The episode image exceeds the ten megabyte limit.', 'rss2cpt' ) );
}
$image_mime = wp_get_image_mime( $temp_file );
$extensions = array(
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/avif' => 'avif',
);
if ( ! $image_mime || ! isset( $extensions[ $image_mime ] ) ) {
wp_delete_file( $temp_file );
return new WP_Error( 'rss2cpt_invalid_image', __( 'The episode artwork is not a supported image.', 'rss2cpt' ) );
}
$dimensions = wp_getimagesize( $temp_file );
if ( ! $dimensions || $dimensions[0] > 10000 || $dimensions[1] > 10000 ) {
wp_delete_file( $temp_file );
return new WP_Error( 'rss2cpt_invalid_image_dimensions', __( 'The episode artwork dimensions are invalid or too large.', 'rss2cpt' ) );
}
$file_array = array(
'name' => sanitize_file_name( $title . '.' . $extensions[ $image_mime ] ),
'tmp_name' => $temp_file,
);
$attachment_id = media_handle_sideload( $file_array, $post_id, $title );
if ( is_wp_error( $attachment_id ) ) {
wp_delete_file( $temp_file );
return $attachment_id;
}
set_post_thumbnail( $post_id, (int) $attachment_id );
update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $title ) );
update_post_meta( $post_id, self::META_IMAGE_URL, esc_url_raw( $image_url ) );
return (int) $attachment_id;
}
/**
* Get an episode image from iTunes or Media RSS data.
*
* @param object $item SimplePie item.
* @return string Image URL.
*/
private function get_episode_image_url( $item ): string {
$image_url = $this->get_tag_attribute( $item, self::ITUNES_NAMESPACE, 'image', 'href' );
if ( $image_url ) {
return esc_url_raw( $image_url );
}
$image_url = $this->get_tag_attribute( $item, self::MEDIA_NAMESPACE, 'thumbnail', 'url' );
if ( $image_url ) {
return esc_url_raw( $image_url );
}
return esc_url_raw( $this->get_tag_attribute( $item, self::MEDIA_NAMESPACE, 'content', 'url' ) );
}
/**
* Get the first value of a namespaced SimplePie tag.
*
* @param object $item SimplePie item.
* @param string $xml_namespace XML namespace.
* @param string $tag Tag name.
* @return string Tag value.
*/
private function get_tag_value( $item, string $xml_namespace, string $tag ): string {
$tags = $item->get_item_tags( $xml_namespace, $tag );
return isset( $tags[0]['data'] ) ? trim( (string) $tags[0]['data'] ) : '';
}
/**
* Get the first attribute of a namespaced SimplePie tag.
*
* @param object $item SimplePie item.
* @param string $xml_namespace XML namespace.
* @param string $tag Tag name.
* @param string $attribute Attribute name.
* @return string Attribute value.
*/
private function get_tag_attribute( $item, string $xml_namespace, string $tag, string $attribute ): string {
$tags = $item->get_item_tags( $xml_namespace, $tag );
return isset( $tags[0]['attribs'][''][ $attribute ] )
? trim( (string) $tags[0]['attribs'][''][ $attribute ] )
: '';
}
/**
* Normalize an iTunes duration to seconds.
*
* @param string $duration Duration in seconds, MM:SS, or HH:MM:SS.
* @return int Duration in seconds.
*/
private function duration_to_seconds( string $duration ): int {
if ( '' === $duration ) {
return 0;
}
if ( ctype_digit( $duration ) ) {
return absint( $duration );
}
$parts = array_map( 'absint', explode( ':', $duration ) );
if ( 2 === count( $parts ) ) {
return ( $parts[0] * MINUTE_IN_SECONDS ) + $parts[1];
}
if ( 3 === count( $parts ) ) {
return ( $parts[0] * HOUR_IN_SECONDS ) + ( $parts[1] * MINUTE_IN_SECONDS ) + $parts[2];
}
return 0;
}
/**
* Sanitize caller-provided import options.
*
* @param array<string,mixed> $options Raw options.
* @return array<string,mixed> Sanitized options.
*/
private function sanitize_options( array $options ): array {
$allowed_statuses = array( 'draft', 'pending', 'private', 'publish' );
$post_status = sanitize_key( (string) $options['post_status'] );
$post_author = absint( $options['author_id'] );
if ( 0 === $post_author ) {
$users = get_users(
array(
'role__in' => array( 'administrator', 'editor', 'author' ),
'number' => 1,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
)
);
$post_author = isset( $users[0] ) ? absint( $users[0] ) : 0;
}
return array(
'post_status' => in_array( $post_status, $allowed_statuses, true ) ? $post_status : 'publish',
'post_author' => $post_author,
'import_image' => (bool) $options['import_image'],
'update_existing' => (bool) $options['update_existing'],
'item_limit' => absint( $options['item_limit'] ),
);
}
/**
* Acquire a short-lived per-feed import lock.
*
* Add_option() provides the atomic operation needed to prevent overlapping
* cron and manual imports. Expired locks are recoverable after 30 minutes.
*
* @param string $lock_key Lock option name.
* @return string|false Lock owner token, or false when already locked.
*/
private function acquire_lock( string $lock_key ) {
$token = wp_generate_uuid4();
$value = array(
'token' => $token,
'expires' => time() + ( 30 * MINUTE_IN_SECONDS ),
);
if ( $this->create_lock_option( $lock_key, $value ) ) {
return $token;
}
$current = get_option( $lock_key, array() );
$current_expiry = is_array( $current ) && isset( $current['expires'] ) ? (int) $current['expires'] : 0;
if ( $current_expiry && $current_expiry >= time() ) {
return false;
}
delete_option( $lock_key );
return $this->create_lock_option( $lock_key, $value ) ? $token : false;
}
/**
* Atomically create a lock option.
*
* @param string $lock_key Lock option name.
* @param array<string,mixed> $value Lock value.
* @phpstan-impure
*/
private function create_lock_option( string $lock_key, array $value ): bool {
return add_option( $lock_key, $value, '', false );
}
/**
* Renew a lock still owned by this process.
*
* @param string $lock_key Lock option name.
* @param string $token Lock owner token.
*/
private function renew_lock( string $lock_key, string $token ): void {
$current = get_option( $lock_key, array() );
if ( is_array( $current ) && isset( $current['token'] ) && hash_equals( $token, (string) $current['token'] ) ) {
$current['expires'] = time() + ( 30 * MINUTE_IN_SECONDS );
update_option( $lock_key, $current, false );
}
}
/**
* Release an import lock.
*
* @param string $lock_key Lock option name.
* @param string $token Lock owner token.
* @return void
*/
private function release_lock( string $lock_key, string $token ): void {
$current = get_option( $lock_key, array() );
if ( is_array( $current ) && isset( $current['token'] ) && hash_equals( $token, (string) $current['token'] ) ) {
delete_option( $lock_key );
}
}
/**
* Create an empty structured import result.
*
* @param string $feed_url Feed URL.
* @return array<string,mixed> Empty result.
*/
private function new_result( string $feed_url ): array {
return array(
'feed_url' => $feed_url,
'post_type' => $this->post_type,
'total' => 0,
'created' => 0,
'updated' => 0,
'skipped' => 0,
'failed' => 0,
'post_ids' => array(
'created' => array(),
'updated' => array(),
'skipped' => array(),
'failed' => array(),
),
'errors' => array(),
'warnings' => 0,
'has_more' => false,
);
}
/**
* Convert WP_Error to a serializable result entry.
*
* @param WP_Error $error WordPress error.
* @param string $source_id Optional source identifier.
* @param int $post_id Optional post ID.
* @param string $severity Error severity.
* @return array<string,mixed> Serializable error.
*/
private function format_error( WP_Error $error, string $source_id = '', int $post_id = 0, string $severity = 'error' ): array {
return array(
'code' => $error->get_error_code(),
'message' => $error->get_error_message(),
'source_id' => $source_id,
'post_id' => $post_id,
'severity' => $severity,
);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* Plugin option access.
*
* @package RSS2CPT
*/
namespace RSS2CPT;
defined( 'ABSPATH' ) || exit;
/**
* Provides normalized access to plugin settings.
*/
final class Options {
public const KEY = 'rss2cpt_settings';
/**
* Return normalized settings.
*
* @return array<string,mixed>
*/
public static function get(): array {
$defaults = array(
'feed_url' => '',
'post_type' => 'podcast_episode',
'post_status' => 'publish',
'author_id' => 0,
'schedule' => 'hourly',
'import_image' => 1,
'update_existing' => 0,
'item_limit' => 25,
);
$value = get_option( self::KEY, array() );
return wp_parse_args( is_array( $value ) ? $value : array(), $defaults );
}
}
+198
View File
@@ -0,0 +1,198 @@
<?php
/**
* Main plugin composition root.
*
* @package RSS2CPT
*/
namespace RSS2CPT;
use RSS2CPT\Admin\Settings;
use RSS2CPT\Admin\EpisodeMetaBox;
use RSS2CPT\Import\Importer;
defined( 'ABSPATH' ) || exit;
/**
* Composes and starts the plugin services.
*/
final class Plugin {
/**
* Singleton plugin instance.
*
* @var self|null
*/
private static $instance;
/**
* Whether services have already been registered.
*
* @var bool
*/
private $booted = false;
/**
* Whether this plugin registered the fallback post type in this request.
*
* @var bool
*/
private $owns_default_post_type = false;
/**
* Get the plugin instance.
*/
public static function instance(): self {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Register runtime services.
*/
public function boot(): void {
if ( $this->booted ) {
return;
}
$this->booted = true;
add_action( 'init', array( $this, 'register_default_post_type' ) );
add_action( 'init', array( $this, 'register_import_meta' ), 20 );
$importer = new Importer();
$scheduler = new Scheduler( $importer );
$scheduler->register();
if ( is_admin() ) {
$meta_box = new EpisodeMetaBox();
$meta_box->register();
$settings = new Settings( $importer, $scheduler );
$settings->register();
}
}
/**
* Register the bundled fallback episode post type.
*/
public function register_default_post_type(): void {
if ( post_type_exists( 'podcast_episode' ) ) {
return;
}
register_post_type(
'podcast_episode',
array(
'labels' => array(
'name' => __( 'Podcast Episodes', 'rss2cpt' ),
'singular_name' => __( 'Podcast Episode', 'rss2cpt' ),
),
'public' => true,
'show_in_rest' => true,
'has_archive' => true,
'rewrite' => array( 'slug' => 'podcast' ),
'menu_icon' => 'dashicons-microphone',
'supports' => array( 'title', 'editor', 'excerpt', 'thumbnail', 'author', 'custom-fields' ),
)
);
$this->owns_default_post_type = true;
}
/**
* Register imported metadata for REST and editor consumers.
*/
public function register_import_meta(): void {
$settings = Options::get();
$post_type = sanitize_key( (string) $settings['post_type'] );
$integers = array(
Importer::META_AUDIO_LENGTH,
Importer::META_DURATION_SECONDS,
Importer::META_SEASON,
Importer::META_EPISODE,
);
$urls = array(
Importer::META_FEED_URL,
Importer::META_EPISODE_LINK,
Importer::META_AUDIO_URL,
Importer::META_IMAGE_URL,
);
$strings = array(
Importer::META_SOURCE_ID,
Importer::META_AUDIO_TYPE,
Importer::META_DURATION,
Importer::META_EPISODE_TYPE,
Importer::META_EXPLICIT,
Importer::META_CREATOR,
);
foreach ( $integers as $meta_key ) {
$this->register_meta_key( $post_type, $meta_key, 'integer', 'absint' );
}
foreach ( $urls as $meta_key ) {
$this->register_meta_key( $post_type, $meta_key, 'string', 'esc_url_raw' );
}
foreach ( $strings as $meta_key ) {
$this->register_meta_key( $post_type, $meta_key, 'string', 'sanitize_text_field' );
}
foreach ( array( Importer::META_SOURCE_KEY, Importer::META_FINGERPRINT ) as $meta_key ) {
$this->register_meta_key( $post_type, $meta_key, 'string', 'sanitize_text_field', false );
}
}
/**
* Register one protected episode meta key.
*
* @param string $post_type Post type name.
* @param string $meta_key Metadata key.
* @param string $type REST scalar type.
* @param callable $sanitize Sanitization callback.
* @param bool $show_rest Whether to expose the field through REST.
*/
private function register_meta_key( string $post_type, string $meta_key, string $type, callable $sanitize, bool $show_rest = true ): void {
register_post_meta(
$post_type,
$meta_key,
array(
'type' => $type,
'single' => true,
'show_in_rest' => $show_rest,
'sanitize_callback' => $sanitize,
'auth_callback' => array( __CLASS__, 'authorize_meta_change' ),
)
);
}
/**
* Authorize writes to protected episode metadata.
*
* @param bool $allowed Default decision.
* @param string $meta_key Metadata key.
* @param int $post_id Post ID.
*/
public static function authorize_meta_change( $allowed, $meta_key, $post_id ): bool {
unset( $allowed, $meta_key );
return current_user_can( 'edit_post', (int) $post_id );
}
/**
* Run plugin activation tasks.
*/
public static function activate(): void {
self::instance()->register_default_post_type();
flush_rewrite_rules();
}
/**
* Run plugin deactivation tasks.
*/
public static function deactivate(): void {
Scheduler::clear();
$plugin = self::instance();
if ( $plugin->owns_default_post_type && post_type_exists( 'podcast_episode' ) ) {
unregister_post_type( 'podcast_episode' );
}
flush_rewrite_rules();
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* WP-Cron integration.
*
* @package RSS2CPT
*/
namespace RSS2CPT;
use RSS2CPT\Import\Importer;
defined( 'ABSPATH' ) || exit;
/**
* Keeps the recurring import synchronized with plugin settings.
*/
final class Scheduler {
public const HOOK = 'rss2cpt_scheduled_import';
public const CONTINUATION_HOOK = 'rss2cpt_continue_import';
/**
* Feed importer.
*
* @var Importer
*/
private $importer;
/**
* Create the scheduler.
*
* @param Importer $importer Feed importer.
*/
public function __construct( Importer $importer ) {
$this->importer = $importer;
}
/**
* Register cron and settings hooks.
*/
public function register(): void {
add_action( self::HOOK, array( $this, 'run' ) );
add_action( self::CONTINUATION_HOOK, array( $this, 'run' ) );
add_action( 'update_option_' . Options::KEY, array( $this, 'settings_updated' ), 10, 2 );
$this->ensure_scheduled();
}
/**
* Run a scheduled import.
*/
public function run(): void {
$result = $this->importer->import( Options::get() );
$this->record_result( $result );
if ( is_array( $result ) && ! empty( $result['has_more'] ) ) {
$this->schedule_continuation();
}
}
/**
* Schedule the next bounded backfill batch.
*/
public function schedule_continuation(): void {
$settings = Options::get();
if ( 'disabled' !== $settings['schedule'] && ! wp_next_scheduled( self::CONTINUATION_HOOK ) ) {
wp_schedule_single_event( time() + MINUTE_IN_SECONDS, self::CONTINUATION_HOOK );
}
}
/**
* Reschedule after a settings change.
*
* @param mixed $old_value Previous option value.
* @param mixed $new_value New option value.
*/
public function settings_updated( $old_value, $new_value ): void {
unset( $old_value, $new_value );
self::clear();
$this->ensure_scheduled();
}
/**
* Ensure the configured recurring event exists.
*/
public function ensure_scheduled(): void {
$settings = Options::get();
$schedule = (string) $settings['schedule'];
if ( empty( $settings['feed_url'] ) || 'disabled' === $schedule || wp_next_scheduled( self::HOOK ) ) {
return;
}
$available = wp_get_schedules();
if ( isset( $available[ $schedule ] ) ) {
wp_schedule_event( time() + MINUTE_IN_SECONDS, $schedule, self::HOOK );
}
}
/**
* Clear all scheduled imports.
*/
public static function clear(): void {
wp_clear_scheduled_hook( self::HOOK );
wp_clear_scheduled_hook( self::CONTINUATION_HOOK );
}
/**
* Persist a bounded last-run summary for support and diagnostics.
*
* @param array<string,mixed>|\WP_Error $result Import result.
*/
public function record_result( $result ): void {
$summary = array(
'timestamp' => time(),
'success' => ! is_wp_error( $result ),
);
if ( is_wp_error( $result ) ) {
$summary['error_code'] = sanitize_key( $result->get_error_code() );
$summary['message'] = sanitize_text_field( wp_trim_words( $result->get_error_message(), 30 ) );
} else {
foreach ( array( 'created', 'updated', 'skipped', 'failed', 'warnings' ) as $key ) {
$summary[ $key ] = isset( $result[ $key ] ) ? absint( $result[ $key ] ) : 0;
}
}
update_option( 'rss2cpt_last_import', $summary, false );
}
}