diff --git a/.github/changelog/add-post-type-support b/.github/changelog/add-post-type-support new file mode 100644 index 0000000..5363d0f --- /dev/null +++ b/.github/changelog/add-post-type-support @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Choose which post types are published to AT Protocol from the ATmosphere settings page. Plugins and themes can also opt their custom post types in directly with `add_post_type_support( 'your_type', 'atmosphere' )`. diff --git a/.github/changelog/fix-cleanup-gate-split b/.github/changelog/fix-cleanup-gate-split new file mode 100644 index 0000000..3dcb07b --- /dev/null +++ b/.github/changelog/fix-cleanup-gate-split @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Preserve remote cleanup of already-synced posts when their post type is removed from the syncable allowlist. diff --git a/includes/class-atmosphere.php b/includes/class-atmosphere.php index adef278..3605a6d 100644 --- a/includes/class-atmosphere.php +++ b/includes/class-atmosphere.php @@ -98,7 +98,7 @@ public function output_document_link(): void { return; } - if ( ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) ) { + if ( ! is_supported_post_type( $post->post_type ) ) { return; } @@ -220,7 +220,7 @@ public function preview(): void { return; } - if ( ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) ) { + if ( ! is_supported_post_type( $post->post_type ) ) { \status_header( 404 ); exit; } @@ -251,10 +251,36 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po return; } - if ( ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) ) { + $is_new_publish = 'publish' === $new_status && 'publish' !== $old_status; + $is_update = 'publish' === $new_status && 'publish' === $old_status; + $is_unpublish = 'publish' === $old_status && 'publish' !== $new_status; + + if ( ! $is_new_publish && ! $is_update && ! $is_unpublish ) { + // Transition between two non-publish states; nothing to schedule. return; } + /* + * Publish-time decisions respect the supported list so sites + * only sync the post types they've opted into. Unpublish is a + * cleanup path for records that were already synced, so + * narrowing support later must not orphan those remote records: + * unpublish defers to publication metadata (TIDs on the post) + * instead of the current support list. + */ + if ( ( $is_new_publish || $is_update ) && ! is_supported_post_type( $post->post_type ) ) { + return; + } + + if ( $is_unpublish ) { + $bsky_tid = \get_post_meta( $post->ID, Transformer\Post::META_TID, true ); + $doc_tid = \get_post_meta( $post->ID, Transformer\Document::META_TID, true ); + if ( ! $bsky_tid && ! $doc_tid ) { + // Unpublish of a post that was never synced — nothing to clean up. + return; + } + } + // Prevent infinite loops from meta updates. if ( \did_action( 'atmosphere_publishing' ) ) { return; @@ -262,24 +288,18 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po \do_action( 'atmosphere_publishing' ); - if ( 'publish' === $new_status && 'publish' !== $old_status ) { - // New publish — schedule async. + if ( $is_new_publish ) { \wp_schedule_single_event( \time(), 'atmosphere_publish_post', array( $post->ID ) ); - } elseif ( 'publish' === $new_status && 'publish' === $old_status ) { - // Update. + } elseif ( $is_update ) { \wp_schedule_single_event( \time(), 'atmosphere_update_post', array( $post->ID ) ); - } elseif ( 'publish' === $old_status && 'publish' !== $new_status ) { + } else { /* - * Genuine unpublish — transitioning away from publish. - * Use atmosphere_delete_post (not delete_records) so that - * post meta is cleaned up on success, allowing a subsequent - * restore (trash → publish) to republish correctly. + * Genuine unpublish — use atmosphere_delete_post (not + * delete_records) so post meta is cleaned up on success, + * allowing a subsequent restore (trash → publish) to + * republish correctly. */ - $bsky_tid = \get_post_meta( $post->ID, Transformer\Post::META_TID, true ); - $doc_tid = \get_post_meta( $post->ID, Transformer\Document::META_TID, true ); - if ( $bsky_tid || $doc_tid ) { - \wp_schedule_single_event( \time(), 'atmosphere_delete_post', array( $post->ID ) ); - } + \wp_schedule_single_event( \time(), 'atmosphere_delete_post', array( $post->ID ) ); } } @@ -298,10 +318,18 @@ public function on_before_delete( int $post_id ): void { $post = \get_post( $post_id ); - if ( ! $post || ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) ) { + if ( ! $post ) { return; } + /* + * No support check here. Permanent delete is a cleanup path: if + * the post has Atmosphere publication metadata it was synced at + * some point, and the remote records must be removed even if the + * post type has since been removed from the supported list. + * Gating this on current support would orphan already-published + * records whenever a site narrows its configuration. + */ $bsky_tid = \get_post_meta( $post_id, Transformer\Post::META_TID, true ); $doc_tid = \get_post_meta( $post_id, Transformer\Document::META_TID, true ); @@ -338,11 +366,19 @@ public function cron_refresh_token(): void { * Register async action hooks (called by WP-Cron). */ public static function register_async_hooks(): void { + /* + * Publish/update cron callbacks re-check post-type support. + * A user (or downstream filter) can disable a post type after a + * cron event was queued, and we must not still publish it. + * + * The delete callback intentionally skips this check so cleanup + * still runs after support is removed. + */ \add_action( 'atmosphere_publish_post', static function ( int $post_id ): void { $post = \get_post( $post_id ); - if ( $post && 'publish' === $post->post_status ) { + if ( $post && 'publish' === $post->post_status && is_supported_post_type( $post->post_type ) ) { Publisher::publish( $post ); } } @@ -352,7 +388,7 @@ static function ( int $post_id ): void { 'atmosphere_update_post', static function ( int $post_id ): void { $post = \get_post( $post_id ); - if ( $post && 'publish' === $post->post_status ) { + if ( $post && 'publish' === $post->post_status && is_supported_post_type( $post->post_type ) ) { Publisher::update( $post ); } } diff --git a/includes/class-backfill.php b/includes/class-backfill.php index 02b8046..339484f 100644 --- a/includes/class-backfill.php +++ b/includes/class-backfill.php @@ -37,7 +37,21 @@ public static function handle_count(): void { \check_ajax_referer( 'atmosphere_backfill', 'nonce' ); - $post_types = self::syncable_post_types(); + $post_types = get_supported_post_types(); + + /* + * Short-circuit when no post types are enabled. Passing an empty + * array to get_posts() falls back to the default `post` query, + * which would surface posts that nothing is configured to sync. + */ + if ( empty( $post_types ) ) { + \wp_send_json_success( + array( + 'total' => 0, + 'post_ids' => array(), + ) + ); + } /** * Filters the maximum number of posts to backfill. @@ -99,12 +113,14 @@ public static function handle_batch(): void { \wp_send_json_error( 'No post IDs provided.' ); } - $results = array(); + // Resolve supported post types once for the whole batch. + $supported = get_supported_post_types(); + $results = array(); foreach ( $post_ids as $post_id ) { $post = \get_post( $post_id ); - if ( ! $post || 'publish' !== $post->post_status ) { + if ( ! $post || 'publish' !== $post->post_status || ! \in_array( $post->post_type, $supported, true ) ) { $results[] = array( 'id' => $post_id, 'success' => false, @@ -133,18 +149,4 @@ public static function handle_batch(): void { \wp_send_json_success( array( 'results' => $results ) ); } - - /** - * Get the post types eligible for syncing. - * - * @return string[] - */ - public static function syncable_post_types(): array { - /** - * Filters the post types that can be synced to AT Protocol. - * - * @param string[] $post_types Post type slugs. - */ - return \apply_filters( 'atmosphere_syncable_post_types', array( 'post' ) ); - } } diff --git a/includes/class-post-types.php b/includes/class-post-types.php new file mode 100644 index 0000000..39a34db --- /dev/null +++ b/includes/class-post-types.php @@ -0,0 +1,82 @@ + true ) ); + + $value = \array_filter( (array) $value, '\is_string' ); + $value = \array_map( 'sanitize_key', $value ); + $value = \array_filter( $value ); + + return \array_values( \array_unique( \array_intersect( $value, $allowed ) ) ); + } +} diff --git a/includes/functions.php b/includes/functions.php index 1b6f416..60524e7 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -128,3 +128,22 @@ function get_did(): string { function get_pds_endpoint(): string { return get_connection()['pds_endpoint'] ?? ''; } + +/** + * Get post types that publish to AT Protocol. + * + * @return string[] Post type slugs. + */ +function get_supported_post_types(): array { + return Post_Types::get_supported(); +} + +/** + * Whether a post type publishes to AT Protocol. + * + * @param string $post_type Post type slug. + * @return bool + */ +function is_supported_post_type( string $post_type ): bool { + return Post_Types::supports( $post_type ); +} diff --git a/includes/wp-admin/class-admin.php b/includes/wp-admin/class-admin.php index 69915e8..d7f5ca3 100644 --- a/includes/wp-admin/class-admin.php +++ b/includes/wp-admin/class-admin.php @@ -9,10 +9,11 @@ \defined( 'ABSPATH' ) || exit; -use Atmosphere\Backfill; use Atmosphere\OAuth\Client; +use Atmosphere\Post_Types; use Atmosphere\Publisher; use function Atmosphere\get_connection; +use function Atmosphere\get_supported_post_types; use function Atmosphere\is_connected; /** @@ -68,6 +69,23 @@ public static function register_settings(): void { ) ); + \register_setting( + 'atmosphere', + 'atmosphere_support_post_types', + array( + 'type' => 'array', + 'description' => 'Post types to publish to AT Protocol.', + 'default' => array( 'post' ), + 'sanitize_callback' => array( Post_Types::class, 'sanitize' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + ), + ), + ) + ); + \register_setting( 'atmosphere', 'atmosphere_handle', @@ -116,6 +134,14 @@ public static function register_settings(): void { 'atmosphere_publishing' ); + \add_settings_field( + 'atmosphere_support_post_types', + \__( 'Post types', 'atmosphere' ), + array( self::class, 'render_support_post_types_field' ), + 'atmosphere', + 'atmosphere_publishing' + ); + \add_settings_field( 'atmosphere_backfill', \__( 'Backfill', 'atmosphere' ), @@ -247,6 +273,64 @@ public static function render_auto_publish_field(): void { true ), 'objects' ); + + /* + * The checkbox state reflects the saved option only. Native + * `add_post_type_support()` opt-ins and the syncable filter are + * surfaced as a note below the label so the user can see when a + * post type is enabled outside this UI. + */ + $saved = (array) \get_option( 'atmosphere_support_post_types', array( 'post' ) ); + $saved = \array_filter( \array_map( 'sanitize_key', $saved ) ); + $effective = get_supported_post_types(); + ?> +
++ +
+ assertTrue( is_supported_post_type( 'post' ) ); + } + + /** + * Custom option drives the supported list. + */ + public function test_option_drives_supported_list() { + \update_option( 'atmosphere_support_post_types', array( 'post', 'page' ) ); + + $supported = get_supported_post_types(); + + $this->assertContains( 'post', $supported ); + $this->assertContains( 'page', $supported ); + $this->assertTrue( is_supported_post_type( 'page' ) ); + } + + /** + * Empty option means nothing is supported (unless opted in via WP API). + */ + public function test_empty_option_supports_nothing() { + \update_option( 'atmosphere_support_post_types', array() ); + + $this->assertFalse( is_supported_post_type( 'post' ) ); + $this->assertSame( array(), get_supported_post_types() ); + } + + /** + * Third parties can opt their post types in via WP's native API. + */ + public function test_third_party_add_post_type_support() { + \update_option( 'atmosphere_support_post_types', array() ); + \add_post_type_support( 'page', 'atmosphere' ); + + $this->assertTrue( is_supported_post_type( 'page' ) ); + $this->assertContains( 'page', get_supported_post_types() ); + } + + /** + * The `atmosphere_syncable_post_types` filter still adjusts the list. + */ + public function test_filter_can_add_post_type() { + \add_filter( + 'atmosphere_syncable_post_types', + static function ( array $types ): array { + $types[] = 'page'; + return $types; + } + ); + + $this->assertTrue( is_supported_post_type( 'page' ) ); + } + + /** + * `Post_Types::sanitize()` drops unknown / non-public post types. + */ + public function test_sanitize_filters_unknown_post_types() { + $result = Post_Types::sanitize( array( 'post', 'bogus_type', 'page' ) ); + + $this->assertSame( array( 'post', 'page' ), $result ); + } + + /** + * `Post_Types::sanitize()` coerces empty input to an empty array. + */ + public function test_sanitize_handles_empty_input() { + $this->assertSame( array(), Post_Types::sanitize( null ) ); + $this->assertSame( array(), Post_Types::sanitize( '' ) ); + $this->assertSame( array(), Post_Types::sanitize( array() ) ); + } + + /** + * `Post_Types::sanitize()` dedupes the result so the saved option is + * a canonical list. + */ + public function test_sanitize_dedupes() { + $result = Post_Types::sanitize( array( 'post', 'page', 'post', 'page' ) ); + + $this->assertSame( array( 'post', 'page' ), $result ); + } + + /** + * `Post_Types::sanitize()` drops non-strings and empties so callers + * never store junk. + */ + public function test_sanitize_drops_non_strings_and_empties() { + $result = Post_Types::sanitize( array( 'post', '', null, 42, true, 'page' ) ); + + $this->assertSame( array( 'post', 'page' ), $result ); + } + + /** + * Filter callbacks returning duplicates / non-strings / empties are + * normalised away so callers always get a clean string[] of unique slugs. + */ + public function test_get_supported_normalises_filter_output() { + \add_filter( + 'atmosphere_syncable_post_types', + static function (): array { + return array( 'post', 'post', '', null, 42, 'page' ); + } + ); + + $result = get_supported_post_types(); + + $this->assertSame( array( 'post', 'page' ), \array_values( $result ) ); + } +} diff --git a/tests/phpunit/tests/class-test-status-change.php b/tests/phpunit/tests/class-test-status-change.php index e2f7534..6bef325 100644 --- a/tests/phpunit/tests/class-test-status-change.php +++ b/tests/phpunit/tests/class-test-status-change.php @@ -284,4 +284,90 @@ public function test_disconnected_state_prevents_scheduling() { 'Disconnected state must prevent scheduling.' ); } + + /** + * Unpublish of a previously-synced post with a post type no longer in + * the syncable allowlist must still schedule remote cleanup. Without + * this, narrowing the allowlist after publishing orphans the remote + * records. + */ + public function test_unpublish_of_previously_synced_non_syncable_post_schedules_delete() { + $post = self::factory()->post->create_and_get( + array( + 'post_status' => 'draft', + 'post_type' => 'page', + ) + ); + + \update_post_meta( $post->ID, Post::META_TID, 'bsky-tid-123' ); + \update_post_meta( $post->ID, Document::META_TID, 'doc-tid-456' ); + + $this->reset_publishing_action(); + $this->atmosphere->on_status_change( 'draft', 'publish', $post ); + + $this->assertNotFalse( + \wp_next_scheduled( 'atmosphere_delete_post', array( $post->ID ) ), + 'Unpublish must clean up remote records even when the post type is no longer in the syncable allowlist.' + ); + } + + /** + * Permanent delete of a previously-synced post with a post type no + * longer in the syncable allowlist must still capture TIDs and + * schedule remote cleanup. Same rationale as the unpublish test + * above: the allowlist governs new-publish eligibility, not cleanup. + */ + public function test_before_delete_of_previously_synced_non_syncable_post_schedules_delete_records() { + $post = self::factory()->post->create_and_get( + array( + 'post_status' => 'publish', + 'post_type' => 'page', + ) + ); + + \update_post_meta( $post->ID, Post::META_TID, 'bsky-tid-123' ); + \update_post_meta( $post->ID, Document::META_TID, 'doc-tid-456' ); + + $this->atmosphere->on_before_delete( $post->ID ); + + $this->assertNotFalse( + \wp_next_scheduled( + 'atmosphere_delete_records', + array( 'bsky-tid-123', 'doc-tid-456' ) + ), + 'Permanent delete must schedule remote cleanup even when the post type is no longer in the syncable allowlist.' + ); + } + + /** + * Regression guard for the split gate: narrowing the allowlist via + * the `atmosphere_syncable_post_types` filter must still block a + * new-publish of a post type the filter excludes. Only cleanup + * paths are meant to bypass the allowlist. + */ + public function test_new_publish_respects_allowlist_even_when_filter_narrows() { + $narrow = static function () { + return array( 'page' ); + }; + \add_filter( 'atmosphere_syncable_post_types', $narrow ); + + try { + $post = self::factory()->post->create_and_get( + array( + 'post_status' => 'publish', + 'post_type' => 'post', + ) + ); + + $this->reset_publishing_action(); + $this->atmosphere->on_status_change( 'publish', 'draft', $post ); + + $this->assertFalse( + \wp_next_scheduled( 'atmosphere_publish_post', array( $post->ID ) ), + 'New publish of a post type outside the allowlist must not be scheduled.' + ); + } finally { + \remove_filter( 'atmosphere_syncable_post_types', $narrow ); + } + } }