✨feature: Initial commit
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
name: Composer, lint, static analysis, and unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '7.4'
|
||||||
|
coverage: none
|
||||||
|
tools: composer:v2
|
||||||
|
- run: composer validate --strict
|
||||||
|
- run: composer install --no-interaction --prefer-dist
|
||||||
|
- run: composer lint
|
||||||
|
- run: composer analyse
|
||||||
|
- run: composer test
|
||||||
|
|
||||||
|
smoke:
|
||||||
|
name: WordPress activation smoke test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
env:
|
||||||
|
MYSQL_DATABASE: wordpress
|
||||||
|
MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
|
||||||
|
ports:
|
||||||
|
- 3306:3306
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mysqladmin ping"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=10
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '7.4'
|
||||||
|
coverage: none
|
||||||
|
tools: wp-cli
|
||||||
|
- name: Install WordPress
|
||||||
|
run: |
|
||||||
|
wp core download --path=/tmp/wordpress
|
||||||
|
wp config create --path=/tmp/wordpress --dbname=wordpress --dbuser=root --dbpass='' --dbhost=127.0.0.1
|
||||||
|
wp core install --path=/tmp/wordpress --url=http://example.test --title=Smoke --admin_user=admin --admin_password=password --admin_email=admin@example.test --skip-email
|
||||||
|
- name: Activate plugin and verify CPT
|
||||||
|
run: |
|
||||||
|
ln -s "$GITHUB_WORKSPACE" /tmp/wordpress/wp-content/plugins/podcast-rss-to-cpt
|
||||||
|
wp plugin activate podcast-rss-to-cpt --path=/tmp/wordpress
|
||||||
|
test "$(wp post-type get podcast_episode --field=name --path=/tmp/wordpress)" = "podcast_episode"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/vendor/
|
||||||
|
/.phpunit.result.cache
|
||||||
|
/composer.lock
|
||||||
Vendored
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"workbench.colorCustomizations": {
|
||||||
|
"tree.indentGuidesStroke": "#3d92ec",
|
||||||
|
"activityBar.background": "#A02C18",
|
||||||
|
"titleBar.activeBackground": "#DE3F24",
|
||||||
|
"titleBar.activeForeground": "#FEF9F9",
|
||||||
|
"titleBar.inactiveBackground": "#A02C18",
|
||||||
|
"titleBar.inactiveForeground": "#FEF9F9",
|
||||||
|
"statusBar.background": "#A02C18",
|
||||||
|
"statusBar.foreground": "#FEF9F9",
|
||||||
|
"statusBar.debuggingBackground": "#A02C18",
|
||||||
|
"statusBar.debuggingForeground": "#FEF9F9",
|
||||||
|
"statusBar.noFolderBackground": "#A02C18",
|
||||||
|
"statusBar.noFolderForeground": "#FEF9F9"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"name": "rss2cpt/podcast-rss-to-cpt",
|
||||||
|
"description": "A lightweight WordPress podcast RSS importer for custom post types.",
|
||||||
|
"type": "wordpress-plugin",
|
||||||
|
"license": "GPL-2.0-or-later",
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.4"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||||
|
"phpcompatibility/phpcompatibility-wp": "^2.1",
|
||||||
|
"php-stubs/wordpress-stubs": "^6.2",
|
||||||
|
"phpstan/phpstan": "^2.2",
|
||||||
|
"phpunit/phpunit": "^9.6",
|
||||||
|
"squizlabs/php_codesniffer": "^3.10",
|
||||||
|
"wp-coding-standards/wpcs": "^3.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"lint": "phpcs",
|
||||||
|
"lint:fix": "phpcbf",
|
||||||
|
"analyse": "phpstan analyse --memory-limit=1G",
|
||||||
|
"test": "phpunit",
|
||||||
|
"check": [
|
||||||
|
"@composer validate --strict",
|
||||||
|
"@lint",
|
||||||
|
"@analyse",
|
||||||
|
"@test"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"allow-plugins": {
|
||||||
|
"dealerdirect/phpcodesniffer-composer-installer": true
|
||||||
|
},
|
||||||
|
"sort-packages": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<ruleset name="Podcast RSS to CPT">
|
||||||
|
<description>Project coding standards.</description>
|
||||||
|
<file>podcast-rss-to-cpt.php</file>
|
||||||
|
<file>src</file>
|
||||||
|
<file>tests</file>
|
||||||
|
<exclude-pattern>vendor/*</exclude-pattern>
|
||||||
|
<arg name="basepath" value="." />
|
||||||
|
<arg name="colors" />
|
||||||
|
<arg value="sp" />
|
||||||
|
<rule ref="WordPress">
|
||||||
|
<exclude name="WordPress.Files.FileName.NotHyphenatedLowercase" />
|
||||||
|
<exclude name="WordPress.Files.FileName.InvalidClassFileName" />
|
||||||
|
</rule>
|
||||||
|
<rule ref="WordPress-Extra" />
|
||||||
|
<rule ref="WordPress-Docs" />
|
||||||
|
<rule ref="PHPCompatibilityWP">
|
||||||
|
<exclude name="Internal.NoCodeFound" />
|
||||||
|
</rule>
|
||||||
|
<config name="testVersion" value="7.4-" />
|
||||||
|
</ruleset>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
parameters:
|
||||||
|
level: 6
|
||||||
|
paths:
|
||||||
|
- podcast-rss-to-cpt.php
|
||||||
|
- src
|
||||||
|
bootstrapFiles:
|
||||||
|
- vendor/php-stubs/wordpress-stubs/wordpress-stubs.php
|
||||||
|
- tools/phpstan-bootstrap.php
|
||||||
|
excludePaths:
|
||||||
|
analyse:
|
||||||
|
- vendor
|
||||||
|
treatPhpDocTypesAsCertain: false
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit bootstrap="tests/bootstrap.php" colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Podcast RSS to CPT">
|
||||||
|
<directory suffix="Test.php">tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Plugin Name: Podcast RSS to CPT
|
||||||
|
* Description: Imports podcast RSS episodes into a configurable WordPress post type.
|
||||||
|
* Version: 1.0.0
|
||||||
|
* Requires at least: 6.2
|
||||||
|
* Requires PHP: 7.4
|
||||||
|
* Author: RSS2CPT Contributors
|
||||||
|
* License: GPL-2.0-or-later
|
||||||
|
* Text Domain: rss2cpt
|
||||||
|
*
|
||||||
|
* @package RSS2CPT
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'ABSPATH' ) || exit;
|
||||||
|
|
||||||
|
define( 'RSS2CPT_VERSION', '1.0.0' );
|
||||||
|
define( 'RSS2CPT_FILE', __FILE__ );
|
||||||
|
define( 'RSS2CPT_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
|
|
||||||
|
require_once RSS2CPT_PATH . 'src/Options.php';
|
||||||
|
require_once RSS2CPT_PATH . 'src/Import/Importer.php';
|
||||||
|
require_once RSS2CPT_PATH . 'src/Scheduler.php';
|
||||||
|
require_once RSS2CPT_PATH . 'src/Admin/EpisodeMetaBox.php';
|
||||||
|
require_once RSS2CPT_PATH . 'src/Admin/Settings.php';
|
||||||
|
require_once RSS2CPT_PATH . 'src/Plugin.php';
|
||||||
|
|
||||||
|
register_activation_hook( __FILE__, array( 'RSS2CPT\\Plugin', 'activate' ) );
|
||||||
|
register_deactivation_hook( __FILE__, array( 'RSS2CPT\\Plugin', 'deactivate' ) );
|
||||||
|
|
||||||
|
add_action(
|
||||||
|
'plugins_loaded',
|
||||||
|
static function () {
|
||||||
|
RSS2CPT\Plugin::instance()->boot();
|
||||||
|
}
|
||||||
|
);
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
=== Podcast RSS to CPT ===
|
||||||
|
Contributors: rss2cpt
|
||||||
|
Tags: podcast, rss, importer, custom post type
|
||||||
|
Requires at least: 6.2
|
||||||
|
Requires PHP: 7.4
|
||||||
|
Stable tag: 1.0.0
|
||||||
|
License: GPLv2 or later
|
||||||
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
|
Import a podcast RSS feed into a configurable WordPress custom post type.
|
||||||
|
|
||||||
|
== Description ==
|
||||||
|
|
||||||
|
Podcast RSS to CPT imports existing and newly published podcast episodes using WordPress's bundled feed parser and WP-Cron. It can use the included Podcast Episode post type or any registered post type that supports the fields you need. Imports default to bounded batches of 25 new or changed episodes and automatically continue while scheduling is enabled.
|
||||||
|
|
||||||
|
Imported data includes the title, full synopsis, excerpt, publication date, episode page URL, enclosure/audio URL and type, duration, season, episode number, explicit flag, source identifiers, and episode artwork. Artwork can be added to the media library and assigned as the featured image.
|
||||||
|
|
||||||
|
== Installation ==
|
||||||
|
|
||||||
|
1. Copy this directory into `wp-content/plugins/podcast-rss-to-cpt`.
|
||||||
|
2. Activate Podcast RSS to CPT.
|
||||||
|
3. Open Settings > Podcast RSS Import.
|
||||||
|
4. Add the feed URL, select a target post type, and save.
|
||||||
|
5. Use Run import now for the initial import. WP-Cron will check for new or changed episodes on the selected schedule.
|
||||||
|
|
||||||
|
== Imported metadata ==
|
||||||
|
|
||||||
|
Themes and integrations can use these protected post meta keys:
|
||||||
|
|
||||||
|
* `_rss2cpt_source_id`
|
||||||
|
* `_rss2cpt_source_key`
|
||||||
|
* `_rss2cpt_feed_url`
|
||||||
|
* `_rss2cpt_episode_url`
|
||||||
|
* `_rss2cpt_audio_url`
|
||||||
|
* `_rss2cpt_audio_type`
|
||||||
|
* `_rss2cpt_audio_length`
|
||||||
|
* `_rss2cpt_duration`
|
||||||
|
* `_rss2cpt_duration_seconds`
|
||||||
|
* `_rss2cpt_season`
|
||||||
|
* `_rss2cpt_episode_number`
|
||||||
|
* `_rss2cpt_episode_type`
|
||||||
|
* `_rss2cpt_explicit`
|
||||||
|
* `_rss2cpt_image_url`
|
||||||
|
* `_rss2cpt_creator`
|
||||||
|
|
||||||
|
Episode fields are registered with types for the configured post type. Public episode fields are available through the WordPress REST API; internal deduplication fingerprints remain private. Enable “Refresh feed-owned fields” if publisher changes should overwrite the imported title, synopsis, excerpt, date, status, author, metadata, and artwork; it is off by default to preserve editorial changes.
|
||||||
|
|
||||||
|
== Notes ==
|
||||||
|
|
||||||
|
WP-Cron runs when the site receives traffic. For reliable publishing schedules, invoke `wp-cron.php` from a real system cron. Removing the plugin keeps imported posts and downloaded media but removes plugin settings.
|
||||||
@@ -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>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 publisher’s 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 (0–500). 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Basic project smoke tests.
|
||||||
|
*
|
||||||
|
* @package RSS2CPT
|
||||||
|
*/
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies project-level invariants without loading WordPress.
|
||||||
|
*/
|
||||||
|
final class SmokeTest extends TestCase {
|
||||||
|
/**
|
||||||
|
* Verify the runtime supports the plugin baseline.
|
||||||
|
*/
|
||||||
|
public function test_supported_php_runtime(): void {
|
||||||
|
$this->assertTrue( version_compare( PHP_VERSION, '7.4', '>=' ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify the persisted option name remains stable.
|
||||||
|
*/
|
||||||
|
public function test_option_key_is_stable(): void {
|
||||||
|
$this->assertSame( 'rss2cpt_settings', RSS2CPT\Options::KEY );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify backfills use a safe bounded default.
|
||||||
|
*/
|
||||||
|
public function test_default_import_limit_is_bounded(): void {
|
||||||
|
$settings = RSS2CPT\Options::get();
|
||||||
|
$this->assertSame( 25, $settings['item_limit'] );
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify ongoing imports do not inherit WordPress's long feed cache.
|
||||||
|
*/
|
||||||
|
public function test_importer_uses_short_feed_cache(): void {
|
||||||
|
$importer = new RSS2CPT\Import\Importer();
|
||||||
|
$this->assertSame( 300, $importer->filter_feed_cache_lifetime( 43200, 'https://example.test/feed.xml' ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* PHPUnit bootstrap.
|
||||||
|
*
|
||||||
|
* @package RSS2CPT
|
||||||
|
*/
|
||||||
|
|
||||||
|
define( 'ABSPATH', dirname( __DIR__ ) . '/' );
|
||||||
|
define( 'MINUTE_IN_SECONDS', 60 );
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal option stub for isolated settings tests.
|
||||||
|
*
|
||||||
|
* @param string $option Option name.
|
||||||
|
* @param mixed $default_value Default value.
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
function get_option( $option, $default_value = false ) {
|
||||||
|
unset( $option );
|
||||||
|
return $default_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal wp_parse_args stub for isolated settings tests.
|
||||||
|
*
|
||||||
|
* @param array $args Supplied values.
|
||||||
|
* @param array $defaults Default values.
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
function wp_parse_args( $args, $defaults = array() ) {
|
||||||
|
return array_merge( $defaults, $args );
|
||||||
|
}
|
||||||
|
|
||||||
|
require_once dirname( __DIR__ ) . '/src/Options.php';
|
||||||
|
require_once dirname( __DIR__ ) . '/src/Import/Importer.php';
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* WordPress runtime constants required during static analysis.
|
||||||
|
*
|
||||||
|
* @package RSS2CPT
|
||||||
|
*/
|
||||||
|
|
||||||
|
if ( ! defined( 'ABSPATH' ) ) {
|
||||||
|
define( 'ABSPATH', __DIR__ . '/wordpress/' );
|
||||||
|
}
|
||||||
|
if ( ! defined( 'WPINC' ) ) {
|
||||||
|
define( 'WPINC', 'wp-includes' );
|
||||||
|
}
|
||||||
|
if ( ! defined( 'MINUTE_IN_SECONDS' ) ) {
|
||||||
|
define( 'MINUTE_IN_SECONDS', 60 );
|
||||||
|
}
|
||||||
|
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
|
||||||
|
define( 'HOUR_IN_SECONDS', 3600 );
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// Static-analysis placeholder. WordPress provides this file at runtime.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// Static-analysis placeholder. WordPress provides this file at runtime.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// Static-analysis placeholder. WordPress provides this file at runtime.
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
// Static-analysis placeholder. WordPress provides this file at runtime.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Uninstall cleanup.
|
||||||
|
*
|
||||||
|
* Imported posts and media are intentionally retained.
|
||||||
|
*
|
||||||
|
* @package RSS2CPT
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined( 'WP_UNINSTALL_PLUGIN' ) || exit;
|
||||||
|
|
||||||
|
delete_option( 'rss2cpt_settings' );
|
||||||
|
delete_option( 'rss2cpt_last_import' );
|
||||||
Reference in New Issue
Block a user