diff --git a/includes/activity/class-event.php b/includes/activity/class-event.php new file mode 100644 index 0000000..a38880c --- /dev/null +++ b/includes/activity/class-event.php @@ -0,0 +1,23 @@ +to_object(); + $transformer = Transformers_Manager::get_transformer( $wp_post ); + $transformer->transform( $wp_post ); + $transformer->to_object(); $activity = new Activity(); $activity->set_type( $type ); @@ -101,7 +103,9 @@ class Activity_Dispatcher { return; } - $object = Post::transform( $wp_post )->to_object(); + $transformer = Transformers_Manager::get_transformer( $wp_post ); + $transformer->transform( $wp_post ); + $transformer->to_object(); $activity = new Activity(); $activity->set_type( 'Announce' ); diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 24228f4..0b768a7 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -20,9 +20,9 @@ class Activitypub { \add_filter( 'get_comment_link', array( self::class, 'remote_comment_link' ), 11, 3 ); // Add support for ActivityPub to custom post types - $post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) ? \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ) : array(); + $transformer_mapping = \get_option( 'activitypub_transformer_mapping', array( 'post' => 'activitypub/default', 'page' => 'activitypub/default' ) ) ? \get_option( 'activitypub_transformer_mapping', array( 'post' => 'activitypub/default', 'page' => 'activitypub/default' ) ) : array(); - foreach ( $post_types as $post_type ) { + foreach ( array_keys( $transformer_mapping ) as $post_type ) { \add_post_type_support( $post_type, 'activitypub' ); } diff --git a/includes/class-admin.php b/includes/class-admin.php index f8afc8d..718e1c6 100644 --- a/includes/class-admin.php +++ b/includes/class-admin.php @@ -3,6 +3,7 @@ namespace Activitypub; use WP_User_Query; use Activitypub\Model\Blog_User; +use Activitypub\Base\Transformer_Base; /** * ActivityPub Admin Class @@ -22,6 +23,10 @@ class Admin { if ( ! is_user_disabled( get_current_user_id() ) ) { \add_action( 'show_user_profile', array( self::class, 'add_profile' ) ); } + + add_filter( 'activitypub/transformers/is_transformer_enabled', function( $should_register, Transformer_Base $widget_instance ) { + return ! Options::is_transformer_disabled( $transformer_instance->get_name() ); + }, 10, 2 ); } /** @@ -154,14 +159,48 @@ class Admin { 'default' => '0', ) ); - \register_setting( + + /** + * Flexible activation of post_types together with mapping ActivityPub transformers. + * + * If a post-type is not mapped to any ActivtiyPub transformer it means it is not activated + * for ActivityPub federation. + * + * @since version_number_transformer_management_placeholder + */ + register_setting( 'activitypub', - 'activitypub_support_post_types', + 'activitypub_transformer_mapping', array( - 'type' => 'string', - 'description' => \esc_html__( 'Enable ActivityPub support for post types', 'activitypub' ), - 'show_in_rest' => true, - 'default' => array( 'post', 'pages' ), + 'type' => 'array', + 'default' => array( + 'post' => 'note', + ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'string', + ), + ), + ), + 'sanitize_callback' => function ( $value ) { + // Check if $value is an array + if (!is_array($value)) { + return array(); + } + $value_keys = array_keys( $value ); + + $all_public_post_types = \get_post_types( array( 'public' => true ), 'names' ); + + // Unset the keys that are missing in $keysToCheck + foreach ( array_diff( $value_keys, $all_public_post_types ) as $missing_key ) { + unset($value[$missing_key]); + } + // var_dump($value); + return $value; + + } ) ); \register_setting( diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index 63f9273..a58e5b9 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -4,7 +4,6 @@ namespace Activitypub; use Activitypub\Collection\Users; use Activitypub\Collection\Followers; -use Activitypub\Transformer\Post; /** * ActivityPub Scheduler Class diff --git a/includes/class-transformer-base.php b/includes/class-transformer-base.php new file mode 100644 index 0000000..09702ae --- /dev/null +++ b/includes/class-transformer-base.php @@ -0,0 +1,595 @@ +supports_post_type( get_post_type( $wp_post ) ) ) { + $this->wp_post = $wp_post; + } else { + //TODO Error, this should not happen. + } + } + + /** + * Get the supported WP post_types that the transformer can use as an input. + * + * By default all post types are supported. + * You may very likely wish to override this function. + * + * @since version_number_transformer_management_placeholder + * @return string[] An array containing all the supported post types. + */ + public function get_supported_post_types() { + return \get_post_types( array( 'public' => true ), 'names' ); + } + + /** + * Get the name of the plugin that registered the transformer. + * + * @see Forked from the WordPress elementor plugin. + * + * @since version_number_transformer_management_placeholder + * @return string Plugin name + */ + private function get_plugin_name_from_transformer_instance( $transformer ) { + $class_reflection = new \ReflectionClass( $transformer ); + + $plugin_basename = plugin_basename( $class_reflection->getFileName() ); + + $plugin_directory = strtok( $plugin_basename, '/' ); + + $plugins_data = get_plugins( '/' . $plugin_directory ); + $plugin_data = array_shift( $plugins_data ); + + return $plugin_data['Name'] ?? esc_html__( 'Unknown', 'activitypub' ); + } + + /** + * Return whether the transformer supports a post type. + * + * @since version_number_transformer_management_placeholder + * + * @return string post_type Post type name. + */ + final public function supports_post_type( $post_type ) { + return in_array( $post_type, $this->get_supported_post_types() ); + } + + /** + * Get the name used for registering the transformer with the ActivityPub plugin. + * + * @since version_number_transformer_management_placeholder + * + * @return string name + */ + abstract public function get_name(); + + /** + * Get the display name for the ActivityPub transformer. + * + * @since version_number_transformer_management_placeholder + * + * @return string display name + */ + abstract public function get_label(); + + /** + * Returns the ActivityStreams 2.0 Object-Type for a Post. + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#activity-types + * + * @return string The Object-Type. + */ + abstract protected function get_object_type(); + + /** + * Returns the content for the ActivityPub Item. + * + * The content will be generated based on the user settings. + * + * @return string The content. + */ + protected function get_content() { + global $post; + + /** + * Provides an action hook so plugins can add their own hooks/filters before AP content is generated. + * + * Example: if a plugin adds a filter to `the_content` to add a button to the end of posts, it can also remove that filter here. + * + * @param WP_Post $post The post object. + */ + do_action( 'activitypub_before_get_content', $post ); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $post = $this->wp_post; + $content = $this->get_post_content_template(); + + // Register our shortcodes just in time. + Shortcodes::register(); + // Fill in the shortcodes. + setup_postdata( $post ); + $content = do_shortcode( $content ); + wp_reset_postdata(); + + $content = \wpautop( $content ); + $content = \preg_replace( '/[\n\r\t]/', '', $content ); + $content = \trim( $content ); + + $content = \apply_filters( 'activitypub_the_content', $content, $post ); + + // Don't need these any more, should never appear in a post. + Shortcodes::unregister(); + + return $content; + } + + + /** + * Returns the ID of the Post. + * + * @return string The Posts ID. + */ + public function get_id() { + return $this->get_url(); + } + + /** + * Returns the URL of the Post. + * + * @return string The Posts URL. + */ + public function get_url() { + $post = $this->wp_post; + + if ( 'trash' === get_post_status( $post ) ) { + $permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true ); + } else { + $permalink = \get_permalink( $post ); + } + + return \esc_url( $permalink ); + } + + /** + * Returns the User-URL of the Author of the Post. + * + * If `single_user` mode is enabled, the URL of the Blog-User is returned. + * + * @return string The User-URL. + */ + protected function get_attributed_to() { + if ( is_single_user() ) { + $user = new Blog_User(); + return $user->get_url(); + } + + return Users::get_by_id( $this->wp_post->post_author )->get_url(); + } + + /** + * Generates all Media Attachments for a Post. + * + * @return array The Attachments. + */ + protected function get_attachments() { + // Once upon a time we only supported images, but we now support audio/video as well. + // We maintain the image-centric naming for backwards compatibility. + $max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); + + if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { + return $this->get_block_attachments( $max_media ); + } + + return $this->get_classic_editor_images( $max_media ); + } + + /** + * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. + * + * @param int $max_media The maximum number of attachments to return. + * + * @return array The attachments. + */ + protected function get_block_attachments( $max_media ) { + // max media can't be negative or zero + if ( $max_media <= 0 ) { + return array(); + } + + $id = $this->wp_post->ID; + + $media_ids = array(); + + // list post thumbnail first if this post has one + if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { + $media_ids[] = \get_post_thumbnail_id( $id ); + } + + if ( $max_media > 0 ) { + $blocks = \parse_blocks( $this->wp_post->post_content ); + $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media ); + } + $media_ids = \array_unique( $media_ids ); + + return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media_ids ) ); + } + + /** + * Get image attachments from the classic editor. + * Note that audio/video attachments are only supported in the block editor. + * + * @param int $max_images The maximum number of images to return. + * + * @return array The attachments. + */ + protected function get_classic_editor_images( $max_images ) { + // max images can't be negative or zero + if ( $max_images <= 0 ) { + return array(); + } + + $id = $this->wp_post->ID; + + $image_ids = array(); + + // list post thumbnail first if this post has one + if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { + $image_ids[] = \get_post_thumbnail_id( $id ); + --$max_images; + } + + if ( $max_images > 0 ) { + $query = new \WP_Query( + array( + 'post_parent' => $id, + 'post_status' => 'inherit', + 'post_type' => 'attachment', + 'post_mime_type' => 'image', + 'order' => 'ASC', + 'orderby' => 'menu_order ID', + 'posts_per_page' => $max_images, + ) + ); + foreach ( $query->get_posts() as $attachment ) { + if ( ! \in_array( $attachment->ID, $image_ids, true ) ) { + $image_ids[] = $attachment->ID; + } + } + } + $image_ids = \array_unique( $image_ids ); + + return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $image_ids ) ); + } + + /** + * Recursively get media IDs from blocks. + * @param array $blocks The blocks to search for media IDs + * @param array $media_ids The media IDs to append new IDs to + * @param int $max_media The maximum number of media to return. + * + * @return array The image IDs. + */ + protected static function get_media_ids_from_blocks( $blocks, $media_ids, $max_media ) { + + foreach ( $blocks as $block ) { + // recurse into inner blocks + if ( ! empty( $block['innerBlocks'] ) ) { + $media_ids = self::get_media_ids_from_blocks( $block['innerBlocks'], $media_ids, $max_media ); + } + + switch ( $block['blockName'] ) { + case 'core/image': + case 'core/cover': + case 'core/audio': + case 'core/video': + case 'videopress/video': + if ( ! empty( $block['attrs']['id'] ) ) { + $media_ids[] = $block['attrs']['id']; + } + break; + case 'jetpack/slideshow': + case 'jetpack/tiled-gallery': + if ( ! empty( $block['attrs']['ids'] ) ) { + $media_ids = array_merge( $media_ids, $block['attrs']['ids'] ); + } + break; + case 'jetpack/image-compare': + if ( ! empty( $block['attrs']['beforeImageId'] ) ) { + $media_ids[] = $block['attrs']['beforeImageId']; + } + if ( ! empty( $block['attrs']['afterImageId'] ) ) { + $media_ids[] = $block['attrs']['afterImageId']; + } + break; + } + + // stop doing unneeded work + if ( count( $media_ids ) >= $max_media ) { + break; + } + } + + // still need to slice it because one gallery could knock us over the limit + return array_slice( $media_ids, 0, $max_media ); + } + + /** + * Converts a WordPress Attachment to an ActivityPub Attachment. + * + * @param int $id The Attachment ID. + * + * @return array The ActivityPub Attachment. + */ + public static function wp_attachment_to_activity_attachment( $id ) { + $attachment = array(); + $mime_type = \get_post_mime_type( $id ); + $mime_type_parts = \explode( '/', $mime_type ); + // switching on image/audio/video + switch ( $mime_type_parts[0] ) { + case 'image': + $image_size = 'full'; + + /** + * Filter the image URL returned for each post. + * + * @param array|false $thumbnail The image URL, or false if no image is available. + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'full' by default. + */ + $thumbnail = apply_filters( + 'activitypub_get_image', + self::get_image( $id, $image_size ), + $id, + $image_size + ); + + if ( $thumbnail ) { + $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); + $image = array( + 'type' => 'Image', + 'url' => $thumbnail[0], + 'mediaType' => $mime_type, + ); + + if ( $alt ) { + $image['name'] = $alt; + } + $attachment = $image; + } + break; + + case 'audio': + case 'video': + $attachment = array( + 'type' => 'Document', + 'mediaType' => $mime_type, + 'url' => \wp_get_attachment_url( $id ), + 'name' => \get_the_title( $id ), + ); + $meta = wp_get_attachment_metadata( $id ); + // height and width for videos + if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { + $attachment['width'] = $meta['width']; + $attachment['height'] = $meta['height']; + } + // @todo: add `icon` support for audio/video attachments. Maybe use post thumbnail? + break; + } + + return \apply_filters( 'activitypub_attachment', $attachment, $id ); + } + + /** + * Return details about an image attachment. + * + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'full' by default. + * + * @return array|false Array of image data, or boolean false if no image is available. + */ + protected static function get_image( $id, $image_size = 'full' ) { + /** + * Hook into the image retrieval process. Before image retrieval. + * + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'full' by default. + */ + do_action( 'activitypub_get_image_pre', $id, $image_size ); + + $image = \wp_get_attachment_image_src( $id, $image_size ); + + /** + * Hook into the image retrieval process. After image retrieval. + * + * @param int $id The attachment ID. + * @param string $image_size The image size to retrieve. Set to 'full' by default. + */ + do_action( 'activitypub_get_image_post', $id, $image_size ); + + return $image; + } + + + /** + * Helper function to get the @-Mentions from the post content. + * + * @return array The list of @-Mentions. + */ + protected function get_mentions() { + return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post ); + } + + /** + * Returns a list of Mentions, used in the Post. + * + * @see https://docs.joinmastodon.org/spec/activitypub/#Mention + * + * @return array The list of Mentions. + */ + protected function get_cc() { + $cc = array(); + + $mentions = $this->get_mentions(); + if ( $mentions ) { + foreach ( $mentions as $url ) { + $cc[] = $url; + } + } + + return $cc; + } + + /** + * Returns a list of Tags, used in the Post. + * + * This includes Hash-Tags and Mentions. + * + * @return array The list of Tags. + */ + protected function get_tags() { + $tags = array(); + + $post_tags = \get_the_tags( $this->wp_post->ID ); + if ( $post_tags ) { + foreach ( $post_tags as $post_tag ) { + $tag = array( + 'type' => 'Hashtag', + 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ), + 'name' => esc_hashtag( $post_tag->name ), + ); + $tags[] = $tag; + } + } + + $mentions = $this->get_mentions(); + if ( $mentions ) { + foreach ( $mentions as $mention => $url ) { + $tag = array( + 'type' => 'Mention', + 'href' => \esc_url( $url ), + 'name' => \esc_html( $mention ), + ); + $tags[] = $tag; + } + } + + return $tags; + } + + + /** + * Returns the locale of the post. + * + * @return string The locale of the post. + */ + public function get_locale() { + $post_id = $this->wp_post->ID; + $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); + + /** + * Filter the locale of the post. + * + * @param string $lang The locale of the post. + * @param int $post_id The post ID. + * @param WP_Post $post The post object. + * + * @return string The filtered locale of the post. + */ + return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_post ); + } + + /** + * Gets the contentMap + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-contentmap + * + * @return array the contenmap + */ + protected function get_content_map() { + return array( + $this->get_locale() => $this->get_content(), + ); + } + + /** + * Transforms the WP_Post object to an ActivityPub Object + * + * @see \Activitypub\Activity\Base_Object + * + * @return \Activitypub\Activity\Base_Object The ActivityPub Object + */ + public function to_object() { + $wp_post = $this->wp_post; + $object = new Base_Object(); + + $object->set_id( $this->get_id() ); + $object->set_url( $this->get_url() ); + $object->set_type( $this->get_object_type() ); + + $published = \strtotime( $wp_post->post_date_gmt ); + + $object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) ); + + $updated = \strtotime( $wp_post->post_modified_gmt ); + + if ( $updated > $published ) { + $object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) ); + } + + $object->set_attributed_to( $this->get_attributed_to() ); + $object->set_content( $this->get_content() ); + $object->set_content_map( $this->get_content_map ); + $path = sprintf( 'users/%d/followers', intval( $wp_post->post_author ) ); + + $object->set_to( + array( + 'https://www.w3.org/ns/activitystreams#Public', + get_rest_url_by_path( $path ), + ) + ); + $object->set_cc( $this->get_cc() ); + $object->set_attachment( $this->get_attachments() ); + $object->set_tag( $this->get_tags() ); + + return $object; + } + +} \ No newline at end of file diff --git a/includes/class-transformers-manager.php b/includes/class-transformers-manager.php new file mode 100644 index 0000000..ee55d7b --- /dev/null +++ b/includes/class-transformers-manager.php @@ -0,0 +1,284 @@ +require_files(); + } + + /** + * Require files. + * + * Require ActivityPub transformer base class. + * + * @since version_number_transformer_management_placeholder + * @access private + */ + private function require_files() { + require ACTIVITYPUB_PLUGIN_DIR . 'includes/class-transformer-base.php'; + } + + /** + * Checks if a transformer is registered. + * + * @since version_number_transformer_management_placeholder + * + * @param string $name Transformer name including namespace. + * @return bool True if the block type is registered, false otherwise. + */ + public function is_registered( $name ) { + return isset( $this->transformers[ $name ] ); + } + + /** + * Register a transformer. + * + * @since version_number_transformer_management_placeholder + * @access public + * + * @param \ActivityPub\Transformer_Base $transformer_instance ActivityPub Transformer. + * + * @return bool True if the ActivityPub transformer was registered. + */ + public function register( Transformer_Base $transformer_instance) { + + if ( ! $transformer_instance instanceof Transformer_Base ) { + _doing_it_wrong( + __METHOD__, + __( 'ActivityPub transformer instance must be a of \ActivityPub\Transformer_Base class.' ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + $transformer_name = $transformer_instance->get_name(); + if ( preg_match( '/[A-Z]+/', $transformer_name ) ) { + _doing_it_wrong( + __METHOD__, + __( 'ActivityPub transformer names must not contain uppercase characters.' ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + $name_matcher = '/^[a-z0-9-]+\/[a-z0-9-]+$/'; + if ( ! preg_match( $name_matcher, $transformer_name ) ) { + _doing_it_wrong( + __METHOD__, + __( 'ActivityPub transformer names must contain a namespace prefix. Example: my-plugin/my-custom-transformer' ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + if ( $this->is_registered( $transformer_name ) ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Block name. */ + sprintf( __( 'ActivityPub transformer with name "%s" is already registered.' ), $transformer_name ), + 'version_number_transformer_management_placeholder' + ); + return false; + } + + /** + * Should the ActivityPub transformer be registered. + * + * @since version_number_transformer_management_placeholder + * + * @param bool $should_register Should the ActivityPub transformer be registered. Default is `true`. + * @param \ActivityPub\Transformer_Base $transformer_instance Widget instance. + */ + // TODO: does not implementing this slow down the website? -> compare with gutenberg block registration. + // $should_register = apply_filters( 'activitypub/transformers/is_transformer_enabled', true, $transformer_instance ); + + // if ( ! $should_register ) { + // return false; + // } + + $this->transformers[ $transformer_name ] = $transformer_instance; + + return true; + } + + /** + * Init transformers. + * + * Initialize ActivityPub transformer manager. + * Include the builtin transformers by default and add third party ones. + * + * @since version_number_transformer_management_placeholder + * @access private + */ + private function init_transformers() { + $builtin_transformers = [ + 'post' + ]; + + $this->transformers = []; + + foreach ( $builtin_transformers as $transformer_name ) { + include ACTIVITYPUB_PLUGIN_DIR . 'includes/transformer/class-' . $transformer_name . '.php'; + + $class_name = ucfirst( $transformer_name ); + + $class_name = '\Activitypub\Transformer_' . $class_name; + + $this->register( new $class_name() ); + } + + /** + * Let other transformers register. + * + * Fires after the built-in Activitypub transformers are registered. + * + * @since version_number_transformer_management_placeholder + * + * @param Transformers_Manager $this The widgets manager. + */ + do_action( 'activitypub/transformers/register', $this ); + } + + + /** + * Get available ActivityPub transformers. + * + * Retrieve the registered transformers list. If given a transformer name + * it returns the given transformer if it is registered. + * + * @since version_number_transformer_management_placeholder + * @access public + * + * @param string $transformers Optional. Transformer name. Default is null. + * + * @return Transformer_Base|Transformer_Base[]|null Registered transformers. + */ + public function get_transformers( $transformer_name = null ) { + if ( is_null( $this->transformers ) ) { + $this->init_transformers(); + } + + if ( null !== $transformer_name ) { + return isset( $this->transformers[ $transformer_name ] ) ? $this->transformers[ $transformer_name ] : null; + } + + return $this->transformers; + } + + /** + * Get the mapped ActivityPub transformer. + * + * @since version_number_transformer_management_placeholder + * @access public + * + * @param WP_Post|WP_Comment $wp_post The WordPress Post/Comment. + * + * @return Transformer_Base|null Registered transformers. + */ + public function get_transformer( $object ) { + switch ( get_class( $object ) ) { + case 'WP_Post': + $post_type = get_post_type( $object ); + $transformer_mapping = \get_option( 'activitypub_transformer_mapping', array( 'post' => 'activitypub/default', 'page' => 'activitypub/default' ) ) ? \get_option( 'activitypub_transformer_mapping', array( 'post' => 'activitypub/default', 'page' => 'activitypub/default' ) ) : array(); + $transformer_name = $transformer_mapping[ $post_type ]; + return new ( $this->get_transformers( $transformer_name ) ); + case 'WP_Comment': + return new Comment( $object ); + default: + return apply_filters( 'activitypub_transformer', null, $object, get_class( $object ) ); + } + } +} + diff --git a/includes/model/class-post.php b/includes/model/class-post.php index d967ad9..a9aa498 100644 --- a/includes/model/class-post.php +++ b/includes/model/class-post.php @@ -1,7 +1,7 @@ post = $post; - $this->object = Post_Transformer::transform( $post )->to_object(); + $this->object = Post_Transformer->set_wp_post( $post )->to_object(); } /** diff --git a/includes/rest/class-collection.php b/includes/rest/class-collection.php index 365641c..e383d3b 100644 --- a/includes/rest/class-collection.php +++ b/includes/rest/class-collection.php @@ -4,7 +4,7 @@ namespace Activitypub\Rest; use WP_Error; use WP_REST_Server; use WP_REST_Response; -use Activitypub\Transformer\Post; +use Activitypub\Transformers_Manager; use Activitypub\Activity\Activity; use Activitypub\Collection\Users as User_Collection; @@ -168,7 +168,7 @@ class Collection { ); foreach ( $posts as $post ) { - $response['orderedItems'][] = Post::transform( $post )->to_object()->to_array(); + $response['orderedItems'][] = Transformers_Manager::get_transformer( $post )->transform( $post )->to_object()->to_array(); } $rest_response = new WP_REST_Response( $response, 200 ); diff --git a/includes/rest/class-outbox.php b/includes/rest/class-outbox.php index d640d17..a9ebc63 100644 --- a/includes/rest/class-outbox.php +++ b/includes/rest/class-outbox.php @@ -5,7 +5,7 @@ use stdClass; use WP_Error; use WP_REST_Server; use WP_REST_Response; -use Activitypub\Transformer\Post; +use Activitypub\Transformers_Manager; use Activitypub\Activity\Activity; use Activitypub\Collection\Users as User_Collection; @@ -59,7 +59,7 @@ class Outbox { return $user; } - $post_types = \get_option( 'activitypub_support_post_types', array( 'post', 'page' ) ); + $post_types = array_keys( \get_option( 'activitypub_transformer_mapping', array( 'post' => 'activitypub/default', 'page' => 'activitypub/default') ) ); $page = $request->get_param( 'page', 1 ); @@ -105,7 +105,9 @@ class Outbox { ); foreach ( $posts as $post ) { - $post = Post::transform( $post )->to_object(); + $transformer = Transformers_Manager::get_transformer( $wp_post ); + $transformer->transform( $wp_post ); + $post = $transformer->to_object(); $activity = new Activity(); $activity->set_type( 'Create' ); $activity->set_context( null ); diff --git a/includes/transformer/class-post.php b/includes/transformer/class-post.php index 2117ce7..ba8f4ed 100644 --- a/includes/transformer/class-post.php +++ b/includes/transformer/class-post.php @@ -1,11 +1,12 @@ wp_post = $wp_post; - } - - /** - * Transforms the WP_Post object to an ActivityPub Object - * - * @see \Activitypub\Activity\Base_Object - * - * @return \Activitypub\Activity\Base_Object The ActivityPub Object - */ - public function to_object() { - $wp_post = $this->wp_post; - $object = new Base_Object(); - - $object->set_id( $this->get_id() ); - $object->set_url( $this->get_url() ); - $object->set_type( $this->get_object_type() ); - - $published = \strtotime( $wp_post->post_date_gmt ); - - $object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) ); - - $updated = \strtotime( $wp_post->post_modified_gmt ); - - if ( $updated > $published ) { - $object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) ); - } - - $object->set_attributed_to( $this->get_attributed_to() ); - $object->set_content( $this->get_content() ); - $object->set_content_map( - array( - $this->get_locale() => $this->get_content(), - ) - ); - $path = sprintf( 'users/%d/followers', intval( $wp_post->post_author ) ); - - $object->set_to( - array( - 'https://www.w3.org/ns/activitystreams#Public', - get_rest_url_by_path( $path ), - ) - ); - $object->set_cc( $this->get_cc() ); - $object->set_attachment( $this->get_attachments() ); - $object->set_tag( $this->get_tags() ); - - return $object; - } - - /** - * Returns the ID of the Post. - * - * @return string The Posts ID. - */ - public function get_id() { - return $this->get_url(); - } - - /** - * Returns the URL of the Post. - * - * @return string The Posts URL. - */ - public function get_url() { - $post = $this->wp_post; - - if ( 'trash' === get_post_status( $post ) ) { - $permalink = \get_post_meta( $post->ID, 'activitypub_canonical_url', true ); - } else { - $permalink = \get_permalink( $post ); - } - - return \esc_url( $permalink ); - } - - /** - * Returns the User-URL of the Author of the Post. - * - * If `single_user` mode is enabled, the URL of the Blog-User is returned. - * - * @return string The User-URL. - */ - protected function get_attributed_to() { - if ( is_single_user() ) { - $user = new Blog_User(); - return $user->get_url(); - } - - return Users::get_by_id( $this->wp_post->post_author )->get_url(); - } - - /** - * Generates all Media Attachments for a Post. - * - * @return array The Attachments. - */ - protected function get_attachments() { - // Once upon a time we only supported images, but we now support audio/video as well. - // We maintain the image-centric naming for backwards compatibility. - $max_media = intval( \apply_filters( 'activitypub_max_image_attachments', \get_option( 'activitypub_max_image_attachments', ACTIVITYPUB_MAX_IMAGE_ATTACHMENTS ) ) ); - - if ( site_supports_blocks() && \has_blocks( $this->wp_post->post_content ) ) { - return $this->get_block_attachments( $max_media ); - } - - return $this->get_classic_editor_images( $max_media ); - } - - /** - * Get media attachments from blocks. They will be formatted as ActivityPub attachments, not as WP attachments. - * - * @param int $max_media The maximum number of attachments to return. - * - * @return array The attachments. - */ - protected function get_block_attachments( $max_media ) { - // max media can't be negative or zero - if ( $max_media <= 0 ) { - return array(); - } - - $id = $this->wp_post->ID; - - $media_ids = array(); - - // list post thumbnail first if this post has one - if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { - $media_ids[] = \get_post_thumbnail_id( $id ); - } - - if ( $max_media > 0 ) { - $blocks = \parse_blocks( $this->wp_post->post_content ); - $media_ids = self::get_media_ids_from_blocks( $blocks, $media_ids, $max_media ); - } - $media_ids = \array_unique( $media_ids ); - - return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $media_ids ) ); - } - - /** - * Get image attachments from the classic editor. - * Note that audio/video attachments are only supported in the block editor. - * - * @param int $max_images The maximum number of images to return. - * - * @return array The attachments. - */ - protected function get_classic_editor_images( $max_images ) { - // max images can't be negative or zero - if ( $max_images <= 0 ) { - return array(); - } - - $id = $this->wp_post->ID; - - $image_ids = array(); - - // list post thumbnail first if this post has one - if ( \function_exists( 'has_post_thumbnail' ) && \has_post_thumbnail( $id ) ) { - $image_ids[] = \get_post_thumbnail_id( $id ); - --$max_images; - } - - if ( $max_images > 0 ) { - $query = new \WP_Query( - array( - 'post_parent' => $id, - 'post_status' => 'inherit', - 'post_type' => 'attachment', - 'post_mime_type' => 'image', - 'order' => 'ASC', - 'orderby' => 'menu_order ID', - 'posts_per_page' => $max_images, - ) - ); - foreach ( $query->get_posts() as $attachment ) { - if ( ! \in_array( $attachment->ID, $image_ids, true ) ) { - $image_ids[] = $attachment->ID; - } - } - } - $image_ids = \array_unique( $image_ids ); - - return \array_filter( \array_map( array( self::class, 'wp_attachment_to_activity_attachment' ), $image_ids ) ); - } - - /** - * Recursively get media IDs from blocks. - * @param array $blocks The blocks to search for media IDs - * @param array $media_ids The media IDs to append new IDs to - * @param int $max_media The maximum number of media to return. - * - * @return array The image IDs. - */ - protected static function get_media_ids_from_blocks( $blocks, $media_ids, $max_media ) { - - foreach ( $blocks as $block ) { - // recurse into inner blocks - if ( ! empty( $block['innerBlocks'] ) ) { - $media_ids = self::get_media_ids_from_blocks( $block['innerBlocks'], $media_ids, $max_media ); - } - - switch ( $block['blockName'] ) { - case 'core/image': - case 'core/cover': - case 'core/audio': - case 'core/video': - case 'videopress/video': - if ( ! empty( $block['attrs']['id'] ) ) { - $media_ids[] = $block['attrs']['id']; - } - break; - case 'jetpack/slideshow': - case 'jetpack/tiled-gallery': - if ( ! empty( $block['attrs']['ids'] ) ) { - $media_ids = array_merge( $media_ids, $block['attrs']['ids'] ); - } - break; - case 'jetpack/image-compare': - if ( ! empty( $block['attrs']['beforeImageId'] ) ) { - $media_ids[] = $block['attrs']['beforeImageId']; - } - if ( ! empty( $block['attrs']['afterImageId'] ) ) { - $media_ids[] = $block['attrs']['afterImageId']; - } - break; - } - - // stop doing unneeded work - if ( count( $media_ids ) >= $max_media ) { - break; - } - } - - // still need to slice it because one gallery could knock us over the limit - return array_slice( $media_ids, 0, $max_media ); - } - - /** - * Converts a WordPress Attachment to an ActivityPub Attachment. - * - * @param int $id The Attachment ID. - * - * @return array The ActivityPub Attachment. - */ - public static function wp_attachment_to_activity_attachment( $id ) { - $attachment = array(); - $mime_type = \get_post_mime_type( $id ); - $mime_type_parts = \explode( '/', $mime_type ); - // switching on image/audio/video - switch ( $mime_type_parts[0] ) { - case 'image': - $image_size = 'full'; - - /** - * Filter the image URL returned for each post. - * - * @param array|false $thumbnail The image URL, or false if no image is available. - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'full' by default. - */ - $thumbnail = apply_filters( - 'activitypub_get_image', - self::get_image( $id, $image_size ), - $id, - $image_size - ); - - if ( $thumbnail ) { - $alt = \get_post_meta( $id, '_wp_attachment_image_alt', true ); - $image = array( - 'type' => 'Image', - 'url' => $thumbnail[0], - 'mediaType' => $mime_type, - ); - - if ( $alt ) { - $image['name'] = $alt; - } - $attachment = $image; - } - break; - - case 'audio': - case 'video': - $attachment = array( - 'type' => 'Document', - 'mediaType' => $mime_type, - 'url' => \wp_get_attachment_url( $id ), - 'name' => \get_the_title( $id ), - ); - $meta = wp_get_attachment_metadata( $id ); - // height and width for videos - if ( isset( $meta['width'] ) && isset( $meta['height'] ) ) { - $attachment['width'] = $meta['width']; - $attachment['height'] = $meta['height']; - } - // @todo: add `icon` support for audio/video attachments. Maybe use post thumbnail? - break; - } - - return \apply_filters( 'activitypub_attachment', $attachment, $id ); - } - - /** - * Return details about an image attachment. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'full' by default. - * - * @return array|false Array of image data, or boolean false if no image is available. - */ - protected static function get_image( $id, $image_size = 'full' ) { - /** - * Hook into the image retrieval process. Before image retrieval. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'full' by default. - */ - do_action( 'activitypub_get_image_pre', $id, $image_size ); - - $image = \wp_get_attachment_image_src( $id, $image_size ); - - /** - * Hook into the image retrieval process. After image retrieval. - * - * @param int $id The attachment ID. - * @param string $image_size The image size to retrieve. Set to 'full' by default. - */ - do_action( 'activitypub_get_image_post', $id, $image_size ); - - return $image; + public function get_label() { + return 'Built-In'; } /** @@ -452,105 +109,6 @@ class Post { return $object_type; } - /** - * Returns a list of Mentions, used in the Post. - * - * @see https://docs.joinmastodon.org/spec/activitypub/#Mention - * - * @return array The list of Mentions. - */ - protected function get_cc() { - $cc = array(); - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $url ) { - $cc[] = $url; - } - } - - return $cc; - } - - /** - * Returns a list of Tags, used in the Post. - * - * This includes Hash-Tags and Mentions. - * - * @return array The list of Tags. - */ - protected function get_tags() { - $tags = array(); - - $post_tags = \get_the_tags( $this->wp_post->ID ); - if ( $post_tags ) { - foreach ( $post_tags as $post_tag ) { - $tag = array( - 'type' => 'Hashtag', - 'href' => \esc_url( \get_tag_link( $post_tag->term_id ) ), - 'name' => esc_hashtag( $post_tag->name ), - ); - $tags[] = $tag; - } - } - - $mentions = $this->get_mentions(); - if ( $mentions ) { - foreach ( $mentions as $mention => $url ) { - $tag = array( - 'type' => 'Mention', - 'href' => \esc_url( $url ), - 'name' => \esc_html( $mention ), - ); - $tags[] = $tag; - } - } - - return $tags; - } - - /** - * Returns the content for the ActivityPub Item. - * - * The content will be generated based on the user settings. - * - * @return string The content. - */ - protected function get_content() { - global $post; - - /** - * Provides an action hook so plugins can add their own hooks/filters before AP content is generated. - * - * Example: if a plugin adds a filter to `the_content` to add a button to the end of posts, it can also remove that filter here. - * - * @param WP_Post $post The post object. - */ - do_action( 'activitypub_before_get_content', $post ); - - // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited - $post = $this->wp_post; - $content = $this->get_post_content_template(); - - // Register our shortcodes just in time. - Shortcodes::register(); - // Fill in the shortcodes. - setup_postdata( $post ); - $content = do_shortcode( $content ); - wp_reset_postdata(); - - $content = \wpautop( $content ); - $content = \preg_replace( '/[\n\r\t]/', '', $content ); - $content = \trim( $content ); - - $content = \apply_filters( 'activitypub_the_content', $content, $post ); - - // Don't need these any more, should never appear in a post. - Shortcodes::unregister(); - - return $content; - } - /** * Gets the template to use to generate the content of the activitypub item. * @@ -572,33 +130,4 @@ class Post { return \get_option( 'activitypub_custom_post_content', ACTIVITYPUB_CUSTOM_POST_CONTENT ); } - /** - * Helper function to get the @-Mentions from the post content. - * - * @return array The list of @-Mentions. - */ - protected function get_mentions() { - return apply_filters( 'activitypub_extract_mentions', array(), $this->wp_post->post_content, $this->wp_post ); - } - - /** - * Returns the locale of the post. - * - * @return string The locale of the post. - */ - public function get_locale() { - $post_id = $this->wp_post->ID; - $lang = \strtolower( \strtok( \get_locale(), '_-' ) ); - - /** - * Filter the locale of the post. - * - * @param string $lang The locale of the post. - * @param int $post_id The post ID. - * @param WP_Post $post The post object. - * - * @return string The filtered locale of the post. - */ - return apply_filters( 'activitypub_post_locale', $lang, $post_id, $this->wp_post ); - } } diff --git a/templates/post-json.php b/templates/post-json.php index 89467c4..0174448 100644 --- a/templates/post-json.php +++ b/templates/post-json.php @@ -2,8 +2,10 @@ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $post = \get_post(); -$object = new \Activitypub\Transformer\Post( $post ); -$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $object->to_object()->to_array() ); +$transformer = \Activitypub\Transformers_Manager::instance()->get_transformer( $post ); +$transformer->set_wp_post( $post ); + +$json = \array_merge( array( '@context' => \Activitypub\get_context() ), $transformer->to_object()->to_array() ); // filter output $json = \apply_filters( 'activitypub_json_post_array', $json ); diff --git a/templates/settings.php b/templates/settings.php index 5ce90e1..91a35fa 100644 --- a/templates/settings.php +++ b/templates/settings.php @@ -201,26 +201,6 @@

- - - -
- - - true ), 'objects' ); ?> - - -
- - @@ -236,6 +216,93 @@ + + +
+

