From 1f4c6754e2c0386056b30475daae7475a0d38f59 Mon Sep 17 00:00:00 2001 From: Keith Solomon Date: Mon, 24 Aug 2026 12:04:09 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8feature:=20Assign=20feed=20season=20to?= =?UTF-8?q?=20a=20configurable=20taxonomy=20on=20import?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Season taxonomy' setting (default empty) that points at an existing taxonomy registered against the destination post type. On every successful import the plugin reads itunes:season, looks up (or creates) a term with that numeric name in the configured taxonomy, and assigns it to the imported post via wp_set_object_terms. Behavior: - Empty option: no-op. Existing posts and installs without a season taxonomy are unaffected. - Tax that doesn't exist or isn't attached to the post type: no-op. - Feed item with no itunes:season: no-op. Manual taxonomy assignments are preserved on subsequent refreshes. - Otherwise: the feed value wins, on both create and update paths. The new assign_season_term() helper is public so it is unit-testable, and four smoke tests cover the lookup, create-on-miss, unconfigured, and no-season paths. --- src/Admin/Settings.php | 23 ++++++++ src/Import/Importer.php | 58 ++++++++++++++++++ src/Options.php | 1 + tests/SmokeTest.php | 126 ++++++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 110 +++++++++++++++++++++++++++++++++++ 5 files changed, 318 insertions(+) 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( + '

%3$s

', + 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';