<?php /** * Replace the default ActivityPub Transformer * * @package ActivityPub_Event_Bridge * @license AGPL-3.0-or-later */ namespace ActivityPub_Event_Bridge\Activitypub\Transformer; // Exit if accessed directly. defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore use Activitypub\Activity\Extended_Object\Event as Event_Object; use Activitypub\Activity\Extended_Object\Place; use Activitypub\Shortcodes; use Activitypub\Transformer\Post; use DateTime; /** * Base transformer for WordPress event post types to ActivityPub events. * * Everything that transforming several WordPress post types that represent events * have in common, as well as sane defaults for events should be defined here. * * BeforeFirstRelease: * [ ] remove link at the end of the content. * [ ] add organizer. * [ ] do add Cancelled reason in the content. */ abstract class Event extends Post { /** * The WordPress event taxonomy. * * @var string */ protected $wp_taxonomy; /** * Returns the ActivityStreams 2.0 Object-Type for an Event. * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event * * @return string The Event Object-Type. */ protected function get_type(): string { return 'Event'; } /** * Get a sane default for whether comments are enabled. */ protected function get_comments_enabled(): ?bool { return comments_open( $this->wp_object ); } /** * Set a hardcoded template for the content. * * This actually disabled templates for the content. * Maybe this independent templates for events will be added later. */ protected function get_post_content_template(): string { return '[ap_content]'; } /** * Returns the title of the event. * * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-name * * @return string The name. */ protected function get_name(): string { return $this->wp_object->post_title; } /** * Extend the construction of the Post Transformer to also set the according taxonomy of the event post type. * * @param WP_Post $wp_object The WordPress post object (event). * @param string $wp_taxonomy The taxonomy slug of the event post type. */ public function __construct( $wp_object, $wp_taxonomy = 'category' ) { parent::__construct( $wp_object ); $this->wp_taxonomy = $wp_taxonomy; } /** * Extract the join mode. * * Currently we don't handle joins, we always mark events as external. * * @return string */ public function get_join_mode(): ?string { return 'external'; } /** * Extract the external participation url. * * Currently we don't handle joins, we always mark events as external. * We just link back to the events HTML representation on our WordPress site. * * @return ?string The external participation URL. */ public function get_external_participation_url(): ?string { return 'external' === $this->get_join_mode() ? $this->get_url() : null; } /** * Set the event category, via the mapping setting. * * @return ?string */ public function get_category(): ?string { if ( is_null( $this->wp_taxonomy ) ) { return null; } $current_category_mapping = \get_option( 'activitypub_event_bridge_event_category_mappings', array() ); $terms = \get_the_terms( $this->wp_object, $this->wp_taxonomy ); // Check if the event has a category set and if that category has a specific mapping return that one. if ( ! is_wp_error( $terms ) && $terms && array_key_exists( $terms[0]->slug, $current_category_mapping ) ) { return sanitize_text_field( $current_category_mapping[ $terms[0]->slug ] ); } else { // Return the default event category. return sanitize_text_field( \get_option( 'activitypub_event_bridge_default_event_category', 'MEETING' ) ); } } /** * Retrieves the excerpt text (may be HTML). Used for constructing the summary. * * @return ?string */ protected function retrieve_excerpt(): ?string { if ( $this->wp_object->post_excerpt ) { return $this->wp_object->post_excerpt; } else { return null; } } /** * Get the start time. * * This is mandatory and must be implemented in the final event transformer class. */ abstract public function get_start_time(): string; /** * Get the end time. * * This is not mandatory and therefore just return null by default. */ public function get_end_time(): ?string { return null; } /** * Get a default for the location. * * This should be overridden in the actual event transformer. */ public function get_location(): ?Place { return null; } /** * Default value for the event status. */ public function get_status(): ?string { return 'CONFIRMED'; } /** * Compose a human readable formatted start time. */ protected function format_start_time(): string { return $this->format_time( $this->get_start_time() ); } /** * Compose a human readable formatted end time. */ protected function format_end_time(): string { return $this->format_time( $this->get_end_time() ); } /** * Compose a human readable formatted time. * * @param ?string $time The time which needs to be formatted. */ protected static function format_time( $time ) { if ( is_null( $time ) ) { return ''; } $start_datetime = new DateTime( $time ); $start_timestamp = $start_datetime->getTimestamp(); $datetime_format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); return wp_date( $datetime_format, $start_timestamp ); } /** * Generates output for the 'ap_start_time' shortcode. * * @param ?array $atts The shortcode's attributes. * @return string The formatted start date and time of the event. */ public function shortcode_start_time( $atts ) { $start_timestamp = $this->get_start_time(); return $this->generate_time_output( $start_timestamp, $atts, '๐๏ธ', __( 'Start', 'activitypub-event-bridge' ) ); } /** * Generates output for the 'ap_end_time' shortcode. * * @param ?array $atts The shortcode's attributes. * @return string The formatted end date and time of the event. */ public function shortcode_end_time( $atts ) { $end_timestamp = $this->get_end_time(); return $this->generate_time_output( $end_timestamp, $atts, 'โณ', __( 'End', 'activitypub-event-bridge' ) ); } /** * Generates the formatted time output for a shortcode. * * @param int|null $timestamp The timestamp for the event time. * @param array $atts The shortcode attributes. * @param string $icon The icon to display. * @param string $label The label to display (e.g., 'Start', 'End'). * @return string The formatted date and time, or an empty string if the timestamp is invalid. */ private function generate_time_output( $timestamp, $atts, $icon, $label ) { if ( ! $timestamp ) { return ''; } $args = shortcode_atts( array( 'icon' => 'true', 'label' => 'true', ), $atts ); $args['icon'] = filter_var( $args['icon'], FILTER_VALIDATE_BOOLEAN ); $args['label'] = filter_var( $args['label'], FILTER_VALIDATE_BOOLEAN ); $output = array(); if ( $args['icon'] ) { $output[] = $icon; } if ( $args['label'] ) { $output[] = $label . ':'; } $output[] = self::format_time( $timestamp ); return implode( ' ', $output ); } /** * Generates output for the 'ap_location' shortcode. * * @param ?array $atts The shortcode's attributes. * @return string The formatted location/address of the event. */ public function shortcode_location( $atts ) { $args = shortcode_atts( array( 'icon' => 'true', 'label' => 'true', 'country' => 'true', 'zip' => 'true', 'city' => 'true', 'street' => 'true', 'name' => 'true', ), $atts, 'ap_location' ); // Convert attributes to booleans. $args = array_map( function ( $value ) { return filter_var( $value, FILTER_VALIDATE_BOOLEAN ); }, $args ); $location = $this->get_location(); if ( ! $location ) { return ''; } $output = array(); if ( $args['icon'] ) { $output[] = '๐'; } if ( $args['label'] ) { $output[] = esc_html__( 'Location', 'activitypub-event-bridge' ) . ':'; } $output[] = self::format_address( $location->get_address(), $args ); // Join output array into a single string with spaces and return. return implode( ' ', array_filter( $output ) ); } /** * Formats the address based on provided arguments. * * @param mixed $address The address data, either as a string or an array. * @param array $args The arguments for which components to include. * @return string The formatted address. */ protected static function format_address( $address, $args = null ) { if ( is_string( $address ) ) { return esc_html( $address ); } if ( is_null( $args ) ) { $args = array( 'icon' => 'true', 'title' => 'true', 'country' => 'true', 'zip' => 'true', 'city' => 'true', 'street' => 'true', 'name' => 'true', ); } if ( is_array( $address ) ) { $address_parts = array(); $components = array( 'name' => 'name', 'street' => 'streetAddress', 'zip' => 'postalCode', 'city' => 'addressLocality', 'country' => 'addressCountry', ); foreach ( $components as $arg_key => $address_key ) { if ( $args[ $arg_key ] && ! empty( $address[ $address_key ] ) ) { $address_parts[] = esc_html( $address[ $address_key ] ); } } return implode( ', ', $address_parts ); } return ''; } /** * Format the category using the translation. */ protected function format_categories(): string { if ( is_null( $this->wp_taxonomy ) ) { return ''; } $categories = array(); // Add the federated category string. require_once ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_DIR . '/includes/event-categories.php'; $federated_category = $this->get_category(); if ( array_key_exists( $federated_category, ACTIVITYPUB_EVENT_BRIDGE_EVENT_CATEGORIES ) ) { $categories[] = ACTIVITYPUB_EVENT_BRIDGE_EVENT_CATEGORIES[ $federated_category ]; } // Add all category terms. $terms = \get_the_terms( $this->wp_object, $this->wp_taxonomy ); if ( $terms && ! is_wp_error( $terms ) ) { foreach ( $terms as $term ) { $categories[] = $term->name; } } if ( ! empty( $categories ) ) { return implode( ' ยท ', array_unique( $categories ) ); } return ''; } /** * Register the shortcodes. */ public function register_shortcodes() { foreach ( get_class_methods( self::class ) as $function ) { if ( 'shortcode_' === substr( $function, 0, 10 ) ) { add_shortcode( 'ap_' . substr( $function, 10, strlen( $function ) ), array( $this, $function ) ); } } } /** * Register the shortcodes. */ public function unregister_shortcodes() { foreach ( get_class_methods( self::class ) as $function ) { if ( 'shortcode_' === substr( $function, 0, 10 ) ) { remove_shortcode( 'ap_' . substr( $function, 10, strlen( $function ) ), array( $this, $function ) ); } } } /** * Get the summary. */ public function get_summary(): ?string { if ( 'preset' === get_option( 'activitypub_summary_type', 'preset' ) ) { return $this->format_preset_summary(); } // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited $post = $this->wp_object; $summary = $this->get_event_summary_template(); // It seems that shortcodes are only applied to published posts. if ( is_preview() ) { $post->post_status = 'publish'; } // Register our shortcodes just in time. Shortcodes::register(); $this->register_shortcodes(); // Fill in the shortcodes. \setup_postdata( $post ); $summary = \do_shortcode( $summary ); \wp_reset_postdata(); $summary = \wpautop( $summary ); $summary = \preg_replace( '/[\n\r\t]/', '', $summary ); $summary = \trim( $summary ); $summary = \apply_filters( 'activitypub_event_bridge_the_summary', $summary, $post ); // Unregister the shortcodes. Shortcodes::unregister(); $this->unregister_shortcodes(); return $summary; } /** * Create a custom summary. * * It contains also the most important meta-information. The summary is often used when the * ActivityPub object type 'Event' is not supported, e.g. in Mastodon. * * @return string $summary The custom event summary. */ public function format_preset_summary(): ?string { add_filter( 'activitypub_object_content_template', array( self::class, 'remove_ap_permalink_from_template' ), 2, 2 ); $excerpt = $this->retrieve_excerpt(); // BeforeFirstRelease: decide whether this should be a admin setting. $fallback_to_content = false; if ( is_null( $excerpt ) && $fallback_to_content ) { $excerpt = $this->get_content(); } remove_filter( 'activitypub_object_content_template', array( self::class, 'remove_ap_permalink_from_template' ) ); $category = $this->format_categories(); $start_time = $this->get_start_time(); $end_time = $this->get_end_time(); $address = $this->format_address( $this->get_location() ); $time_atts = array( 'icon' => true, 'label' => true, ); $formatted_items = array(); if ( ! empty( $category ) ) { $formatted_items[] = '๐ท๏ธ ' . __( 'Category', 'activitypub-event-bridge' ) . ': ' . $category; } if ( ! empty( $start_time ) ) { $formatted_items[] = $this->generate_time_output( $start_time, $time_atts, '๐๏ธ', __( 'Start', 'activitypub-event-bridge' ) ); } if ( ! empty( $end_time ) ) { $formatted_items[] = $this->generate_time_output( $end_time, $time_atts, 'โณ', __( 'End', 'activitypub-event-bridge' ) ); } if ( ! empty( $address ) ) { $formatted_items[] = '๐ ' . __( 'Address', 'activitypub-event-bridge' ) . ': ' . $address; } // Compose the summary based on the number of meta items. if ( count( $formatted_items ) > 1 ) { $summary = '<ul><li>' . implode( '</li><li>', $formatted_items ) . '</li></ul>'; } elseif ( 1 === count( $formatted_items ) ) { $summary = $formatted_items[0]; // Just the one item without <ul><li>. } else { $summary = ''; // No items, so no output. } $summary .= $excerpt; return $summary; } /** * Gets the template to use to generate the summary of the ActivityStreams representation of an event post. * * @return string The Template. */ protected function get_event_summary_template() { $summary = \get_option( 'activitypub_event_bridge_custom_summary', ACTIVITYPUB_EVENT_BRIDGE_CUSTOM_SUMMARY ); $template = $summary ?? ACTIVITYPUB_EVENT_BRIDGE_CUSTOM_SUMMARY; return apply_filters( 'activitypub_event_bridge_summary_template', $template, $this->wp_object ); } /** * By default set the timezone of the WordPress site. * * This is likely to be overwritten by the actual transformer. * * @return string The timezone string of the site. */ public function get_timezone(): string { return wp_timezone_string(); } /** * Remove the permalink shortcode from a WordPress template. * * This used for the summary template, because the summary usually gets, * used when converting a object, where the URL is usually appended anyway. * * @param string $template The template string. * @param WP_Post|WP_Comment $wp_object The wp_object which was used to select the template. */ public static function remove_ap_permalink_from_template( $template, $wp_object ) { // we could override the template here, to get out custom template from an option. if ( 'event' === $wp_object->post_type ) { $template = str_replace( '[ap_permalink]', '', $template ); $template = str_replace( '[ap_permalink type="html"]', '', $template ); } return $template; } /** * Generic function that converts an WP-Event object to an ActivityPub-Event object. * * @return Event_Object */ public function to_object(): Event_Object { $activitypub_object = new Event_Object(); $activitypub_object = $this->transform_object_properties( $activitypub_object ); // maybe move the following logic (till end of the function) into getter functions. $published = \strtotime( $this->wp_object->post_date_gmt ); $activitypub_object->set_published( \gmdate( 'Y-m-d\TH:i:s\Z', $published ) ); $updated = \strtotime( $this->wp_object->post_modified_gmt ); if ( $updated > $published ) { $activitypub_object->set_updated( \gmdate( 'Y-m-d\TH:i:s\Z', $updated ) ); } $activitypub_object->set_content_map( array( $this->get_locale() => $this->get_content(), ) ); $activitypub_object->set_to( array( 'https://www.w3.org/ns/activitystreams#Public', $this->get_actor_object()->get_followers(), // this fails on my machine. ) ); return $activitypub_object; } }