+ + + + + + + + +
+ + + + + true ), 'objects' ); + $transformer_mapping = \get_option( 'activitypub_transformer_mapping', array( 'default' => 'note' ) ); + + $all_public_post_type_names = array_map(function ($object) { + return $object->name; + }, $all_public_post_types); + + $transformer_manager = \Activitypub\Transformers_Manager::instance(); + $transformers = $transformer_manager->get_transformers(); + + ?> + + + + + + + + + ' . htmlspecialchars($transformer->get_label()) . ''; + } + ?> + + + + '; + echo ''; + // Generate radio inputs for each transformer, considering support for the post type + foreach ($transformers as $transformer) { + $disabled_attribute = $transformer->supports_post_type( $post_type->name ) ? '' : ' disabled'; + $is_selected = ( is_array( $transformer_mapping ) && isset( $transformer_mapping[ $post_type->name ] ) && $transformer_mapping[ $post_type->name ] === $transformer->get_name() ) ? ' checked ' : ''; + echo ''; + } + + echo ''; + } + ?> + +
' . htmlspecialchars($post_type->label) . '
+ + +
+
+

diff --git a/tests/test-class-activitypub-activity.php b/tests/test-class-activitypub-activity.php index ba9f5a2..70ddb33 100644 --- a/tests/test-class-activitypub-activity.php +++ b/tests/test-class-activitypub-activity.php @@ -16,8 +16,9 @@ class Test_Activitypub_Activity extends WP_UnitTestCase { }, 10 ); - - $activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object(); + + $wp_post = get_post( $post ); + $activitypub_post = \Activitypub\Transformers_Manager::get_transforemr( $wp_post )->transform( $wp_post )->to_object(); $activitypub_activity = new \Activitypub\Activity\Activity(); $activitypub_activity->set_type( 'Create' ); diff --git a/tests/test-class-activitypub-post.php b/tests/test-class-activitypub-post.php index 0b5ee6d..eccd6bc 100644 --- a/tests/test-class-activitypub-post.php +++ b/tests/test-class-activitypub-post.php @@ -10,13 +10,13 @@ class Test_Activitypub_Post extends WP_UnitTestCase { $permalink = \get_permalink( $post ); - $activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object(); + $activitypub_post = \Activitypub\Transformers_Manager::get_transformer( get_post( $post ) )->transform( get_post( $post ) )->to_object(); $this->assertEquals( $permalink, $activitypub_post->get_id() ); \wp_trash_post( $post ); - $activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object(); + $activitypub_post = \Activitypub\Transformers_Manager::get_transformer( get_post( $post ) )->transform( get_post( $post ) )->to_object(); $this->assertEquals( $permalink, $activitypub_post->get_id() ); diff --git a/tests/test-class-activitypub-rest-post-signature-verification.php b/tests/test-class-activitypub-rest-post-signature-verification.php index 2d1c2f9..2da383b 100644 --- a/tests/test-class-activitypub-rest-post-signature-verification.php +++ b/tests/test-class-activitypub-rest-post-signature-verification.php @@ -10,7 +10,7 @@ class Test_Activitypub_Signature_Verification extends WP_UnitTestCase { ) ); $remote_actor = \get_author_posts_url( 2 ); - $activitypub_post = \Activitypub\Transformer\Post::transform( get_post( $post ) )->to_object(); + $activitypub_post = \Activitypub\Transformers_Manager::get_transformer( get_post( $post ) )->transform( get_post( $post ) )->to_object(); $activitypub_activity = new Activitypub\Activity\Activity( 'Create' ); $activitypub_activity->set_type( 'Create' ); $activitypub_activity->set_object( $activitypub_post ); @@ -82,7 +82,7 @@ class Test_Activitypub_Signature_Verification extends WP_UnitTestCase { ); $remote_actor = \get_author_posts_url( 2 ); $remote_actor_inbox = Activitypub\get_rest_url_by_path( '/inbox' ); - $activitypub_post = \Activitypub\Transformer\Post::transform( \get_post( $post ) )->to_object(); + $activitypub_post = \Activitypub\Transformers_Manager::get_transformer( get_post( $post ) )->transform( get_post( $post ) )->to_object(); $activitypub_activity = new Activitypub\Activity\Activity(); $activitypub_activity->set_type( 'Create' ); $activitypub_activity->set_object( $activitypub_post );