diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php
index ce1c0f8..10f5c68 100644
--- a/src/Admin/Settings.php
+++ b/src/Admin/Settings.php
@@ -31,9 +31,15 @@ final class Settings {
/** Manual import admin-post action. */
public const MANUAL_IMPORT_ACTION = 'rss2cpt_manual_import';
+ /** Cache-reset admin-post action. */
+ public const RESET_CACHE_ACTION = 'rss2cpt_reset_cache';
+
/** Manual import nonce action. */
private const NONCE_ACTION = 'rss2cpt_run_manual_import';
+ /** Cache-reset nonce action. */
+ private const RESET_NONCE_ACTION = 'rss2cpt_reset_cache';
+
/**
* Import service.
*
@@ -71,6 +77,7 @@ final class Settings {
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_post_' . self::RESET_CACHE_ACTION, array( $this, 'handle_reset_cache' ) );
add_action( 'admin_notices', array( $this, 'render_notices' ) );
$this->scheduler->ensure_scheduled();
}
@@ -233,6 +240,14 @@ final class Settings {
+
+
+
+
redirect_with_notice( 'success', $counts );
}
+ /**
+ * Handle a verified cache-reset request.
+ *
+ * @return void
+ */
+ public function handle_reset_cache(): void {
+ if ( ! current_user_can( 'manage_options' ) ) {
+ wp_die(
+ esc_html__( 'You are not allowed to clear the podcast feed cache.', 'rss2cpt' ),
+ esc_html__( 'Forbidden', 'rss2cpt' ),
+ array( 'response' => 403 )
+ );
+ }
+
+ check_admin_referer( self::RESET_NONCE_ACTION );
+
+ $settings = Options::get();
+ $feed_url = (string) $settings['feed_url'];
+ $post_type = (string) $settings['post_type'];
+
+ $cleared = $this->clear_feed_cache( $feed_url, $post_type );
+
+ $this->redirect_with_notice( $cleared ? 'cache_cleared' : 'cache_clear_failed' );
+ }
+
+ /**
+ * Delete the SimplePie feed-cache rows and the importer's per-feed lock.
+ *
+ * WordPress's WP_Feed_Cache_Transient stores the cached feed under
+ * transients named "feed_" and "feed_mod_". On disk,
+ * transients surface in the options table as _transient_* and
+ * _transient_timeout_* rows; on multisite the corresponding
+ * _site_transient_* rows are used. We delete all four standard variants
+ * plus the two site-transient variants so the next import always re-fetches.
+ *
+ * The importer's lock key is "rss2cpt_lock_".
+ *
+ * @param string $feed_url Configured feed URL.
+ * @param string $post_type Configured destination post type.
+ * @return bool True when at least one cache row was deleted.
+ */
+ public function clear_feed_cache( string $feed_url, string $post_type ): bool {
+ if ( '' === $feed_url ) {
+ return false;
+ }
+
+ $url_hash = md5( $feed_url );
+ $lock_hash = md5( $feed_url . '|' . $post_type );
+
+ $option_keys = array(
+ '_transient_feed_' . $url_hash,
+ '_transient_timeout_feed_' . $url_hash,
+ '_transient_feed_mod_' . $url_hash,
+ '_transient_timeout_feed_mod_' . $url_hash,
+ 'rss2cpt_lock_' . $lock_hash,
+ );
+
+ $cleared = false;
+ foreach ( $option_keys as $key ) {
+ if ( delete_option( $key ) ) {
+ $cleared = true;
+ }
+ }
+
+ // Multisite: feed cache may live in the site transients table.
+ foreach ( array( 'feed_' . $url_hash, 'feed_mod_' . $url_hash ) as $transient ) {
+ if ( delete_site_transient( $transient ) ) {
+ $cleared = true;
+ }
+ }
+
+ /**
+ * Fires after the plugin clears its feed cache and import lock.
+ *
+ * @param string $feed_url Feed URL whose cache was cleared.
+ * @param string $post_type Destination post type.
+ */
+ do_action( 'rss2cpt_cache_cleared', $feed_url, $post_type );
+
+ return $cleared;
+ }
+
/**
* Render a manual-import result notice.
*
@@ -351,6 +448,12 @@ final class Settings {
$failed,
$warnings
);
+ } elseif ( 'cache_cleared' === $notice ) {
+ $class = 'notice notice-success';
+ $message = __( 'Feed cache cleared. The next import will re-fetch the feed from the source.', 'rss2cpt' );
+ } elseif ( 'cache_clear_failed' === $notice ) {
+ $class = 'notice notice-warning';
+ $message = __( 'Feed cache could not be cleared. Make sure a feed URL is configured.', 'rss2cpt' );
}
?>
diff --git a/tests/SmokeTest.php b/tests/SmokeTest.php
index ac31c52..881ff26 100644
--- a/tests/SmokeTest.php
+++ b/tests/SmokeTest.php
@@ -7,6 +7,9 @@
use PHPUnit\Framework\TestCase;
+require_once dirname( __DIR__ ) . '/src/Scheduler.php';
+require_once dirname( __DIR__ ) . '/src/Admin/Settings.php';
+
/**
* Verifies project-level invariants without loading WordPress.
*/
@@ -133,4 +136,43 @@ final class SmokeTest extends TestCase {
'fingerprint' => str_repeat( 'b', 64 ),
);
}
+
+ /**
+ * Verify that clear_feed_cache deletes the SimplePie feed-cache option
+ * rows, the importer's per-feed lock, and the multisite site-transient
+ * variants derived from the configured feed URL.
+ */
+ public function test_clear_feed_cache_removes_lock_and_simplepie_transient_keys(): void {
+ rss2cpt_test_reset_delete_logs();
+
+ $importer = new RSS2CPT\Import\Importer();
+ $settings = new RSS2CPT\Admin\Settings( $importer, new RSS2CPT\Scheduler( $importer ) );
+ $cleared = $settings->clear_feed_cache( 'https://example.test/feed.xml', 'podcast_episode' );
+
+ $this->assertTrue( $cleared );
+ $this->assertContains( '_transient_feed_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] );
+ $this->assertContains( '_transient_timeout_feed_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] );
+ $this->assertContains( '_transient_feed_mod_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] );
+ $this->assertContains( '_transient_timeout_feed_mod_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_options'] );
+ $this->assertContains( 'rss2cpt_lock_' . md5( 'https://example.test/feed.xml|podcast_episode' ), $GLOBALS['rss2cpt_test_deleted_options'] );
+ $this->assertContains( 'feed_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_site_transients'] );
+ $this->assertContains( 'feed_mod_' . md5( 'https://example.test/feed.xml' ), $GLOBALS['rss2cpt_test_deleted_site_transients'] );
+ }
+
+ /**
+ * Verify that clear_feed_cache refuses to do anything destructive when
+ * the feed URL is empty (so an unconfigured install cannot wipe someone
+ * else's transient rows by accident).
+ */
+ public function test_clear_feed_cache_is_noop_when_feed_url_empty(): void {
+ rss2cpt_test_reset_delete_logs();
+
+ $importer = new RSS2CPT\Import\Importer();
+ $settings = new RSS2CPT\Admin\Settings( $importer, new RSS2CPT\Scheduler( $importer ) );
+ $cleared = $settings->clear_feed_cache( '', 'podcast_episode' );
+
+ $this->assertFalse( $cleared );
+ $this->assertSame( array(), $GLOBALS['rss2cpt_test_deleted_options'] );
+ $this->assertSame( array(), $GLOBALS['rss2cpt_test_deleted_site_transients'] );
+ }
}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 663efee..0c2aa6d 100644
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -55,5 +55,48 @@ function get_gmt_from_date( $date_string ) {
return gmdate( 'Y-m-d H:i:s', $timestamp );
}
+/**
+ * In-memory option store for isolated tests.
+ *
+ * @param string $name Option name.
+ * @return bool Always true; the test asserts via $GLOBALS['rss2cpt_test_deleted_options'].
+ */
+function delete_option( $name ) {
+ $GLOBALS['rss2cpt_test_deleted_options'][] = $name;
+ return true;
+}
+
+/**
+ * In-memory site-transient store for isolated tests.
+ *
+ * @param string $name Transient name.
+ * @return bool Always true; the test asserts via $GLOBALS['rss2cpt_test_deleted_site_transients'].
+ */
+function delete_site_transient( $name ) {
+ $GLOBALS['rss2cpt_test_deleted_site_transients'][] = $name;
+ return true;
+}
+
+/**
+ * Reset recorded delete_* call logs between tests.
+ *
+ * @return void
+ */
+function rss2cpt_test_reset_delete_logs() {
+ $GLOBALS['rss2cpt_test_deleted_options'] = array();
+ $GLOBALS['rss2cpt_test_deleted_site_transients'] = array();
+}
+rss2cpt_test_reset_delete_logs();
+
+/**
+ * Minimal do_action stub for isolated tests.
+ *
+ * @param string $hook Hook name.
+ * @param mixed ...$args Hook arguments.
+ */
+function do_action( $hook, ...$args ) {
+ unset( $hook, $args );
+}
+
require_once dirname( __DIR__ ) . '/src/Options.php';
require_once dirname( __DIR__ ) . '/src/Import/Importer.php';