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/includes/class-atmosphere.php b/includes/class-atmosphere.php index acbba7f..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; } @@ -261,16 +261,14 @@ public function on_status_change( string $new_status, string $old_status, \WP_Po } /* - * Publish-time decisions respect the allowlist so sites only sync - * the post types they've opted into. Unpublish is a cleanup path - * for records that were already synced, so narrowing the allowlist - * later must not orphan those remote records: unpublish defers to - * publication metadata (TIDs on the post) instead of the current - * allowlist. + * 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 ) - && ! \in_array( $post->post_type, Backfill::syncable_post_types(), true ) - ) { + if ( ( $is_new_publish || $is_update ) && ! is_supported_post_type( $post->post_type ) ) { return; } @@ -325,12 +323,12 @@ public function on_before_delete( int $post_id ): void { } /* - * No allowlist check here. Permanent delete is a cleanup path: if + * 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 dropped from the syncable allowlist. - * Gating this on the current allowlist would orphan already- - * published records whenever a site narrows its configuration. + * 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 ); @@ -368,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 ); } } @@ -382,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 ) ); + } +}