diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php
index 10f5c68..77970fe 100644
--- a/src/Admin/Settings.php
+++ b/src/Admin/Settings.php
@@ -129,6 +129,7 @@ final class Settings {
'import_image' => __( 'Episode images', 'rss2cpt' ),
'update_existing' => __( 'Existing episodes', 'rss2cpt' ),
'item_limit' => __( 'Per-run import limit', 'rss2cpt' ),
+ 'season_taxonomy' => __( 'Season taxonomy', 'rss2cpt' ),
);
foreach ( $fields as $field => $label ) {
@@ -208,6 +209,17 @@ final class Settings {
$output['update_existing'] = empty( $input['update_existing'] ) ? 0 : 1;
$output['item_limit'] = isset( $input['item_limit'] ) ? min( 500, absint( $input['item_limit'] ) ) : 0;
+ $season_taxonomy = isset( $input['season_taxonomy'] ) ? sanitize_key( wp_unslash( $input['season_taxonomy'] ) ) : '';
+ if ( '' !== $season_taxonomy && ! taxonomy_exists( $season_taxonomy ) ) {
+ add_settings_error(
+ Options::KEY,
+ 'rss2cpt_invalid_season_taxonomy',
+ esc_html__( 'Enter the slug of an existing taxonomy, or leave blank to skip season assignment.', 'rss2cpt' )
+ );
+ $season_taxonomy = (string) $current['season_taxonomy'];
+ }
+ $output['season_taxonomy'] = $season_taxonomy;
+
return $output;
}
@@ -588,6 +600,17 @@ final class Settings {
);
}
+ /** Render season-taxonomy slug field. */
+ public function render_season_taxonomy_field(): void {
+ $value = (string) $this->get_settings()['season_taxonomy'];
+ printf(
+ '
',
+ esc_attr( Options::KEY ),
+ esc_attr( $value ),
+ esc_html__( 'Slug of the taxonomy to assign each episode’s season term to (e.g. series_season). The plugin creates the term from the feed’s itunes:season if it does not yet exist. Leave blank to skip taxonomy assignment.', 'rss2cpt' )
+ );
+ }
+
/**
* Determine whether a post type is a valid import target.
*
diff --git a/src/Import/Importer.php b/src/Import/Importer.php
index 398579d..4f2ab21 100644
--- a/src/Import/Importer.php
+++ b/src/Import/Importer.php
@@ -334,6 +334,8 @@ final class Importer {
);
}
+ $this->assign_season_term( (int) $post_id, $data, $options );
+
$error = null;
if ( $options['import_image'] && ! empty( $data['image_url'] ) ) {
$image_result = $this->set_episode_image( (int) $post_id, $data['image_url'], $data['title'] );
@@ -524,6 +526,56 @@ final class Importer {
return empty( $query->posts ) ? 0 : (int) $query->posts[0];
}
+ /**
+ * Assign the configured season taxonomy term based on itunes:season.
+ *
+ * No-op when the option is empty, when the feed item has no season value,
+ * when the season term cannot be created, or when the post type is not
+ * attached to the configured taxonomy.
+ *
+ * @param int $post_id Imported post ID.
+ * @param array $data Normalized item data.
+ * @param array $options Sanitized import options.
+ * @return int|false Assigned term ID, or false on no-op.
+ */
+ public function assign_season_term( int $post_id, array $data, array $options ) {
+ $taxonomy = isset( $options['season_taxonomy'] ) ? (string) $options['season_taxonomy'] : '';
+ if ( '' === $taxonomy ) {
+ return false;
+ }
+ if ( ! taxonomy_exists( $taxonomy ) ) {
+ return false;
+ }
+ if ( ! is_object_in_taxonomy( $this->post_type, $taxonomy ) ) {
+ return false;
+ }
+
+ $season = isset( $data['season'] ) ? (int) $data['season'] : 0;
+ if ( $season <= 0 ) {
+ return false;
+ }
+
+ $term_name = (string) $season;
+ $term = get_term_by( 'name', $term_name, $taxonomy );
+ if ( ! $term ) {
+ $created = wp_insert_term( $term_name, $taxonomy );
+ if ( is_wp_error( $created ) ) {
+ return false;
+ }
+ $term_id = isset( $created['term_id'] ) ? (int) $created['term_id'] : 0;
+ } else {
+ $term_id = (int) $term->term_id;
+ }
+
+ if ( 0 === $term_id ) {
+ return false;
+ }
+
+ wp_set_object_terms( $post_id, array( $term_id ), $taxonomy, false );
+
+ return $term_id;
+ }
+
/**
* Sideload and attach a changed episode image.
*
@@ -697,12 +749,18 @@ final class Importer {
$post_author = isset( $users[0] ) ? absint( $users[0] ) : 0;
}
+ $season_taxonomy = isset( $options['season_taxonomy'] ) ? sanitize_key( (string) $options['season_taxonomy'] ) : '';
+ if ( '' !== $season_taxonomy && ! taxonomy_exists( $season_taxonomy ) ) {
+ $season_taxonomy = '';
+ }
+
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'] ),
+ 'season_taxonomy' => $season_taxonomy,
);
}
diff --git a/src/Options.php b/src/Options.php
index 0604169..3017320 100644
--- a/src/Options.php
+++ b/src/Options.php
@@ -30,6 +30,7 @@ final class Options {
'import_image' => 1,
'update_existing' => 0,
'item_limit' => 25,
+ 'season_taxonomy' => '',
);
$value = get_option( self::KEY, array() );
diff --git a/tests/SmokeTest.php b/tests/SmokeTest.php
index 881ff26..4eae16a 100644
--- a/tests/SmokeTest.php
+++ b/tests/SmokeTest.php
@@ -175,4 +175,130 @@ final class SmokeTest extends TestCase {
$this->assertSame( array(), $GLOBALS['rss2cpt_test_deleted_options'] );
$this->assertSame( array(), $GLOBALS['rss2cpt_test_deleted_site_transients'] );
}
+
+ /**
+ * Verify that assign_season_term resolves an existing numeric term and
+ * assigns it to the post without creating a duplicate.
+ */
+ public function test_assign_season_term_uses_existing_term(): void {
+ rss2cpt_test_reset_term_stores();
+
+ $GLOBALS['rss2cpt_test_taxonomies']['series_season'] = true;
+ $GLOBALS['rss2cpt_test_object_in_taxonomy']['podcast_episode']['series_season'] = true;
+ $GLOBALS['rss2cpt_test_terms']['series_season']['8'] = 42;
+
+ $importer = new RSS2CPT\Import\Importer();
+ $method = ( new \ReflectionClass( $importer ) )->getMethod( 'assign_season_term' );
+ $method->setAccessible( true );
+
+ $post_type = ( new \ReflectionClass( $importer ) )->getProperty( 'post_type' );
+ $post_type->setAccessible( true );
+ $post_type->setValue( $importer, 'podcast_episode' );
+
+ $term_id = $method->invoke(
+ $importer,
+ 123,
+ array( 'season' => 8 ),
+ array( 'season_taxonomy' => 'series_season' )
+ );
+
+ $this->assertSame( 42, $term_id );
+ $this->assertCount( 1, $GLOBALS['rss2cpt_test_object_terms'] );
+ $this->assertSame(
+ array(
+ 'object_id' => 123,
+ 'term_ids' => array( 42 ),
+ 'taxonomy' => 'series_season',
+ ),
+ $GLOBALS['rss2cpt_test_object_terms'][0]
+ );
+ }
+
+ /**
+ * Verify that assign_season_term creates the term when the feed exposes
+ * a season number whose term does not yet exist (e.g. a brand-new season).
+ */
+ public function test_assign_season_term_creates_missing_term(): void {
+ rss2cpt_test_reset_term_stores();
+
+ $GLOBALS['rss2cpt_test_taxonomies']['series_season'] = true;
+ $GLOBALS['rss2cpt_test_object_in_taxonomy']['podcast_episode']['series_season'] = true;
+
+ $importer = new RSS2CPT\Import\Importer();
+ $method = ( new \ReflectionClass( $importer ) )->getMethod( 'assign_season_term' );
+ $method->setAccessible( true );
+
+ $post_type = ( new \ReflectionClass( $importer ) )->getProperty( 'post_type' );
+ $post_type->setAccessible( true );
+ $post_type->setValue( $importer, 'podcast_episode' );
+
+ $term_id = $method->invoke(
+ $importer,
+ 456,
+ array( 'season' => 9 ),
+ array( 'season_taxonomy' => 'series_season' )
+ );
+
+ $this->assertGreaterThan( 0, $term_id );
+ $this->assertArrayHasKey( '9', $GLOBALS['rss2cpt_test_terms']['series_season'] );
+ $this->assertSame( $term_id, $GLOBALS['rss2cpt_test_terms']['series_season']['9'] );
+ $this->assertCount( 1, $GLOBALS['rss2cpt_test_object_terms'] );
+ $this->assertSame( array( 456, $term_id, 'series_season' ), array( $GLOBALS['rss2cpt_test_object_terms'][0]['object_id'], $GLOBALS['rss2cpt_test_object_terms'][0]['term_ids'][0], $GLOBALS['rss2cpt_test_object_terms'][0]['taxonomy'] ) );
+ }
+
+ /**
+ * Verify that assign_season_term is a no-op when no season taxonomy is
+ * configured (the option is empty by default for installs that don't use one).
+ */
+ public function test_assign_season_term_is_noop_when_taxonomy_unconfigured(): void {
+ rss2cpt_test_reset_term_stores();
+
+ $importer = new RSS2CPT\Import\Importer();
+ $method = ( new \ReflectionClass( $importer ) )->getMethod( 'assign_season_term' );
+ $method->setAccessible( true );
+
+ $post_type = ( new \ReflectionClass( $importer ) )->getProperty( 'post_type' );
+ $post_type->setAccessible( true );
+ $post_type->setValue( $importer, 'podcast_episode' );
+
+ $result = $method->invoke(
+ $importer,
+ 789,
+ array( 'season' => 3 ),
+ array( 'season_taxonomy' => '' )
+ );
+
+ $this->assertFalse( $result );
+ $this->assertSame( array(), $GLOBALS['rss2cpt_test_object_terms'] );
+ }
+
+ /**
+ * Verify that assign_season_term is a no-op when the feed item has no
+ * itunes:season value (so existing manual taxonomy assignments are not
+ * stripped).
+ */
+ public function test_assign_season_term_is_noop_when_feed_has_no_season(): void {
+ rss2cpt_test_reset_term_stores();
+
+ $GLOBALS['rss2cpt_test_taxonomies']['series_season'] = true;
+ $GLOBALS['rss2cpt_test_object_in_taxonomy']['podcast_episode']['series_season'] = true;
+
+ $importer = new RSS2CPT\Import\Importer();
+ $method = ( new \ReflectionClass( $importer ) )->getMethod( 'assign_season_term' );
+ $method->setAccessible( true );
+
+ $post_type = ( new \ReflectionClass( $importer ) )->getProperty( 'post_type' );
+ $post_type->setAccessible( true );
+ $post_type->setValue( $importer, 'podcast_episode' );
+
+ $result = $method->invoke(
+ $importer,
+ 321,
+ array( 'season' => 0 ),
+ array( 'season_taxonomy' => 'series_season' )
+ );
+
+ $this->assertFalse( $result );
+ $this->assertSame( array(), $GLOBALS['rss2cpt_test_object_terms'] );
+ }
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 0c2aa6d..5924f1a 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -86,7 +86,21 @@ function rss2cpt_test_reset_delete_logs() {
$GLOBALS['rss2cpt_test_deleted_options'] = array();
$GLOBALS['rss2cpt_test_deleted_site_transients'] = array();
}
+
+/**
+ * Reset in-memory term/taxonomy stores between tests.
+ *
+ * @return void
+ */
+function rss2cpt_test_reset_term_stores() {
+ $GLOBALS['rss2cpt_test_taxonomies'] = array();
+ $GLOBALS['rss2cpt_test_object_in_taxonomy'] = array();
+ $GLOBALS['rss2cpt_test_terms'] = array();
+ $GLOBALS['rss2cpt_test_object_terms'] = array();
+ $GLOBALS['rss2cpt_test_next_term_id'] = 1;
+}
rss2cpt_test_reset_delete_logs();
+rss2cpt_test_reset_term_stores();
/**
* Minimal do_action stub for isolated tests.
@@ -98,5 +112,101 @@ function do_action( $hook, ...$args ) {
unset( $hook, $args );
}
+/**
+ * In-memory taxonomy registry for isolated tests.
+ *
+ * Populate via $GLOBALS['rss2cpt_test_taxonomies'][$tax] = true.
+ *
+ * @param string $taxonomy Taxonomy slug.
+ * @return bool
+ */
+function taxonomy_exists( $taxonomy ) {
+ return ! empty( $GLOBALS['rss2cpt_test_taxonomies'][ (string) $taxonomy ] );
+}
+
+/**
+ * In-memory post-type/taxonomy attachment map for isolated tests.
+ *
+ * Populate via $GLOBALS['rss2cpt_test_object_in_taxonomy'][$post_type][$tax] = bool.
+ *
+ * @param string $post_type Post type slug.
+ * @param string $taxonomy Taxonomy slug.
+ * @return bool
+ */
+function is_object_in_taxonomy( $post_type, $taxonomy ) {
+ return ! empty( $GLOBALS['rss2cpt_test_object_in_taxonomy'][ (string) $post_type ][ (string) $taxonomy ] );
+}
+
+/**
+ * In-memory term store for isolated tests.
+ *
+ * Populate via $GLOBALS['rss2cpt_test_terms'][$tax][$name] = $term_id.
+ *
+ * @param string $field Field to look up (only 'name' is meaningful here).
+ * @param string|int $value Value to look up.
+ * @param string $taxonomy Taxonomy slug.
+ * @return object|null
+ */
+function get_term_by( $field, $value, $taxonomy ) {
+ $taxonomy = (string) $taxonomy;
+ if ( 'name' !== $field ) {
+ return null;
+ }
+ if ( empty( $GLOBALS['rss2cpt_test_terms'][ $taxonomy ][ (string) $value ] ) ) {
+ return null;
+ }
+ return (object) array( 'term_id' => (int) $GLOBALS['rss2cpt_test_terms'][ $taxonomy ][ (string) $value ] );
+}
+
+/**
+ * In-memory term creator for isolated tests.
+ *
+ * @param string $term Term name.
+ * @param string $taxonomy Taxonomy slug.
+ * @return array|\WP_Error
+ */
+function wp_insert_term( $term, $taxonomy ) {
+ $taxonomy = (string) $taxonomy;
+ $term = (string) $term;
+ if ( ! isset( $GLOBALS['rss2cpt_test_terms'][ $taxonomy ] ) ) {
+ $GLOBALS['rss2cpt_test_terms'][ $taxonomy ] = array();
+ }
+ if ( isset( $GLOBALS['rss2cpt_test_terms'][ $taxonomy ][ $term ] ) ) {
+ return array( 'term_id' => (int) $GLOBALS['rss2cpt_test_terms'][ $taxonomy ][ $term ] );
+ }
+ $next = ( isset( $GLOBALS['rss2cpt_test_next_term_id'] ) ? (int) $GLOBALS['rss2cpt_test_next_term_id'] : 1 );
+ $GLOBALS['rss2cpt_test_next_term_id'] = $next + 1;
+ $GLOBALS['rss2cpt_test_terms'][ $taxonomy ][ $term ] = $next;
+ return array( 'term_id' => $next );
+}
+
+/**
+ * In-memory object-term linker for isolated tests.
+ *
+ * Records (post_id, term_ids[], taxonomy) on $GLOBALS['rss2cpt_test_object_terms'].
+ *
+ * @param int $object_id Object ID.
+ * @param array|string $terms Term IDs or slugs.
+ * @param string $taxonomy Taxonomy slug.
+ */
+function wp_set_object_terms( $object_id, $terms, $taxonomy ) {
+ $terms = array_map( 'intval', (array) $terms );
+ $GLOBALS['rss2cpt_test_object_terms'][] = array(
+ 'object_id' => (int) $object_id,
+ 'term_ids' => $terms,
+ 'taxonomy' => (string) $taxonomy,
+ );
+}
+
+/**
+ * Minimal is_wp_error stub: tests construct WP_Error objects by hand.
+ *
+ * @param mixed $thing Thing to check.
+ * @return bool
+ */
+function is_wp_error( $thing ) {
+ return $thing instanceof \WP_Error;
+}
+
require_once dirname( __DIR__ ) . '/src/Options.php';
require_once dirname( __DIR__ ) . '/src/Import/Importer.php';