From d229529acc76f0ea1982de042cabb07fe15a1679 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Thu, 10 Oct 2024 15:31:24 +0200
Subject: [PATCH 01/13] Add transormer for WP Event Manager, closes #54 (#55)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/55
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
.forgejo/workflows/phpunit.yml | 7 +-
bin/install-wp-tests.sh | 1 +
composer.json | 8 +-
.../transformer/class-events-manager.php | 1 -
.../transformer/class-gatherpress.php | 1 -
.../transformer/class-the-events-calendar.php | 1 -
.../transformer/class-vs-event-list.php | 1 -
.../transformer/class-wp-event-manager.php | 140 ++++++++++++++
includes/class-setup.php | 1 +
includes/plugins/class-wp-event-manager.php | 72 +++++++
tests/bootstrap.php | 3 +
tests/test-class-plugin-vs-event-list.php | 10 +-
tests/test-class-plugin-wp-event-manager.php | 182 ++++++++++++++++++
13 files changed, 415 insertions(+), 13 deletions(-)
create mode 100644 includes/activitypub/transformer/class-wp-event-manager.php
create mode 100644 includes/plugins/class-wp-event-manager.php
create mode 100644 tests/test-class-plugin-wp-event-manager.php
diff --git a/.forgejo/workflows/phpunit.yml b/.forgejo/workflows/phpunit.yml
index 5bdae8d..b67d8e9 100644
--- a/.forgejo/workflows/phpunit.yml
+++ b/.forgejo/workflows/phpunit.yml
@@ -37,7 +37,7 @@ jobs:
path: |
${{ env.WP_CORE_DIR }}
${{ env.WP_TESTS_DIR }}
- key: cache-wordpress-4
+ key: cache-wordpress-5
- name: Cache Composer
id: cache-composer-phpunit
@@ -91,5 +91,10 @@ jobs:
- name: Run Integration tests for Events Manager
run: cd /workspace/Event-Federation/wordpress-activitypub-event-bridge/ && ./vendor/bin/phpunit --filter=events_manager
+ env:
+ PHP_VERSION: ${{ matrix.php-version }}
+
+ - name: Run Integration tests for WP Event Manager
+ run: cd /workspace/Event-Federation/wordpress-activitypub-event-bridge/ && ./vendor/bin/phpunit --filter=wp_event_manager
env:
PHP_VERSION: ${{ matrix.php-version }}
\ No newline at end of file
diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh
index 8d495bf..0e0e4ad 100755
--- a/bin/install-wp-tests.sh
+++ b/bin/install-wp-tests.sh
@@ -238,6 +238,7 @@ install_wp_plugins() {
install_wp_plugin very-simple-event-list
install_wp_plugin gatherpress
install_wp_plugin events-manager
+ install_wp_plugin wp-event-manager
}
install_wp
diff --git a/composer.json b/composer.json
index 44b5406..1b3bb36 100644
--- a/composer.json
+++ b/composer.json
@@ -50,15 +50,17 @@
"@test-vs-event-list",
"@test-the-events-calendar",
"@test-gatherpress",
- "@test-events-manager"
+ "@test-events-manager",
+ "@test-wp-event-manager"
],
"test-debug": [
"@prepare-test",
- "@test-gatherpress"
+ "@test-wp-event-manager"
],
"test-vs-event-list": "phpunit --filter=vs_event_list",
"test-the-events-calendar": "phpunit --filter=the_events_calendar",
"test-gatherpress": "phpunit --filter=gatherpress",
- "test-events-manager": "phpunit --filter=events_manager"
+ "test-events-manager": "phpunit --filter=events_manager",
+ "test-wp-event-manager": "phpunit --filter=wp_event_manager"
}
}
diff --git a/includes/activitypub/transformer/class-events-manager.php b/includes/activitypub/transformer/class-events-manager.php
index 3acf6aa..1def3f7 100644
--- a/includes/activitypub/transformer/class-events-manager.php
+++ b/includes/activitypub/transformer/class-events-manager.php
@@ -11,7 +11,6 @@ namespace ActivityPub_Event_Bridge\Activitypub\Transformer;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
-use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
use ActivityPub_Event_Bridge\Activitypub\Transformer\Event as Event_Transformer;
use DateTime;
diff --git a/includes/activitypub/transformer/class-gatherpress.php b/includes/activitypub/transformer/class-gatherpress.php
index e3ec013..b67bc58 100644
--- a/includes/activitypub/transformer/class-gatherpress.php
+++ b/includes/activitypub/transformer/class-gatherpress.php
@@ -13,7 +13,6 @@ defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use Activitypub\Activity\Extended_Object\Event as Event_Object;
use Activitypub\Activity\Extended_Object\Place;
-use Activitypub\Model\Blog;
use ActivityPub_Event_Bridge\Activitypub\Transformer\Event;
use GatherPress\Core\Event as GatherPress_Event;
diff --git a/includes/activitypub/transformer/class-the-events-calendar.php b/includes/activitypub/transformer/class-the-events-calendar.php
index 72e25c8..9d76aaa 100644
--- a/includes/activitypub/transformer/class-the-events-calendar.php
+++ b/includes/activitypub/transformer/class-the-events-calendar.php
@@ -11,7 +11,6 @@ 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_Event_Bridge\Activitypub\Transformer\Event;
use WP_Post;
diff --git a/includes/activitypub/transformer/class-vs-event-list.php b/includes/activitypub/transformer/class-vs-event-list.php
index 35d33a2..c476404 100644
--- a/includes/activitypub/transformer/class-vs-event-list.php
+++ b/includes/activitypub/transformer/class-vs-event-list.php
@@ -11,7 +11,6 @@ namespace ActivityPub_Event_Bridge\Activitypub\Transformer;
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
-use Activitypub\Activity\Extended_Object\Event;
use Activitypub\Activity\Extended_Object\Place;
use ActivityPub_Event_Bridge\Activitypub\Transformer\Event as Event_Transformer;
diff --git a/includes/activitypub/transformer/class-wp-event-manager.php b/includes/activitypub/transformer/class-wp-event-manager.php
new file mode 100644
index 0000000..6330152
--- /dev/null
+++ b/includes/activitypub/transformer/class-wp-event-manager.php
@@ -0,0 +1,140 @@
+wp_object->ID, '_event_online', true );
+ $is_online = false;
+ // Radio buttons.
+ if ( 'yes' === $is_online_text ) {
+ $is_online = true;
+ }
+ // Checkbox.
+ if ( '1' === $is_online_text ) {
+ $is_online = true;
+ }
+ return $is_online;
+ }
+
+ /**
+ * Get the event location.
+ *
+ * @return array The Place.
+ */
+ public function get_location(): ?Place {
+ $location_name = get_post_meta( $this->wp_object->ID, '_event_location', true );
+
+ if ( $location_name ) {
+ $location = new Place();
+ $location->set_name( $location_name );
+ $location->set_sensitive( null );
+ $location->set_address( $location_name );
+
+ return $location;
+ }
+ return null;
+ }
+
+ /**
+ * Get the end time from the events metadata.
+ *
+ * @return ?string The events end-datetime if is set, null otherwise.
+ */
+ public function get_end_time(): ?string {
+ $end_date = get_post_meta( $this->wp_object->ID, '_event_end_date', true );
+ if ( $end_date ) {
+ $end_datetime = new DateTime( $end_date );
+ return \gmdate( 'Y-m-d\TH:i:s\Z', $end_datetime->getTimestamp() );
+ }
+ return null;
+ }
+
+ /**
+ * Get the end time from the events metadata.
+ */
+ public function get_start_time(): string {
+ $start_date = get_post_meta( $this->wp_object->ID, '_event_start_date', true );
+ if ( ! is_numeric( $start_date ) ) {
+ $start_datetime = new DateTime( $start_date );
+ $start_timestamp = $start_datetime->getTimestamp();
+ } else {
+ $start_timestamp = (int) $start_date;
+ }
+
+ return \gmdate( 'Y-m-d\TH:i:s\Z', $start_timestamp );
+ }
+
+ /**
+ * Get the event link as an ActivityPub Link object, but as an associative array.
+ *
+ * @return ?array
+ */
+ private function get_event_link_attachment(): ?array {
+ $event_link_url = get_post_meta( $this->wp_object->ID, '_event_video_url', true );
+
+ if ( str_starts_with( $event_link_url, 'http' ) ) {
+ return array(
+ 'type' => 'Link',
+ 'name' => \esc_html__( 'Video URL', 'activitypub-event-bridge' ),
+ 'href' => \esc_url( $event_link_url ),
+ 'mediaType' => 'text/html',
+ );
+ } else {
+ return null;
+ }
+ }
+
+ /**
+ * Overrides/extends the get_attachments function to also add the event Link.
+ */
+ protected function get_attachment() {
+ // Get attachments via parent function.
+ $attachments = parent::get_attachment();
+
+ // The first attachment is the featured image, make sure it is compatible with Mobilizon.
+ if ( count( $attachments ) ) {
+ $attachments[0]['type'] = 'Document';
+ $attachments[0]['name'] = 'Banner';
+ }
+
+ if ( $this->get_event_link_attachment() ) {
+ $attachments[] = $this->get_event_link_attachment();
+ }
+ return $attachments;
+ }
+
+ /**
+ * Get the events title/name.
+ *
+ * @return string
+ */
+ protected function get_name(): string {
+ return $this->wp_object->post_title;
+ }
+}
diff --git a/includes/class-setup.php b/includes/class-setup.php
index 39a4766..bafe050 100644
--- a/includes/class-setup.php
+++ b/includes/class-setup.php
@@ -128,6 +128,7 @@ class Setup {
'\ActivityPub_Event_Bridge\Plugins\GatherPress',
'\ActivityPub_Event_Bridge\Plugins\The_Events_Calendar',
'\ActivityPub_Event_Bridge\Plugins\VS_Event_List',
+ '\ActivityPub_Event_Bridge\Plugins\WP_Event_Manager',
);
/**
diff --git a/includes/plugins/class-wp-event-manager.php b/includes/plugins/class-wp-event-manager.php
new file mode 100644
index 0000000..e3d3493
--- /dev/null
+++ b/includes/plugins/class-wp-event-manager.php
@@ -0,0 +1,72 @@
+ 'VSEL Test Event',
- 'post_status' => 'published',
+ 'post_status' => 'publish',
'post_type' => 'event',
'meta_input' => array(
'event-start-date' => strtotime( '+10 days 15:00:00' ),
@@ -69,7 +69,7 @@ class Test_VS_Event_List extends WP_UnitTestCase {
$wp_post_id = wp_insert_post(
array(
'post_title' => 'VSEL Test Event',
- 'post_status' => 'published',
+ 'post_status' => 'publish',
'post_type' => 'event',
'meta_input' => array(
'event-start-date' => strtotime( '+10 days 15:00:00' ),
@@ -102,7 +102,7 @@ class Test_VS_Event_List extends WP_UnitTestCase {
$wp_post_id = wp_insert_post(
array(
'post_title' => 'VSEL Test Event',
- 'post_status' => 'published',
+ 'post_status' => 'publish',
'post_type' => 'event',
'meta_input' => array(
'event-start-date' => strtotime( '+10 days 15:00:00' ),
@@ -147,7 +147,7 @@ class Test_VS_Event_List extends WP_UnitTestCase {
$wp_post_id = wp_insert_post(
array(
'post_title' => 'VSEL Test Event',
- 'post_status' => 'published',
+ 'post_status' => 'publish',
'post_type' => 'event',
'meta_input' => array(
'event-start-date' => strtotime( '+10 days 15:00:00' ),
@@ -182,7 +182,7 @@ class Test_VS_Event_List extends WP_UnitTestCase {
$wp_post_id = wp_insert_post(
array(
'post_title' => 'VSEL Test Event',
- 'post_status' => 'published',
+ 'post_status' => 'publish',
'post_type' => 'event',
'meta_input' => array(
'event-start-date' => strtotime( '+10 days 15:00:00' ),
diff --git a/tests/test-class-plugin-wp-event-manager.php b/tests/test-class-plugin-wp-event-manager.php
new file mode 100644
index 0000000..318a295
--- /dev/null
+++ b/tests/test-class-plugin-wp-event-manager.php
@@ -0,0 +1,182 @@
+activate_activitypub_support_for_active_event_plugins();
+
+ // Delete all posts afterwards.
+ _delete_all_posts();
+ }
+
+ /**
+ * Test that the right transformer gets applied.
+ */
+ public function test_transformer_class() {
+ // We only test for one event plugin being active at the same time,
+ // even though we support multiple onces in theory.
+ // But testing all combinations is beyond scope.
+ $active_event_plugins = \ActivityPub_Event_Bridge\Setup::get_instance()->get_active_event_plugins();
+ $this->assertEquals( 1, count( $active_event_plugins ) );
+
+ // Enable ActivityPub support for the event plugin.
+ $this->assertContains( 'event_listing', get_option( 'activitypub_support_post_types' ) );
+
+ // Insert a new Event.
+ $wp_post_id = wp_insert_post(
+ array(
+ 'post_title' => 'WP Event Manager TestEvent',
+ 'post_status' => 'publish',
+ 'post_type' => 'event_listing',
+ 'meta_input' => array(
+ 'event-start-date' => strtotime( '+10 days 15:00:00' ),
+ ),
+ )
+ );
+
+ $wp_object = get_post( $wp_post_id );
+
+ // Call the transformer Factory.
+ $transformer = \Activitypub\Transformer\Factory::get_transformer( $wp_object );
+
+ // Check that we got the right transformer.
+ $this->assertInstanceOf( \ActivityPub_Event_Bridge\Activitypub\Transformer\WP_Event_Manager::class, $transformer );
+ }
+
+ /**
+ * Test the transformation to ActivityStreams of minimal event.
+ */
+ public function test_transform_of_minimal_event() {
+ // Insert a new Event.
+ $wp_post_id = wp_insert_post(
+ array(
+ 'post_title' => 'WP Event Manager TestEvent',
+ 'post_status' => 'publish',
+ 'post_type' => 'event_listing',
+ 'post_content' => 'Come to my WP Event Manager event!',
+ 'meta_input' => array(
+ '_event_start_date' => strtotime( '+10 days 15:00:00' ),
+ ),
+ )
+ );
+
+ // Transform the event to ActivityStreams.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( get_post( $wp_post_id ) )->to_object()->to_array();
+
+ // Check that the event ActivityStreams representation contains everything as expected.
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'WP Event Manager TestEvent', $event_array['name'] );
+ $this->assertEquals( 'Come to my WP Event Manager event!', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ) . 'T15:00:00Z', $event_array['startTime'] );
+ $this->assertArrayNotHasKey( 'endTime', $event_array );
+ $this->assertEquals( comments_open( $wp_post_id ), $event_array['commentsEnabled'] );
+ $this->assertEquals( comments_open( $wp_post_id ) ? 'allow_all' : 'closed', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertEquals( esc_url( get_permalink( $wp_post_id ) ), $event_array['externalParticipationUrl'] );
+ $this->assertArrayNotHasKey( 'location', $event_array );
+ $this->assertEquals( 'MEETING', $event_array['category'] );
+ }
+
+ /**
+ * Test the transformation to ActivityStreams of minimal event.
+ */
+ public function test_transform_of_full_online_event() {
+ // Insert a new Event.
+ $wp_post_id = wp_insert_post(
+ array(
+ 'post_title' => 'WP Event Manager TestEvent',
+ 'post_status' => 'publish',
+ 'post_type' => 'event_listing',
+ 'post_content' => 'Come to my WP Event Manager event!',
+ 'meta_input' => array(
+ '_event_start_date' => \gmdate( 'Y-m-d H:i:s', strtotime( '+10 days 15:00:00' ) ),
+ '_event_end_date' => \gmdate( 'Y-m-d H:i:s', strtotime( '+10 days 16:00:00' ) ),
+ '_event_video_url' => 'https://event-federation.eu/meeting-room',
+ '_event_online' => 'yes',
+ ),
+ )
+ );
+
+ // Transform the event to ActivityStreams.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( get_post( $wp_post_id ) )->to_object()->to_array();
+
+ // Check that the event ActivityStreams representation contains everything as expected.
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'WP Event Manager TestEvent', $event_array['name'] );
+ $this->assertEquals( 'Come to my WP Event Manager event!', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ) . 'T15:00:00Z', $event_array['startTime'] );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ) . 'T16:00:00Z', $event_array['endTime'] );
+ $this->assertEquals( comments_open( $wp_post_id ), $event_array['commentsEnabled'] );
+ $this->assertEquals( comments_open( $wp_post_id ) ? 'allow_all' : 'closed', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertEquals( true, $event_array['isOnline'] );
+ $this->assertEquals( esc_url( get_permalink( $wp_post_id ) ), $event_array['externalParticipationUrl'] );
+ $this->assertArrayNotHasKey( 'location', $event_array );
+ $this->assertEquals( 'MEETING', $event_array['category'] );
+ $this->assertContains(
+ array(
+ 'type' => 'Link',
+ 'name' => __( 'Video URL', 'activitypub-event-bridge' ),
+ 'href' => 'https://event-federation.eu/meeting-room',
+ 'mediaType' => 'text/html',
+ ),
+ $event_array['attachment']
+ );
+ }
+
+ /**
+ * Test the transformation to ActivityStreams of minimal event.
+ */
+ public function test_transform_of_event_with_location() {
+ // Insert a new Event.
+ $wp_post_id = wp_insert_post(
+ array(
+ 'post_title' => 'WP Event Manager TestEvent',
+ 'post_status' => 'publish',
+ 'post_type' => 'event_listing',
+ 'post_content' => 'Come to my WP Event Manager event!',
+ 'meta_input' => array(
+ '_event_start_date' => \gmdate( 'Y-m-d H:i:s', strtotime( '+10 days 15:00:00' ) ),
+ '_event_end_date' => \gmdate( 'Y-m-d H:i:s', strtotime( '+10 days 16:00:00' ) ),
+ '_event_location' => 'Some text location',
+ '_event_online' => 'no',
+ ),
+ )
+ );
+
+ // Transform the event to ActivityStreams.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( get_post( $wp_post_id ) )->to_object()->to_array();
+
+ // Check that the event ActivityStreams representation contains everything as expected.
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'WP Event Manager TestEvent', $event_array['name'] );
+ $this->assertEquals( 'Come to my WP Event Manager event!', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ) . 'T15:00:00Z', $event_array['startTime'] );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ) . 'T16:00:00Z', $event_array['endTime'] );
+ $this->assertEquals( comments_open( $wp_post_id ), $event_array['commentsEnabled'] );
+ $this->assertEquals( comments_open( $wp_post_id ) ? 'allow_all' : 'closed', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertEquals( false, $event_array['isOnline'] );
+ $this->assertEquals( esc_url( get_permalink( $wp_post_id ) ), $event_array['externalParticipationUrl'] );
+ $this->assertArrayHasKey( 'location', $event_array );
+ $this->assertEquals( 'Some text location', $event_array['location']['address'] );
+ }
+}
From 596e32fe2649386339c505298f76bb72ec2ad629 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Fri, 11 Oct 2024 11:53:51 +0200
Subject: [PATCH 02/13] Improve documentation how to add new event plugins/how
to write new transformers. (#59)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/59
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
docs/add_your_event_plugin.md | 113 ++++++++++++++++++++++++++++++++++
1 file changed, 113 insertions(+)
diff --git a/docs/add_your_event_plugin.md b/docs/add_your_event_plugin.md
index 8c387a4..44ec1f4 100644
--- a/docs/add_your_event_plugin.md
+++ b/docs/add_your_event_plugin.md
@@ -28,6 +28,15 @@ First you need to add some basic information about your event plugin. Just creat
final class My_Event_Plugin extends Event_Plugin {
```
+Then you need to tell the ActivityPub Event Bridge about that class by adding it to the `EVENT_PLUGIN_CLASSES` constant in the `includes/setup.php` file:
+
+```php
+ private const EVENT_PLUGIN_CLASSES = array(
+ ...
+ '\ActivityPub_Event_Bridge\Plugins\My_Event_Plugin',
+ );
+```
+
The ActivityPub Event Bridge then takes care of applying the transformer, so you can jump right into implementing it.
## Writing an event transformer class
@@ -107,3 +116,107 @@ In order to ensure your events are compatible with other ActivityPub Event imple
* **`get_tag`**
* **`timezone`**
* **`commentsEnabled`**
+
+## Writing integration tests
+
+Create a new tests class in `tests/test-class-plugin-my-event-plugin.php`.
+
+```
+/**
+ * Sample test case.
+ */
+class Test_My_Event_Plugin extends WP_UnitTestCase {
+```
+
+Implement a check whether your event plugin is active in the `set_up` function. It may be the presence of a class, function or constant.
+
+```php
+ /**
+ * Override the setup function, so that tests don't run if the Events Calendar is not active.
+ */
+ public function set_up() {
+ parent::set_up();
+
+ if ( ! ) {
+ self::markTestSkipped( 'The Events Calendar plugin is not active.' );
+ }
+
+ // Make sure that ActivityPub support is enabled for The Events Calendar.
+ $aec = \ActivityPub_Event_Bridge\Setup::get_instance();
+ $aec->activate_activitypub_support_for_active_event_plugins();
+
+ // Delete all posts afterwards.
+ _delete_all_posts();
+ }
+```
+
+## Running the tests for your plugin/ add the tests to the CI pipeline
+
+### Install the plugin in the CI
+
+The tests are set up by the bash script in `bin/install-wp-tests.sh`. Make sure your WordPress Event plugin is installed within the function `install_wp_plugins`.
+
+### Add a composer script for your plugin
+
+In the pipeline we want to run each event plugins integration tests in a single command, to achieve that, we use phpunit's filters.
+
+```json
+{
+ "scripts": {
+ ...
+ "test": [
+ ...
+ "@test-my-event-plugin"
+ ],
+ ...
+ "@test-my-event-plugin": "phpunit --filter=my_event_plugin",
+ ]
+ }
+}
+```
+
+### Load your plugin during the tests
+
+To activate/load your plugin add it to the switch statement within the function `_manually_load_plugin()` within `tests/bootstrap.php`.
+
+```php
+ switch ( $activitypub_event_extension_integration_filter ) {
+ ...
+ case 'my_event_plugin':
+ $plugin_file = 'my-event-plugin/my-event-plugin.php';
+ break;
+```
+
+If you want to run your tests locally just change the `test-debug` script in the `composer.json` file:
+
+```json
+ "test-debug": [
+ "@prepare-test",
+ "@test-my-event-plugin"
+ ],
+```
+
+Now you just can execute `docker compose up` to run the tests (make sure you have the latest docker and docker-compose installed).
+
+### Debugging the tests
+
+If you are using Visual Studio Code or VSCodium you can step-debug within the tests by adding this configuration to your `.vscode/launch.json`:
+
+```json
+{
+ "version": "0.2.0",
+ "configurations": [
+ ...,
+ {
+ "name": "Listen for PHPUnit",
+ "type": "php",
+ "request": "launch",
+ "port": 9003,
+ "pathMappings": {
+ "/app/": "${workspaceRoot}/wp-content/plugins/activitypub-event-bridge/",
+ "/tmp/wordpress/": "${workspaceRoot}/"
+ },
+ }
+ ]
+}
+```
From 8fb92bfc10221afc5b0e0f38bf06ffd03f9fafaa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sun, 13 Oct 2024 09:44:00 +0200
Subject: [PATCH 03/13] Add Transformer for Eventin (WP Event Solution) (#62)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
https://wordpress.org/plugins/wp-event-solution/
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/62
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
.forgejo/workflows/phpunit.yml | 7 +-
bin/install-wp-tests.sh | 1 +
composer.json | 10 +-
.../activitypub/transformer/class-eventin.php | 164 ++++++++++++++++
includes/class-setup.php | 1 +
includes/plugins/class-eventin.php | 60 ++++++
tests/bootstrap.php | 8 +
tests/test-class-plugin-eventin.php | 175 ++++++++++++++++++
8 files changed, 422 insertions(+), 4 deletions(-)
create mode 100644 includes/activitypub/transformer/class-eventin.php
create mode 100644 includes/plugins/class-eventin.php
create mode 100644 tests/test-class-plugin-eventin.php
diff --git a/.forgejo/workflows/phpunit.yml b/.forgejo/workflows/phpunit.yml
index b67d8e9..3cb7904 100644
--- a/.forgejo/workflows/phpunit.yml
+++ b/.forgejo/workflows/phpunit.yml
@@ -37,7 +37,7 @@ jobs:
path: |
${{ env.WP_CORE_DIR }}
${{ env.WP_TESTS_DIR }}
- key: cache-wordpress-5
+ key: cache-wordpress-8
- name: Cache Composer
id: cache-composer-phpunit
@@ -96,5 +96,10 @@ jobs:
- name: Run Integration tests for WP Event Manager
run: cd /workspace/Event-Federation/wordpress-activitypub-event-bridge/ && ./vendor/bin/phpunit --filter=wp_event_manager
+ env:
+ PHP_VERSION: ${{ matrix.php-version }}
+
+ - name: Run Integration tests for Eventin (WP Event Solution)
+ run: cd /workspace/Event-Federation/wordpress-activitypub-event-bridge/ && ./vendor/bin/phpunit --filter=eventin
env:
PHP_VERSION: ${{ matrix.php-version }}
\ No newline at end of file
diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh
index 0e0e4ad..759ad25 100755
--- a/bin/install-wp-tests.sh
+++ b/bin/install-wp-tests.sh
@@ -239,6 +239,7 @@ install_wp_plugins() {
install_wp_plugin gatherpress
install_wp_plugin events-manager
install_wp_plugin wp-event-manager
+ install_wp_plugin wp-event-solution
}
install_wp
diff --git a/composer.json b/composer.json
index 1b3bb36..940dff0 100644
--- a/composer.json
+++ b/composer.json
@@ -1,5 +1,6 @@
{
"name": "menrath/wordpress-activitypub-event-bridge",
+ "version": "1.0.0",
"description": "The ActivityPub Event Bridge help for event custom post types to federate properly.",
"type": "wordpress-plugin",
"require": {
@@ -51,16 +52,19 @@
"@test-the-events-calendar",
"@test-gatherpress",
"@test-events-manager",
- "@test-wp-event-manager"
+ "@test-wp-event-manager",
+ "@test-eventin"
],
"test-debug": [
"@prepare-test",
- "@test-wp-event-manager"
+ "@test-eventin"
],
"test-vs-event-list": "phpunit --filter=vs_event_list",
"test-the-events-calendar": "phpunit --filter=the_events_calendar",
"test-gatherpress": "phpunit --filter=gatherpress",
"test-events-manager": "phpunit --filter=events_manager",
- "test-wp-event-manager": "phpunit --filter=wp_event_manager"
+ "test-wp-event-manager": "phpunit --filter=wp_event_manager",
+ "test-eventin": "phpunit --filter=eventin",
+ "test-all": "phpunit"
}
}
diff --git a/includes/activitypub/transformer/class-eventin.php b/includes/activitypub/transformer/class-eventin.php
new file mode 100644
index 0000000..b47d93e
--- /dev/null
+++ b/includes/activitypub/transformer/class-eventin.php
@@ -0,0 +1,164 @@
+event_model = new Event_Model( $this->wp_object->ID );
+ }
+
+ /**
+ * Get the end time from the event object.
+ */
+ public function get_start_time(): string {
+ return \gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $this->event_model->get_start_datetime() ) );
+ }
+
+ /**
+ * Get the end time from the event object.
+ */
+ public function get_end_time(): string {
+ return \gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $this->event_model->get_end_datetime() ) );
+ }
+
+ /**
+ * Get the timezone of the event.
+ */
+ public function get_timezone(): string {
+ return $this->event_model->get_timezone();
+ }
+
+ /**
+ * Get whether the event is online.
+ *
+ * @return bool
+ */
+ public function get_is_online(): bool {
+ return 'online' === $this->event_model->__get( 'event_type' ) ? true : false;
+ }
+
+ /**
+ * Maybe add online link to attachments.
+ *
+ * @return array
+ */
+ public function get_attachment(): array {
+ $attachment = parent::get_attachment();
+
+ $location = (array) $this->event_model->__get( 'location' );
+ if ( array_key_exists( 'integration', $location ) && array_key_exists( $location['integration'], $location ) ) {
+ $online_link = array(
+ 'type' => 'Link',
+ 'mediaType' => 'text/html',
+ 'name' => $location[ $location['integration'] ],
+ 'href' => $location[ $location['integration'] ],
+ );
+ $attachment[] = $online_link;
+ }
+ return $attachment;
+ }
+
+ /**
+ * Compose the events tags.
+ */
+ public function get_tag() {
+ // The parent tag function also fetches the mentions.
+ $tags = parent::get_tag();
+
+ $post_tags = \wp_get_post_terms( $this->wp_object->ID, 'etn_tags' );
+ $post_categories = \wp_get_post_terms( $this->wp_object->ID, 'etn_category' );
+
+ if ( ! is_wp_error( $post_tags ) && $post_tags ) {
+ foreach ( $post_tags as $term ) {
+ $tag = array(
+ 'type' => 'Hashtag',
+ 'href' => \esc_url( \get_tag_link( $term->term_id ) ),
+ 'name' => esc_hashtag( $term->name ),
+ );
+ $tags[] = $tag;
+ }
+ }
+
+ if ( ! is_wp_error( $post_categories ) && $post_categories ) {
+ foreach ( $post_categories as $term ) {
+ $tag = array(
+ 'type' => 'Hashtag',
+ 'href' => \esc_url( \get_tag_link( $term->term_id ) ),
+ 'name' => esc_hashtag( $term->name ),
+ );
+ $tags[] = $tag;
+ }
+ }
+
+ if ( empty( $tags ) ) {
+ return null;
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Get the location.
+ *
+ * @return ?Place
+ */
+ public function get_location(): ?Place {
+ $location = (array) $this->event_model->__get( 'location' );
+
+ if ( ! array_key_exists( 'address', $location ) ) {
+ return null;
+ }
+
+ $place = new Place();
+
+ $address = $location['address'];
+
+ $place->set_name( $address );
+ $place->set_address( $address );
+ $place->set_sensitive( null );
+
+ return $place;
+ }
+}
diff --git a/includes/class-setup.php b/includes/class-setup.php
index bafe050..e03c7d0 100644
--- a/includes/class-setup.php
+++ b/includes/class-setup.php
@@ -129,6 +129,7 @@ class Setup {
'\ActivityPub_Event_Bridge\Plugins\The_Events_Calendar',
'\ActivityPub_Event_Bridge\Plugins\VS_Event_List',
'\ActivityPub_Event_Bridge\Plugins\WP_Event_Manager',
+ '\ActivityPub_Event_Bridge\Plugins\Eventin',
);
/**
diff --git a/includes/plugins/class-eventin.php b/includes/plugins/class-eventin.php
new file mode 100644
index 0000000..fcab7c8
--- /dev/null
+++ b/includes/plugins/class-eventin.php
@@ -0,0 +1,60 @@
+ 'publish',
+ 'post_title' => 'Eventin Test Event Title',
+ 'post_content' => 'Eventin Test Event Description',
+ 'etn_start_date' => \gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ),
+ 'etn_end_date' => \gmdate( 'Y-m-d', strtotime( '+10 days 16:00:00' ) ),
+ 'etn_start_time' => \gmdate( 'H:i', strtotime( '+10 days 15:00:00' ) ),
+ 'etn_end_time' => \gmdate( 'H:i', strtotime( '+10 days 16:00:00' ) ),
+ 'event_timezone' => 'Europe/Vienna',
+ );
+ }
+
+ /**
+ * Override the setup function, so that tests don't run if the Events Calendar is not active.
+ */
+ public function set_up() {
+ parent::set_up();
+
+ if ( ! class_exists( '\Wpeventin' ) ) {
+ self::markTestSkipped( 'Eventin plugin is not active.' );
+ }
+
+ // Make sure that ActivityPub support is enabled for The Events Calendar.
+ $aec = \ActivityPub_Event_Bridge\Setup::get_instance();
+ $aec->activate_activitypub_support_for_active_event_plugins();
+
+ // Delete all posts afterwards.
+ _delete_all_posts();
+ }
+
+ /**
+ * Test that the right transformer gets applied.
+ */
+ public function test_eventin_transformer_class() {
+ // We only test for one event plugin being active at the same time,
+ // even though we support multiple onces in theory.
+ // But testing all combinations is beyond scope.
+ $active_event_plugins = \ActivityPub_Event_Bridge\Setup::get_instance()->get_active_event_plugins();
+ $this->assertEquals( 1, count( $active_event_plugins ) );
+
+ // Enable ActivityPub support for the event plugin.
+ $this->assertContains( 'etn', get_option( 'activitypub_support_post_types' ) );
+
+ // Create a Eventin Event without content.
+ $event = new \Etn\Core\Event\Event_Model();
+ $event->create( $this->get_mockup_event() );
+
+ // Call the transformer Factory.
+ $transformer = \Activitypub\Transformer\Factory::get_transformer( get_post( $event->id ) );
+
+ // Check that we got the right transformer.
+ $this->assertInstanceOf( \ActivityPub_Event_Bridge\Activitypub\Transformer\Eventin::class, $transformer );
+ }
+
+ /**
+ * Test that the right transformer gets applied.
+ */
+ public function test_eventin_test_minimal_event() {
+ // Create a Eventin Event without content.
+ $event = new \Etn\Core\Event\Event_Model();
+ $event->create( $this->get_mockup_event() );
+
+ // Call the transformer Factory.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( get_post( $event->id ) )->to_object()->to_array();
+
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'Eventin Test Event Title', $event_array['name'] );
+ $this->assertEquals( 'Eventin Test Event Description', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '+10 days 15:00:00' ) ), $event_array['startTime'] );
+ $this->assertEquals( gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '+10 days 16:00:00' ) ), $event_array['endTime'] );
+ $this->assertEquals( 'Europe/Vienna', $event_array['timezone'] );
+ $this->assertEquals( comments_open( $event->id ), $event_array['commentsEnabled'] );
+ $this->assertEquals( comments_open( $event->id ) ? 'allow_all' : 'closed', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertArrayNotHasKey( 'location', $event_array );
+ $this->assertEquals( 'MEETING', $event_array['category'] );
+ $this->assertEquals( false, $event_array['isOnline'] );
+ }
+
+ /**
+ * Test that the right transformer gets applied.
+ */
+ public function test_eventin_test_online_event_with_custom_link() {
+ // Create a Eventin Event without content.
+ $event = new \Etn\Core\Event\Event_Model();
+ $args = array_merge(
+ $this->get_mockup_event(),
+ array(
+ 'event_type' => 'online',
+ 'location' => array(
+ 'integration' => 'custom_url',
+ 'custom_url' => 'https://jit.si/eventmeeting',
+ ),
+ )
+ );
+ $event->create( $args );
+
+ // Call the transformer Factory.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( get_post( $event->id ) )->to_object()->to_array();
+
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'Eventin Test Event Title', $event_array['name'] );
+ $this->assertEquals( 'Eventin Test Event Description', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '+10 days 15:00:00' ) ), $event_array['startTime'] );
+ $this->assertEquals( gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '+10 days 16:00:00' ) ), $event_array['endTime'] );
+ $this->assertEquals( 'Europe/Vienna', $event_array['timezone'] );
+ $this->assertEquals( comments_open( $event->id ), $event_array['commentsEnabled'] );
+ $this->assertEquals( comments_open( $event->id ) ? 'allow_all' : 'closed', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertArrayNotHasKey( 'location', $event_array );
+ $this->assertEquals( 'MEETING', $event_array['category'] );
+ $this->assertEquals( true, $event_array['isOnline'] );
+ $this->assertContains(
+ array(
+ 'type' => 'Link',
+ 'mediaType' => 'text/html',
+ 'name' => 'https://jit.si/eventmeeting',
+ 'href' => 'https://jit.si/eventmeeting',
+ ),
+ $event_array['attachment']
+ );
+ }
+
+
+ /**
+ * Test that the right transformer gets applied.
+ */
+ public function test_eventin_test_online_event_with_physical_location() {
+ // Create a Eventin Event without content.
+ $event = new \Etn\Core\Event\Event_Model();
+ $args = array_merge(
+ $this->get_mockup_event(),
+ array(
+ 'event_type' => 'offline',
+ 'location' => array(
+ 'address' => 'The NlNet center',
+ ),
+ )
+ );
+ $event->create( $args );
+
+ // Call the transformer Factory.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( get_post( $event->id ) )->to_object()->to_array();
+
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'Eventin Test Event Title', $event_array['name'] );
+ $this->assertEquals( 'Eventin Test Event Description', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '+10 days 15:00:00' ) ), $event_array['startTime'] );
+ $this->assertEquals( gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '+10 days 16:00:00' ) ), $event_array['endTime'] );
+ $this->assertEquals( 'Europe/Vienna', $event_array['timezone'] );
+ $this->assertEquals( comments_open( $event->id ), $event_array['commentsEnabled'] );
+ $this->assertEquals( comments_open( $event->id ) ? 'allow_all' : 'closed', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertArrayHasKey( 'location', $event_array );
+ $this->assertEquals( 'MEETING', $event_array['category'] );
+ $this->assertEquals( false, $event_array['isOnline'] );
+ $this->assertEquals( 'The NlNet center', $event_array['location']['address'] );
+ $this->assertEquals( 'The NlNet center', $event_array['location']['name'] );
+ }
+}
From cc04a1e7dd794067d998936fab7979719800c731 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sun, 13 Oct 2024 16:03:10 +0200
Subject: [PATCH 04/13] Improve Readme and Documentation (#65)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/65
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
README.md | 12 ++++++++++++
readme.txt | 12 ++++++++++++
2 files changed, 24 insertions(+)
diff --git a/README.md b/README.md
index 3462494..416ebe7 100644
--- a/README.md
+++ b/README.md
@@ -42,6 +42,16 @@ These platforms create public event calendars by pulling in events from various
Even platforms that don’t yet fully support events, like [Mastodon](https://joinmastodon.org), will still receive a detailed, well-composed summary of your event.
The Event Federation plugin ensures that users from those platforms are provided with all important information about an event.
+### Features for Your WordPress Events and the Fediverse
+
+**ActivityPub-Enabled Event Sharing:** Your WordPress events are now compatible with the Fediverse, using the ActivityStreams format. This means your events can be easily discovered and followed by users on platforms like Mastodon and other ActivityPub-compatible services.
+
+**Automatic Event Summaries:** When your event is shared on the Fediverse, platforms like Mastodon that don't fully support events will display a brief HTML summary of key details — such as the event's title, start time, and location. This ensures that even if someone can't view the full event on their platform, they still get the important info at a glance, with a link to your WordPress event page.
+
+**Improved Event Discoverability:** Your custom event categories are mapped to a set of default categories used in the Fediverse, helping your events reach a wider audience. This improves the chances that users searching for similar events on other platforms will find yours.
+
+**Event Reminders for Your Followers:** Often, events are planned well in advance. To keep your followers informed right in time, you can set up reminders that are supposed to trigger the events showing up in their timelines right before the event starts. At the moment this reminder is implemented as a self-boost of your original event post. While this feature may behave differently across various platforms, we are working on a more robust solution that will let you schedule dedicated reminder notes that appear in all followers’ timelines.
+
## Installation
This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/activitypub/). Additionally, you need to use one of the supported event plugins. See below.
@@ -51,6 +61,8 @@ This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/ac
* [The Events Calendar](https://de.wordpress.org/plugins/the-events-calendar/)
* [VS Event List](https://de.wordpress.org/plugins/very-simple-event-list/)
* [Events Manager](https://de.wordpress.org/plugins/events-manager/)
+* [WP Event Manager](https://de.wordpress.org/plugins/wp-event-manager/)
+* [Eventin](https://de.wordpress.org/plugins/wp-event-solution/)
## Configuration
diff --git a/readme.txt b/readme.txt
index 526cd13..d1c7b0c 100644
--- a/readme.txt
+++ b/readme.txt
@@ -32,6 +32,16 @@ These platforms create public event calendars by pulling in events from various
![](./.wordpress-org/decentralized-event-calenders.gif)
+= Features for Your WordPress Events and the Fediverse =
+
+**ActivityPub-Enabled Event Sharing:** Your WordPress events are now compatible with the Fediverse, using the ActivityStreams format. This means your events can be easily discovered and followed by users on platforms like Mastodon and other ActivityPub-compatible services.
+
+**Automatic Event Summaries:** When your event is shared on the Fediverse, platforms like Mastodon that don't fully support events will display a brief HTML summary of key details — such as the event's title, start time, and location. This ensures that even if someone can't view the full event on their platform, they still get the important info at a glance, with a link to your WordPress event page.
+
+**Improved Event Discoverability:** Your custom event categories are mapped to a set of default categories used in the Fediverse, helping your events reach a wider audience. This improves the chances that users searching for similar events on other platforms will find yours.
+
+**Event Reminders for Your Followers:** Often, events are planned well in advance. To keep your followers informed right in time, you can set up reminders that are supposed to trigger the events showing up in their timelines right before the event starts. At the moment this reminder is implemented as a self-boost of your original event post. While this feature may behave differently across various platforms, we are working on a more robust solution that will let you schedule dedicated reminder notes that appear in all followers’ timelines.
+
== Installation ==
This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/activitypub/). Additionally, you need to use one of the supported event Plugins.
@@ -41,6 +51,8 @@ This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/ac
* [The Events Calendar](https://de.wordpress.org/plugins/the-events-calendar/)
* [VS Event List](https://de.wordpress.org/plugins/very-simple-event-list/)
* [Events Manager](https://de.wordpress.org/plugins/events-manager/)
+* [WP Event Manager](https://de.wordpress.org/plugins/wp-event-manager/)
+* [Eventin](https://de.wordpress.org/plugins/wp-event-solution/)
== Configuration ==
From 29536e7a4d1b41726818ced818c747f637c03b24 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sun, 13 Oct 2024 16:09:55 +0200
Subject: [PATCH 05/13] remove non-unicode characters in readme
---
README.md | 4 ++--
readme.txt | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 416ebe7..a23638c 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ These platforms create public event calendars by pulling in events from various
-Even platforms that don’t yet fully support events, like [Mastodon](https://joinmastodon.org), will still receive a detailed, well-composed summary of your event.
+Even platforms that don't yet fully support events, like [Mastodon](https://joinmastodon.org), will still receive a detailed, well-composed summary of your event.
The Event Federation plugin ensures that users from those platforms are provided with all important information about an event.
### Features for Your WordPress Events and the Fediverse
@@ -50,7 +50,7 @@ The Event Federation plugin ensures that users from those platforms are provided
**Improved Event Discoverability:** Your custom event categories are mapped to a set of default categories used in the Fediverse, helping your events reach a wider audience. This improves the chances that users searching for similar events on other platforms will find yours.
-**Event Reminders for Your Followers:** Often, events are planned well in advance. To keep your followers informed right in time, you can set up reminders that are supposed to trigger the events showing up in their timelines right before the event starts. At the moment this reminder is implemented as a self-boost of your original event post. While this feature may behave differently across various platforms, we are working on a more robust solution that will let you schedule dedicated reminder notes that appear in all followers’ timelines.
+**Event Reminders for Your Followers:** Often, events are planned well in advance. To keep your followers informed right in time, you can set up reminders that are supposed to trigger the events showing up in their timelines right before the event starts. At the moment this reminder is implemented as a self-boost of your original event post. While this feature may behave differently across various platforms, we are working on a more robust solution that will let you schedule dedicated reminder notes that appear in all followers' timelines.
## Installation
diff --git a/readme.txt b/readme.txt
index d1c7b0c..59e8103 100644
--- a/readme.txt
+++ b/readme.txt
@@ -40,7 +40,7 @@ These platforms create public event calendars by pulling in events from various
**Improved Event Discoverability:** Your custom event categories are mapped to a set of default categories used in the Fediverse, helping your events reach a wider audience. This improves the chances that users searching for similar events on other platforms will find yours.
-**Event Reminders for Your Followers:** Often, events are planned well in advance. To keep your followers informed right in time, you can set up reminders that are supposed to trigger the events showing up in their timelines right before the event starts. At the moment this reminder is implemented as a self-boost of your original event post. While this feature may behave differently across various platforms, we are working on a more robust solution that will let you schedule dedicated reminder notes that appear in all followers’ timelines.
+**Event Reminders for Your Followers:** Often, events are planned well in advance. To keep your followers informed right in time, you can set up reminders that are supposed to trigger the events showing up in their timelines right before the event starts. At the moment this reminder is implemented as a self-boost of your original event post. While this feature may behave differently across various platforms, we are working on a more robust solution that will let you schedule dedicated reminder notes that appear in all followers' timelines.
== Installation ==
From 7e8346cf7be9227c08eabc1416c6f7b4203410ac Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Fri, 18 Oct 2024 13:54:53 +0200
Subject: [PATCH 06/13] Add Transformer and Integration tests for Modern Events
Calendar Lite (#66)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
https://webnus.net/modern-events-calendar/
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/66
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
.forgejo/workflows/phpunit.yml | 7 +-
README.md | 4 +-
bin/install-wp-tests.sh | 16 ++
composer.json | 6 +-
improvements.md | 16 ++
.../class-modern-events-calendar-lite.php | 114 +++++++++++
includes/class-setup.php | 1 +
.../class-modern-events-calendar-lite.php | 61 ++++++
readme.txt | 1 +
tests/bootstrap.php | 9 +
...ass-plugin-modern-events-calendar-lite.php | 185 ++++++++++++++++++
11 files changed, 416 insertions(+), 4 deletions(-)
create mode 100644 improvements.md
create mode 100644 includes/activitypub/transformer/class-modern-events-calendar-lite.php
create mode 100644 includes/plugins/class-modern-events-calendar-lite.php
create mode 100644 tests/test-class-plugin-modern-events-calendar-lite.php
diff --git a/.forgejo/workflows/phpunit.yml b/.forgejo/workflows/phpunit.yml
index 3cb7904..a893dce 100644
--- a/.forgejo/workflows/phpunit.yml
+++ b/.forgejo/workflows/phpunit.yml
@@ -37,7 +37,7 @@ jobs:
path: |
${{ env.WP_CORE_DIR }}
${{ env.WP_TESTS_DIR }}
- key: cache-wordpress-8
+ key: cache-wordpress-9
- name: Cache Composer
id: cache-composer-phpunit
@@ -101,5 +101,10 @@ jobs:
- name: Run Integration tests for Eventin (WP Event Solution)
run: cd /workspace/Event-Federation/wordpress-activitypub-event-bridge/ && ./vendor/bin/phpunit --filter=eventin
+ env:
+ PHP_VERSION: ${{ matrix.php-version }}
+
+ - name: Run Integration tests for Modern Events Calendar Lite
+ run: cd /workspace/Event-Federation/wordpress-activitypub-event-bridge/ && ./vendor/bin/phpunit --filter=modern_events_calendar_lite
env:
PHP_VERSION: ${{ matrix.php-version }}
\ No newline at end of file
diff --git a/README.md b/README.md
index a23638c..892dc58 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@ The Event Federation plugin ensures that users from those platforms are provided
### Features for Your WordPress Events and the Fediverse
-**ActivityPub-Enabled Event Sharing:** Your WordPress events are now compatible with the Fediverse, using the ActivityStreams format. This means your events can be easily discovered and followed by users on platforms like Mastodon and other ActivityPub-compatible services.
+**ActivityPub-Enabled Event Sharing:** Your WordPress events are not part of the Fediverse and
**Automatic Event Summaries:** When your event is shared on the Fediverse, platforms like Mastodon that don't fully support events will display a brief HTML summary of key details — such as the event's title, start time, and location. This ensures that even if someone can't view the full event on their platform, they still get the important info at a glance, with a link to your WordPress event page.
@@ -63,6 +63,8 @@ This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/ac
* [Events Manager](https://de.wordpress.org/plugins/events-manager/)
* [WP Event Manager](https://de.wordpress.org/plugins/wp-event-manager/)
* [Eventin](https://de.wordpress.org/plugins/wp-event-solution/)
+* [Modern Events Calendar Lite](https://webnus.net/modern-events-calendar/)
+
## Configuration
diff --git a/bin/install-wp-tests.sh b/bin/install-wp-tests.sh
index 759ad25..f0bf333 100755
--- a/bin/install-wp-tests.sh
+++ b/bin/install-wp-tests.sh
@@ -226,6 +226,20 @@ install_wp_plugin() {
unzip -q -o "$TMPDIR/$PLUGIN_FILE" -d "$WP_CORE_DIR/wp-content/plugins/"
}
+install_wp_plugin_mec() {
+ mkdir -p "$WP_CORE_DIR/wp-content/plugins/"
+
+ if [ -d "$WP_CORE_DIR/wp-content/plugins/modern-events-calendar-lite" ]; then
+ return;
+ fi
+
+ PLUGIN_VERSION="v7.15.0"
+
+ URL="https://code.event-federation.eu/Event-Federation/modern-events-calendar-lite"
+
+ git clone $URL "$WP_CORE_DIR/wp-content/plugins/modern-events-calendar-lite"
+}
+
install_wp_plugins() {
if [ "$SKIP_PLUGINS_INSTALL" = "true" ]; then
echo "Skipping WordPress plugin installation."
@@ -240,6 +254,8 @@ install_wp_plugins() {
install_wp_plugin events-manager
install_wp_plugin wp-event-manager
install_wp_plugin wp-event-solution
+ # Mec is not installable via wordpress.org, we use our own mirror.
+ install_wp_plugin_mec
}
install_wp
diff --git a/composer.json b/composer.json
index 940dff0..eaf1ea5 100644
--- a/composer.json
+++ b/composer.json
@@ -53,11 +53,12 @@
"@test-gatherpress",
"@test-events-manager",
"@test-wp-event-manager",
- "@test-eventin"
+ "@test-eventin",
+ "@test-modern-events-calendar-lite"
],
"test-debug": [
"@prepare-test",
- "@test-eventin"
+ "@test-modern-events-calendar-lite"
],
"test-vs-event-list": "phpunit --filter=vs_event_list",
"test-the-events-calendar": "phpunit --filter=the_events_calendar",
@@ -65,6 +66,7 @@
"test-events-manager": "phpunit --filter=events_manager",
"test-wp-event-manager": "phpunit --filter=wp_event_manager",
"test-eventin": "phpunit --filter=eventin",
+ "test-modern-events-calendar-lite": "phpunit --filter=modern_events_calendar_lite",
"test-all": "phpunit"
}
}
diff --git a/improvements.md b/improvements.md
new file mode 100644
index 0000000..c095490
--- /dev/null
+++ b/improvements.md
@@ -0,0 +1,16 @@
+Make use of:
+
+https://docs.theeventscalendar.com/reference/classes/tribe__events__api/get_event_terms/
+
+for getting all tags/terms for an event!
+
+```
+public static function get_event_terms( $event_id, array $args = array() ) {
+ $terms = array();
+ foreach ( get_post_taxonomies( $event_id ) as $taxonomy ) {
+ $tax_terms = wp_get_object_terms( $event_id, $taxonomy, $args );
+ $terms[ $taxonomy ] = $tax_terms;
+ }
+ return $terms;
+}
+```
diff --git a/includes/activitypub/transformer/class-modern-events-calendar-lite.php b/includes/activitypub/transformer/class-modern-events-calendar-lite.php
new file mode 100644
index 0000000..0877abf
--- /dev/null
+++ b/includes/activitypub/transformer/class-modern-events-calendar-lite.php
@@ -0,0 +1,114 @@
+mec_main = MEC::getInstance( 'app.libraries.main' );
+ $this->mec_event = new MEC_Event( $wp_object );
+ }
+
+ /**
+ * Get the end time from the event object.
+ */
+ public function get_start_time(): string {
+ return \gmdate( 'Y-m-d\TH:i:s\Z', $this->mec_event->get_datetime()['start']['timestamp'] );
+ }
+
+ /**
+ * Get the end time from the event object.
+ */
+ public function get_end_time(): ?string {
+ return \gmdate( 'Y-m-d\TH:i:s\Z', $this->mec_event->get_datetime()['end']['timestamp'] );
+ }
+
+ /**
+ * Get the location.
+ */
+ public function get_location(): ?Place {
+ $location_id = $this->mec_main->get_master_location_id( $this->mec_event->ID );
+
+ if ( ! $location_id ) {
+ return null;
+ }
+
+ $data = $this->mec_main->get_location_data( $location_id );
+
+ $location = new Place();
+ $location->set_sensitive( null );
+
+ if ( ! empty( $data['address'] ) ) {
+ $location->set_address( $data['address'] );
+ }
+ if ( ! empty( $data['name'] ) ) {
+ $location->set_name( $data['name'] );
+ }
+ if ( ! empty( $data['longitude'] ) ) {
+ $location->set_longitude( $data['longitude'] );
+ }
+ if ( ! empty( $data['latitude'] ) ) {
+ $location->set_latitude( $data['latitude'] );
+ }
+
+ return $location;
+ }
+
+ /**
+ * Get the location.
+ */
+ public function get_timezone(): string {
+ $timezone = get_post_meta( $this->wp_object->ID, 'mec_timezone', true );
+
+ if ( 'global' === $timezone ) {
+ return parent::get_timezone();
+ }
+
+ return $timezone;
+ }
+}
diff --git a/includes/class-setup.php b/includes/class-setup.php
index e03c7d0..a03c988 100644
--- a/includes/class-setup.php
+++ b/includes/class-setup.php
@@ -130,6 +130,7 @@ class Setup {
'\ActivityPub_Event_Bridge\Plugins\VS_Event_List',
'\ActivityPub_Event_Bridge\Plugins\WP_Event_Manager',
'\ActivityPub_Event_Bridge\Plugins\Eventin',
+ '\ActivityPub_Event_Bridge\Plugins\Modern_Events_Calendar_Lite',
);
/**
diff --git a/includes/plugins/class-modern-events-calendar-lite.php b/includes/plugins/class-modern-events-calendar-lite.php
new file mode 100644
index 0000000..f8f5552
--- /dev/null
+++ b/includes/plugins/class-modern-events-calendar-lite.php
@@ -0,0 +1,61 @@
+get_main_post_type().
+ return apply_filters( 'mec_post_type_name', 'mec-events' ); // phpcs:ignore
+ }
+
+ /**
+ * Returns the ID of the main settings page of the plugin.
+ *
+ * @return string The settings page url.
+ */
+ public static function get_settings_page(): string {
+ return 'mec-event';
+ }
+
+ /**
+ * Returns the taxonomy used for the plugin's event categories.
+ *
+ * @return string
+ */
+ public static function get_event_category_taxonomy(): string {
+ return 'mec_category';
+ }
+}
diff --git a/readme.txt b/readme.txt
index 59e8103..149fe68 100644
--- a/readme.txt
+++ b/readme.txt
@@ -53,6 +53,7 @@ This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/ac
* [Events Manager](https://de.wordpress.org/plugins/events-manager/)
* [WP Event Manager](https://de.wordpress.org/plugins/wp-event-manager/)
* [Eventin](https://de.wordpress.org/plugins/wp-event-solution/)
+* [Modern Events Calendar Lite](https://webnus.net/modern-events-calendar/)
== Configuration ==
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
index 4bbd83a..df5b41e 100755
--- a/tests/bootstrap.php
+++ b/tests/bootstrap.php
@@ -62,6 +62,9 @@ function _manually_load_plugin() {
case 'eventin':
$plugin_file = 'wp-event-solution/eventin.php';
break;
+ case 'modern_events_calendar_lite':
+ $plugin_file = 'modern-events-calendar-lite/modern-events-calendar-lite.php';
+ break;
case 'gatherpress':
$plugin_file = 'gatherpress/gatherpress.php';
break;
@@ -88,6 +91,12 @@ function _manually_load_plugin() {
em_create_locations_table();
}
+ if ( 'modern_events_calendar_lite' === $activitypub_event_extension_integration_filter ) {
+ require_once $plugin_dir . 'modern-events-calendar-lite/app/libraries/factory.php';
+ $mec_factory = new MEC_factory();
+ $mec_factory->install();
+ }
+
// At last manually load our WordPress plugin.
require dirname( __DIR__ ) . '/activitypub-event-bridge.php';
}
diff --git a/tests/test-class-plugin-modern-events-calendar-lite.php b/tests/test-class-plugin-modern-events-calendar-lite.php
new file mode 100644
index 0000000..55114d9
--- /dev/null
+++ b/tests/test-class-plugin-modern-events-calendar-lite.php
@@ -0,0 +1,185 @@
+activate_activitypub_support_for_active_event_plugins();
+
+ $this->mec_main = \MEC::getInstance( 'app.libraries.main' );
+
+ // Delete all posts afterwards.
+ _delete_all_posts();
+ }
+
+ /**
+ * Test that the right transformer gets applied.
+ */
+ public function test_modern_events_calendar_lite_transformer_class() {
+ // We only test for one event plugin being active at the same time,
+ // even though we support multiple onces in theory.
+ // But testing all combinations is beyond scope.
+ $active_event_plugins = \ActivityPub_Event_Bridge\Setup::get_instance()->get_active_event_plugins();
+ $this->assertEquals( 1, count( $active_event_plugins ) );
+
+ // Enable ActivityPub support for the event plugin.
+ $this->assertContains( 'mec-events', get_option( 'activitypub_support_post_types' ) );
+
+ // Insert a new Event.
+ $event = array(
+ 'title' => 'MEC Test Event',
+ 'status' => 'publish',
+ 'start_time_hour' => '3',
+ 'start_time_minutes' => '00',
+ 'start_time_ampm' => 'PM',
+ 'start' => '2025-01-01',
+ 'end' => '2025-01-01',
+ 'end_time_hour' => '4',
+ 'end_time_minutes' => '00',
+ 'end_time_ampm' => 'PM',
+ 'repeat_status' => 0,
+ 'repeat_type' => 'daily',
+ 'interval' => 1,
+ );
+
+ $post_id = $this->mec_main->save_event( $event );
+
+ $wp_object = get_post( $post_id );
+
+ // Call the transformer Factory.
+ $transformer = \Activitypub\Transformer\Factory::get_transformer( $wp_object );
+
+ // Check that we got the right transformer.
+ $this->assertInstanceOf( \ActivityPub_Event_Bridge\Activitypub\Transformer\Modern_Events_Calendar_Lite::class, $transformer );
+ }
+
+ /**
+ * Test that the transformation of minimal event.
+ */
+ public function test_modern_events_calendar_lite_minimal_event() {
+ $start_timestamp = strtotime( '+10 days 15:00:00' );
+ $end_timestamp = strtotime( '+10 days 16:00:00' );
+
+ // Insert a new Event.
+ $event = array(
+ 'title' => 'MEC Test Event',
+ 'status' => 'publish',
+ 'content' => 'This is the content of the MEC!',
+ 'start_time_hour' => gmdate( 'h', $start_timestamp ),
+ 'start_time_minutes' => gmdate( 'i', $start_timestamp ),
+ 'start_time_ampm' => gmdate( 'A', $start_timestamp ),
+ 'start' => gmdate( 'Y-m-d', $start_timestamp ),
+ 'end' => gmdate( 'Y-m-d', $end_timestamp ),
+ 'end_time_hour' => gmdate( 'h', $end_timestamp ),
+ 'end_time_minutes' => gmdate( 'i', $end_timestamp ),
+ 'end_time_ampm' => gmdate( 'A', $end_timestamp ),
+ 'repeat_status' => 0,
+ 'repeat_type' => 'daily',
+ 'interval' => 1,
+ );
+
+ $post_id = $this->mec_main->save_event( $event );
+
+ $wp_object = get_post( $post_id );
+
+ // Call the transformer to make the ActivityStreams representation of the event.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( $wp_object )->to_object()->to_array();
+
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'MEC Test Event', $event_array['name'] );
+ $this->assertEquals( 'This is the content of the MEC!', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ) . 'T15:00:00Z', $event_array['startTime'] );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 16:00:00' ) ) . 'T16:00:00Z', $event_array['endTime'] );
+ $this->assertTrue( $event_array['commentsEnabled'] );
+ $this->assertEquals( 'allow_all', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertEquals( get_permalink( $wp_object ), $event_array['externalParticipationUrl'] );
+ $this->assertArrayNotHasKey( 'location', $event_array );
+ $this->assertEquals( 'MEETING', $event_array['category'] );
+ }
+
+ /**
+ * Test that the transformation of minimal event.
+ */
+ public function test_modern_events_calendar_lite_event_with_location() {
+ $start_timestamp = strtotime( '+10 days 15:00:00' );
+ $end_timestamp = strtotime( '+10 days 16:00:00' );
+
+ // Add new location.
+ $location = array(
+ 'name' => 'MEC Location',
+ 'latitude' => '52.356370',
+ 'longitude' => '4.955760',
+ 'address' => 'Stichting NLnet, Science Park 400, 1098 XH Amsterdam',
+ 'url' => 'https://nlnet.nl/',
+ );
+
+ $location_id = $this->mec_main->save_location( $location );
+
+ // Insert a new Event.
+ $event = array(
+ 'title' => 'MEC Test Event',
+ 'status' => 'publish',
+ 'content' => 'This is the content of the MEC!',
+ 'start_time_hour' => gmdate( 'h', $start_timestamp ),
+ 'start_time_minutes' => gmdate( 'i', $start_timestamp ),
+ 'start_time_ampm' => gmdate( 'A', $start_timestamp ),
+ 'start' => gmdate( 'Y-m-d', $start_timestamp ),
+ 'end' => gmdate( 'Y-m-d', $end_timestamp ),
+ 'end_time_hour' => gmdate( 'h', $end_timestamp ),
+ 'end_time_minutes' => gmdate( 'i', $end_timestamp ),
+ 'end_time_ampm' => gmdate( 'A', $end_timestamp ),
+ 'repeat_status' => 0,
+ 'repeat_type' => 'daily',
+ 'interval' => 1,
+ 'location_id' => $location_id,
+ );
+
+ $post_id = $this->mec_main->save_event( $event );
+
+ $wp_object = get_post( $post_id );
+
+ // Call the transformer to make the ActivityStreams representation of the event.
+ $event_array = \Activitypub\Transformer\Factory::get_transformer( $wp_object )->to_object()->to_array();
+
+ $this->assertEquals( 'Event', $event_array['type'] );
+ $this->assertEquals( 'MEC Test Event', $event_array['name'] );
+ $this->assertEquals( 'This is the content of the MEC!', wp_strip_all_tags( $event_array['content'] ) );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 15:00:00' ) ) . 'T15:00:00Z', $event_array['startTime'] );
+ $this->assertEquals( gmdate( 'Y-m-d', strtotime( '+10 days 16:00:00' ) ) . 'T16:00:00Z', $event_array['endTime'] );
+ $this->assertTrue( $event_array['commentsEnabled'] );
+ $this->assertEquals( 'allow_all', $event_array['repliesModerationOption'] );
+ $this->assertEquals( 'external', $event_array['joinMode'] );
+ $this->assertEquals( get_permalink( $wp_object ), $event_array['externalParticipationUrl'] );
+ $this->assertArrayHasKey( 'location', $event_array );
+ $this->assertEquals( 'MEETING', $event_array['category'] );
+ $this->assertEquals( $location['address'], $event_array['location']['address'] );
+ $this->assertEquals( $location['name'], $event_array['location']['name'] );
+ $this->assertEquals( $location['latitude'], $event_array['location']['latitude'] );
+ $this->assertEquals( $location['longitude'], $event_array['location']['longitude'] );
+ }
+}
From 580b6b9989280efaf440f461c43dcc460ad01000 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sat, 19 Oct 2024 11:58:56 +0200
Subject: [PATCH 07/13] Fix content for mec not including rendered shortcodes
(#68)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/68
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
includes/activitypub/transformer/class-event.php | 4 ++--
.../transformer/class-modern-events-calendar-lite.php | 8 ++++++++
2 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/includes/activitypub/transformer/class-event.php b/includes/activitypub/transformer/class-event.php
index 5fb09f4..babf2b9 100644
--- a/includes/activitypub/transformer/class-event.php
+++ b/includes/activitypub/transformer/class-event.php
@@ -267,9 +267,9 @@ abstract class Event extends Post {
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 = true;
+ $fallback_to_content = false;
if ( is_null( $excerpt ) && $fallback_to_content ) {
- $excerpt = parent::get_content();
+ $excerpt = $this->get_content();
}
remove_filter( 'activitypub_object_content_template', array( self::class, 'remove_ap_permalink_from_template' ) );
diff --git a/includes/activitypub/transformer/class-modern-events-calendar-lite.php b/includes/activitypub/transformer/class-modern-events-calendar-lite.php
index 0877abf..8622de4 100644
--- a/includes/activitypub/transformer/class-modern-events-calendar-lite.php
+++ b/includes/activitypub/transformer/class-modern-events-calendar-lite.php
@@ -54,6 +54,14 @@ final class Modern_Events_Calendar_Lite extends Event {
$this->mec_event = new MEC_Event( $wp_object );
}
+ /**
+ * Retrieves the content without the plugins rendered shortcodes.
+ */
+ public function get_content(): string {
+ $content = wpautop( $this->wp_object->post_content );
+ return $content;
+ }
+
/**
* Get the end time from the event object.
*/
From 7bc134e13519762b58d1bfbacb8328360d665915 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sat, 19 Oct 2024 16:46:50 +0200
Subject: [PATCH 08/13] Add most minimal setup wizard, welcome page with status
and basic health checks (#67)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/67
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
CHANGELOG.md | 12 ++
assets/css/activitypub-event-bridge-admin.css | 70 +++++++
.../activitypub-event-extensions-admin.css | 6 -
includes/admin/class-health-check.php | 181 ++++++++++++++++++
includes/admin/class-settings-page.php | 46 +++--
includes/class-settings.php | 10 +
includes/class-setup.php | 18 +-
includes/plugins/class-event-plugin.php | 22 ++-
includes/plugins/class-eventin.php | 8 +-
includes/plugins/class-events-manager.php | 8 +-
includes/plugins/class-gatherpress.php | 8 +-
.../class-modern-events-calendar-lite.php | 8 +-
.../plugins/class-the-events-calendar.php | 8 +-
includes/plugins/class-vs-event-list.php | 8 +-
includes/plugins/class-wp-event-manager.php | 8 +-
templates/admin-header.php | 33 ++++
templates/settings.php | 31 +--
templates/welcome.php | 72 +++++++
18 files changed, 482 insertions(+), 75 deletions(-)
create mode 100644 CHANGELOG.md
create mode 100644 assets/css/activitypub-event-bridge-admin.css
delete mode 100644 assets/css/activitypub-event-extensions-admin.css
create mode 100644 includes/admin/class-health-check.php
create mode 100644 templates/admin-header.php
create mode 100644 templates/welcome.php
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..9a605a7
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,12 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [0.1.0] - 2024-10-20
+
+### Added
+
+* Initial version tag.
diff --git a/assets/css/activitypub-event-bridge-admin.css b/assets/css/activitypub-event-bridge-admin.css
new file mode 100644
index 0000000..b6eaae1
--- /dev/null
+++ b/assets/css/activitypub-event-bridge-admin.css
@@ -0,0 +1,70 @@
+.activitypub-event-bridge-settings-page .box {
+ border: 1px solid #c3c4c7;
+ background-color: #fff;
+ padding: 1em 1.5em;
+ margin-bottom: 1.5em;
+}
+
+.activitypub-event-bridge-settings-page .box ul.activitypub-event-bridge-list {
+ list-style-type: disc;
+ margin-left: 1.4rem;
+}
+
+.activitypub-event-bridge-settings-page .box pre {
+ padding: 1rem;
+ min-height: 200px;
+ box-shadow: none;
+ border-radius: 15px;
+ border: 1px solid #dfe0e2;
+ background-color: #f7f7f7;
+}
+
+.activitypub-event-bridge-settings {
+ max-width: 800px;
+ margin: 0 auto;
+}
+
+.activitypub-event-bridge-settings-header {
+ text-align: center;
+ margin: 0 0 1rem;
+ background: #fff;
+ border-bottom: 1px solid #dcdcde;
+}
+
+.activitypub-event-bridge-settings-title-section {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ clear: both;
+ padding-top: 8px;
+}
+
+.activitypub-event-bridge-settings-tabs-wrapper {
+ display: -ms-inline-grid;
+ -ms-grid-columns: auto auto auto auto;
+ vertical-align: top;
+ display: inline-grid;
+ grid-template-columns: auto auto auto auto;
+}
+
+.activitypub-event-bridge-settings-tab.active {
+ box-shadow: inset 0 -3px #3582c4;
+ font-weight: 600;
+}
+
+.activitypub-event-bridge-settings-tab {
+ display: block;
+ text-decoration: none;
+ color: inherit;
+ padding: .5rem 1rem 1rem;
+ margin: 0 1rem;
+ transition: box-shadow .5s ease-in-out;
+}
+
+.activitypub-event-bridge-settings .box h3 {
+ font-size: 1.1rem!important;
+}
+
+#activitypub_event_bridge_initially_activated {
+ display: hidden;
+}
diff --git a/assets/css/activitypub-event-extensions-admin.css b/assets/css/activitypub-event-extensions-admin.css
deleted file mode 100644
index c59d1a1..0000000
--- a/assets/css/activitypub-event-extensions-admin.css
+++ /dev/null
@@ -1,6 +0,0 @@
-.activitypub-event-bridge-settings-page .box {
- border: 1px solid #c3c4c7;
- background-color: #fff;
- padding: 1em 1.5em;
- margin-bottom: 1.5em;
-}
diff --git a/includes/admin/class-health-check.php b/includes/admin/class-health-check.php
new file mode 100644
index 0000000..1f44905
--- /dev/null
+++ b/includes/admin/class-health-check.php
@@ -0,0 +1,181 @@
+ __( 'ActivityPub Event Transformer Test', 'activitypub-event-bridge' ),
+ 'test' => array( self::class, 'test_event_transformation' ),
+ );
+
+ return $tests;
+ }
+
+ /**
+ * The the transformation of the most recent event posts.
+ *
+ * @return array
+ */
+ public static function test_event_transformation() {
+ $result = array(
+ 'label' => \__( 'Transformation of Events to a valid ActivityStreams representation.', 'activitypub' ),
+ 'status' => 'good',
+ 'badge' => array(
+ 'label' => \__( 'ActivityPub Event Bridge', 'activitypub-event-bridge' ),
+ 'color' => 'green',
+ ),
+ 'description' => \sprintf(
+ '%s
',
+ \__( 'The transformation of your most recent events was successful.', 'activitypub-event-bridge' )
+ ),
+ 'actions' => '',
+ 'test' => 'test_event_transformation',
+ );
+
+ $check = self::transform_most_recent_event_posts();
+
+ if ( true === $check ) {
+ return $result;
+ }
+
+ $result['status'] = 'critical';
+ $result['label'] = \__( 'One or more of your most recent events failed to transform to ActivityPub', 'activitypub-event-bridge' );
+ $result['badge']['color'] = 'red';
+ $result['description'] = \sprintf(
+ '%s
',
+ $check->get_error_message()
+ );
+
+ return $result;
+ }
+
+ /**
+ * Test if right transformer gets applied.
+ *
+ * @param Event_Plugin $event_plugin The event plugin definition.
+ *
+ * @return bool True if the check passed.
+ */
+ public static function test_if_event_transformer_is_used( $event_plugin ) {
+ // Get a (random) event post.
+ $event_posts = self::get_most_recent_event_posts( $event_plugin->get_post_type(), 1 );
+
+ // If no post is found, we can not do this test.
+ if ( ! $event_posts || is_wp_error( $event_posts ) || empty( $event_posts ) ) {
+ return true;
+ }
+
+ // Call the transformer Factory.
+ $transformer = Transformer_Factory::get_transformer( $event_posts[0] );
+ // Check that we got the right transformer.
+ $desired_transformer_class = $event_plugin::get_activitypub_event_transformer_class();
+ if ( $transformer instanceof $desired_transformer_class ) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Retrieves the most recently published event posts of a certain event post type.
+ *
+ * @param string $event_post_type The post type of the events.
+ * @param int $number_of_posts The maximum number of events to return.
+ *
+ * @return WP_Post[]|false Array of event posts, or false if none are found.
+ */
+ public static function get_most_recent_event_posts( $event_post_type, $number_of_posts = 5 ) {
+ $args = array(
+ 'numberposts' => $number_of_posts,
+ 'category' => 0,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'include' => array(),
+ 'exclude' => array(),
+ 'meta_key' => '',
+ 'meta_value' => '',
+ 'post_type' => $event_post_type,
+ 'suppress_filters' => true,
+ );
+
+ $query = new WP_Query();
+ return $query->query( $args );
+ }
+
+ /**
+ * Transform the most recent event posts.
+ */
+ public static function transform_most_recent_event_posts() {
+ return true;
+ }
+
+ /**
+ * Retrieves information like name and version from active event plugins.
+ */
+ private static function get_info_about_active_event_plugins() {
+ $active_event_plugins = Setup::get_instance()->get_active_event_plugins();
+ $info = array();
+ foreach ( $active_event_plugins as $active_event_plugin ) {
+ $event_plugin_file = $active_event_plugin->get_plugin_file();
+ $event_plugin_data = \get_plugin_data( $event_plugin_file );
+ $event_plugin_name = isset( $event_plugin_data['Plugin Name'] ) ? $event_plugin_data['Plugin Name'] : 'Name not found';
+ $event_plugin_version = isset( $event_plugin_version['Plugin Version'] ) ? $event_plugin_version['Plugin Version'] : 'Version not found';
+
+ $info[] = array(
+ 'event_plugin_name' => $event_plugin_name,
+ 'event_plugin_version' => $event_plugin_version,
+ 'event_plugin_file' => $event_plugin_file,
+ );
+ }
+ }
+
+ /**
+ * Static function for generating site debug data when required.
+ *
+ * @param array $info The debug information to be added to the core information page.
+ * @return array The extended information.
+ */
+ public static function add_debug_information( $info ) {
+ $info['activitypub_event_bridge'] = array(
+ 'label' => __( 'ActivityPub Event Bridge', 'activitypub-event-bridge' ),
+ 'fields' => array(
+ 'plugin_version' => array(
+ 'label' => __( 'Plugin Version', 'activitypub' ),
+ 'value' => ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_VERSION,
+ 'private' => true,
+ ),
+ 'active_event_plugins' => self::get_info_about_active_event_plugins(),
+ ),
+ );
+
+ return $info;
+ }
+}
diff --git a/includes/admin/class-settings-page.php b/includes/admin/class-settings-page.php
index 08a36d6..9694d98 100644
--- a/includes/admin/class-settings-page.php
+++ b/includes/admin/class-settings-page.php
@@ -89,21 +89,41 @@ class Settings_Page {
* @return void
*/
public static function settings_page(): void {
- $plugin_setup = Setup::get_instance();
-
- $event_plugins = $plugin_setup->get_active_event_plugins();
-
- $event_terms = array();
-
- foreach ( $event_plugins as $event_plugin ) {
- $event_terms = array_merge( $event_terms, self::get_event_terms( $event_plugin ) );
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( empty( $_GET['tab'] ) ) {
+ $tab = 'welcome';
+ } else {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ $tab = sanitize_key( $_GET['tab'] );
}
- $args = array(
- 'slug' => self::SETTINGS_SLUG,
- 'event_terms' => $event_terms,
- );
+ switch ( $tab ) {
+ case 'settings':
+ $plugin_setup = Setup::get_instance();
- \load_template( ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_DIR . 'templates/settings.php', true, $args );
+ $event_plugins = $plugin_setup->get_active_event_plugins();
+
+ $event_terms = array();
+
+ foreach ( $event_plugins as $event_plugin ) {
+ $event_terms = array_merge( $event_terms, self::get_event_terms( $event_plugin ) );
+ }
+
+ $args = array(
+ 'slug' => self::SETTINGS_SLUG,
+ 'event_terms' => $event_terms,
+ );
+
+ \load_template( ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_DIR . 'templates/settings.php', true, $args );
+ break;
+ case 'welcome':
+ default:
+ wp_enqueue_script( 'plugin-install' );
+ add_thickbox();
+ wp_enqueue_script( 'updates' );
+
+ \load_template( ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_DIR . 'templates/welcome.php', true );
+ break;
+ }
}
}
diff --git a/includes/class-settings.php b/includes/class-settings.php
index 922531f..e5f69f0 100644
--- a/includes/class-settings.php
+++ b/includes/class-settings.php
@@ -61,6 +61,16 @@ class Settings {
'sanitize_callback' => array( self::class, 'sanitize_event_category_mappings' ),
)
);
+
+ \register_setting(
+ 'activitypub-event-bridge',
+ 'activitypub_event_bridge_initially_activated',
+ array(
+ 'type' => 'boolean',
+ 'description' => \__( 'Whether the plugin just got activated for the first time.', 'activitypub' ),
+ 'default' => 1,
+ )
+ );
}
/**
diff --git a/includes/class-setup.php b/includes/class-setup.php
index a03c988..e9b602c 100644
--- a/includes/class-setup.php
+++ b/includes/class-setup.php
@@ -17,6 +17,7 @@ defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
use ActivityPub_Event_Bridge\Admin\Event_Plugin_Admin_Notices;
use ActivityPub_Event_Bridge\Admin\General_Admin_Notices;
+use ActivityPub_Event_Bridge\Admin\Health_Check;
use ActivityPub_Event_Bridge\Admin\Settings_Page;
use ActivityPub_Event_Bridge\Plugins\Event_Plugin;
@@ -167,20 +168,19 @@ class Setup {
add_action( 'admin_init', array( $this, 'do_admin_notices' ) );
add_action( 'admin_init', array( Settings::class, 'register_settings' ) );
+ add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_styles' ) );
+ add_action( 'admin_menu', array( Settings_Page::class, 'admin_menu' ) );
+ add_filter(
+ 'plugin_action_links_' . ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_BASENAME,
+ array( Settings_Page::class, 'settings_link' )
+ );
// If we don't have any active event plugins, or the ActivityPub plugin is not enabled, abort here.
if ( empty( $this->active_event_plugins ) || ! $this->activitypub_plugin_is_active ) {
return;
}
- add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_styles' ) );
-
- add_action( 'admin_menu', array( Settings_Page::class, 'admin_menu' ) );
-
- add_filter(
- 'plugin_action_links_' . ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_BASENAME,
- array( Settings_Page::class, 'settings_link' )
- );
+ add_action( 'init', array( Health_Check::class, 'init' ) );
// Check if the minimum required version of the ActivityPub plugin is installed.
if ( ! version_compare( $this->activitypub_plugin_version, ACTIVITYPUB_EVENT_BRIDGE_ACTIVITYPUB_PLUGIN_MIN_VERSION ) ) {
@@ -287,7 +287,7 @@ class Setup {
* This method handles the activation of the ActivityPub Event Bridge plugin.
*
* @since 1.0.0
- *
+ * @see register_activation_hook()
* @return void
*/
public function activate(): void {
diff --git a/includes/plugins/class-event-plugin.php b/includes/plugins/class-event-plugin.php
index f70cfa0..a5c5734 100644
--- a/includes/plugins/class-event-plugin.php
+++ b/includes/plugins/class-event-plugin.php
@@ -45,12 +45,24 @@ abstract class Event_Plugin {
abstract public static function get_event_category_taxonomy(): string;
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The IDs of one or several admin/settings pages.
*/
- public static function get_settings_page(): string {
- return '';
+ public static function get_settings_pages(): array {
+ return array();
+ }
+
+ /**
+ * Get the plugins name from the main plugin-file's top-level-file-comment.
+ */
+ final public static function get_plugin_name(): string {
+ $plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . static::get_plugin_file() );
+ if ( isset( $plugin_data['Name'] ) ) {
+ return $plugin_data['Name'];
+ } else {
+ return '';
+ }
}
/**
@@ -62,7 +74,7 @@ abstract class Event_Plugin {
// Check if we are on a edit page for the event, or on the settings page of the event plugin.
$is_event_plugins_edit_page = 'edit' === $screen->base && static::get_post_type() === $screen->post_type;
- $is_event_plugins_settings_page = static::get_settings_page() === $screen->id;
+ $is_event_plugins_settings_page = in_array( $screen->id, static::get_settings_pages(), true );
return $is_event_plugins_edit_page || $is_event_plugins_settings_page;
}
diff --git a/includes/plugins/class-eventin.php b/includes/plugins/class-eventin.php
index fcab7c8..6584e85 100644
--- a/includes/plugins/class-eventin.php
+++ b/includes/plugins/class-eventin.php
@@ -41,12 +41,12 @@ final class Eventin extends Event_plugin {
}
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The settings page url.
*/
- public static function get_settings_page(): string {
- return 'eventin'; // Base always is wp-admin/admin.php?page=eventin.
+ public static function get_settings_pages(): array {
+ return array( 'eventin' ); // Base always is wp-admin/admin.php?page=eventin.
}
/**
diff --git a/includes/plugins/class-events-manager.php b/includes/plugins/class-events-manager.php
index feffcc5..02ca060 100644
--- a/includes/plugins/class-events-manager.php
+++ b/includes/plugins/class-events-manager.php
@@ -41,12 +41,12 @@ final class Events_Manager extends Event_Plugin {
}
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The settings page urls.
*/
- public static function get_settings_page(): string {
- return 'wp-admin/edit.php?post_type=event&page=events-manager-options#general';
+ public static function get_settings_page(): array {
+ return array();
}
/**
diff --git a/includes/plugins/class-gatherpress.php b/includes/plugins/class-gatherpress.php
index dead962..6c6af0f 100644
--- a/includes/plugins/class-gatherpress.php
+++ b/includes/plugins/class-gatherpress.php
@@ -41,12 +41,12 @@ final class GatherPress extends Event_Plugin {
}
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The settings page urls.
*/
- public static function get_settings_page(): string {
- return class_exists( '\GatherPress\Core\Utility' ) ? \GatherPress\Core\Utility::prefix_key( 'general' ) : 'gatherpress_general';
+ public static function get_settings_pages(): array {
+ return array( class_exists( '\GatherPress\Core\Utility' ) ? \GatherPress\Core\Utility::prefix_key( 'general' ) : 'gatherpress_general' );
}
/**
diff --git a/includes/plugins/class-modern-events-calendar-lite.php b/includes/plugins/class-modern-events-calendar-lite.php
index f8f5552..796634d 100644
--- a/includes/plugins/class-modern-events-calendar-lite.php
+++ b/includes/plugins/class-modern-events-calendar-lite.php
@@ -42,12 +42,12 @@ final class Modern_Events_Calendar_Lite extends Event_plugin {
}
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The settings page urls.
*/
- public static function get_settings_page(): string {
- return 'mec-event';
+ public static function get_settings_pages(): array {
+ return array( 'MEC-settings', 'MEC-support', 'MEC-ix', 'MEC-wizard', 'MEC-addons', 'mec-intro' );
}
/**
diff --git a/includes/plugins/class-the-events-calendar.php b/includes/plugins/class-the-events-calendar.php
index ef07590..36ab8e0 100644
--- a/includes/plugins/class-the-events-calendar.php
+++ b/includes/plugins/class-the-events-calendar.php
@@ -41,17 +41,17 @@ final class The_Events_Calendar extends Event_plugin {
}
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The settings page urls.
*/
- public static function get_settings_page(): string {
+ public static function get_settings_pages(): array {
if ( class_exists( '\Tribe\Events\Admin\Settings' ) ) {
$page = \Tribe\Events\Admin\Settings::$settings_page_id;
} else {
$page = 'tec-events-settings';
}
- return sprintf( 'edit.php?post_type=tribe_events&page=%s', $page );
+ return array( $page );
}
/**
diff --git a/includes/plugins/class-vs-event-list.php b/includes/plugins/class-vs-event-list.php
index 0e1b0dc..f1bd96b 100644
--- a/includes/plugins/class-vs-event-list.php
+++ b/includes/plugins/class-vs-event-list.php
@@ -44,12 +44,12 @@ final class VS_Event_List extends Event_Plugin {
}
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The settings page urls.
*/
- public static function get_settings_page(): string {
- return 'settings_page_vsel';
+ public static function get_settings_pages(): array {
+ return array( 'settings_page_vsel' );
}
/**
diff --git a/includes/plugins/class-wp-event-manager.php b/includes/plugins/class-wp-event-manager.php
index e3d3493..28a852a 100644
--- a/includes/plugins/class-wp-event-manager.php
+++ b/includes/plugins/class-wp-event-manager.php
@@ -44,12 +44,12 @@ final class WP_Event_Manager extends Event_Plugin {
}
/**
- * Returns the ID of the main settings page of the plugin.
+ * Returns the IDs of the admin pages of the plugin.
*
- * @return string The settings page url.
+ * @return array The settings page urls.
*/
- public static function get_settings_page(): string {
- return 'event-manager-settings';
+ public static function get_settings_pages(): array {
+ return array( 'event-manager-settings' );
}
/**
diff --git a/templates/admin-header.php b/templates/admin-header.php
new file mode 100644
index 0000000..efc02bc
--- /dev/null
+++ b/templates/admin-header.php
@@ -0,0 +1,33 @@
+ '',
+ 'settings' => '',
+ )
+);
+?>
+
+
+
diff --git a/templates/settings.php b/templates/settings.php
index 8eb382e..309590f 100644
--- a/templates/settings.php
+++ b/templates/settings.php
@@ -13,6 +13,14 @@
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
+\load_template(
+ __DIR__ . '/admin-header.php',
+ true,
+ array(
+ 'settings' => 'active',
+ )
+);
+
use Activitypub\Activity\Extended_Object\Event;
if ( ! isset( $args ) || ! array_key_exists( 'event_terms', $args ) ) {
@@ -31,19 +39,12 @@ $selected_default_event_category = \get_option( 'activitypub_event_bridge_defaul
$current_category_mapping = \get_option( 'activitypub_event_bridge_event_category_mappings', array() );
?>
-
-
-
-
+
diff --git a/templates/welcome.php b/templates/welcome.php
new file mode 100644
index 0000000..3fbed43
--- /dev/null
+++ b/templates/welcome.php
@@ -0,0 +1,72 @@
+ 'active',
+ )
+);
+
+$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
+
+global $wp_filesystem;
+WP_Filesystem();
+
+?>
+
+
+
+
+
+
+
+ -
+ get_plugin_name() ); ?>:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ get_contents( ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_DIR . '/CHANGELOG.md' );
+ echo esc_html( substr( $changelog, strpos( $changelog, "\n", 180 ) + 1 ) );
+ ?>
+
+
+
+
+
+
From 2e9d4c45232c596300c125775c3e86df0a131d2d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sat, 19 Oct 2024 16:58:54 +0200
Subject: [PATCH 09/13] Fix passing null to strpos in gatherpress transformer
(#69)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/69
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
composer.json | 2 +-
includes/activitypub/transformer/class-gatherpress.php | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/composer.json b/composer.json
index eaf1ea5..6236dfc 100644
--- a/composer.json
+++ b/composer.json
@@ -58,7 +58,7 @@
],
"test-debug": [
"@prepare-test",
- "@test-modern-events-calendar-lite"
+ "@test-gatherpress"
],
"test-vs-event-list": "phpunit --filter=vs_event_list",
"test-the-events-calendar": "phpunit --filter=the_events_calendar",
diff --git a/includes/activitypub/transformer/class-gatherpress.php b/includes/activitypub/transformer/class-gatherpress.php
index b67bc58..756524a 100644
--- a/includes/activitypub/transformer/class-gatherpress.php
+++ b/includes/activitypub/transformer/class-gatherpress.php
@@ -124,7 +124,7 @@ final class GatherPress extends Event {
*/
public static function filter_gatherpress_blocks( $block_content, $block ) {
// Check if the block name starts with 'gatherpress'.
- if ( strpos( $block['blockName'], 'gatherpress/' ) === 0 ) {
+ if ( isset( $block['blockName'] ) && 0 === strpos( $block['blockName'], 'gatherpress/' ) ) {
return ''; // Skip rendering this block.
}
From 036cea270811c5004b776b0fb0213ecb45538a5c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sun, 20 Oct 2024 10:50:08 +0200
Subject: [PATCH 10/13] Add GatherPress to Readme
---
README.md | 2 +-
readme.txt | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 892dc58..07aceea 100644
--- a/README.md
+++ b/README.md
@@ -64,7 +64,7 @@ This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/ac
* [WP Event Manager](https://de.wordpress.org/plugins/wp-event-manager/)
* [Eventin](https://de.wordpress.org/plugins/wp-event-solution/)
* [Modern Events Calendar Lite](https://webnus.net/modern-events-calendar/)
-
+* [GatherPress](https://gatherpress.org/)
## Configuration
diff --git a/readme.txt b/readme.txt
index 149fe68..d0b9c7e 100644
--- a/readme.txt
+++ b/readme.txt
@@ -54,6 +54,7 @@ This plugin depends on the [ActivityPub plugin](https://wordpress.org/plugins/ac
* [WP Event Manager](https://de.wordpress.org/plugins/wp-event-manager/)
* [Eventin](https://de.wordpress.org/plugins/wp-event-solution/)
* [Modern Events Calendar Lite](https://webnus.net/modern-events-calendar/)
+* [GatherPress](https://gatherpress.org/)
== Configuration ==
From 45f36d5c2a56d5f0d3f0bdb5da7be877f3f51d25 Mon Sep 17 00:00:00 2001
From: linos
Date: Tue, 22 Oct 2024 16:54:17 +0200
Subject: [PATCH 11/13] docker-compose.yml aktualisiert
---
docker-compose.yml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/docker-compose.yml b/docker-compose.yml
index dc71501..d39760b 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,6 +1,9 @@
version: '3'
# This files purpose is to run the PHPunit tests locally.
+# Prerequisites:
+# Run "composer install" to generate a composer.lock file and install PHP dev dependencies.
+#
# Install docker and docker compose and than just run:
# docker compose up
From 2da7bd443c3c141af18b0433783464d9168af8c8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Tue, 22 Oct 2024 18:52:57 +0200
Subject: [PATCH 12/13] Add "How to Check if It's Working" section in admin
(#70)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reviewed-on: https://code.event-federation.eu/Event-Federation/wordpress-activitypub-event-bridge/pulls/70
Co-authored-by: André Menrath
Co-committed-by: André Menrath
---
activitypub-event-bridge.php | 4 +-
assets/css/activitypub-event-bridge-admin.css | 115 ++++++-
assets/img/activitypub.svg | 288 ++++++++++++++++++
assets/img/fediverse.svg | 17 ++
assets/img/mastodon.svg | 10 +
assets/img/mobilizon.svg | 1 +
assets/js/activitypub-event-bridge-admin.js | 14 +
.../transformer/class-the-events-calendar.php | 2 +-
.../admin/class-general-admin-notices.php | 18 ++
includes/admin/class-health-check.php | 10 +-
includes/admin/class-settings-page.php | 12 +-
includes/class-settings.php | 2 +-
includes/class-setup.php | 10 +
templates/welcome.php | 181 ++++++++++-
14 files changed, 660 insertions(+), 24 deletions(-)
create mode 100644 assets/img/activitypub.svg
create mode 100644 assets/img/fediverse.svg
create mode 100644 assets/img/mastodon.svg
create mode 100644 assets/img/mobilizon.svg
create mode 100644 assets/js/activitypub-event-bridge-admin.js
diff --git a/activitypub-event-bridge.php b/activitypub-event-bridge.php
index a4e0cad..b1cb2ea 100644
--- a/activitypub-event-bridge.php
+++ b/activitypub-event-bridge.php
@@ -3,7 +3,7 @@
* Plugin Name: ActivityPub Event Bridge
* Description: Integrating popular event plugins with the ActivityPub plugin.
* Plugin URI: https://event-federation.eu/
- * Version: 0.1.0
+ * Version: 0.1.1
* Author: André Menrath
* Author URI: https://graz.social/@linos
* Text Domain: activitypub-event-bridge
@@ -11,7 +11,7 @@
* License URI: https://www.gnu.org/licenses/agpl-3.0.de.html
* Requires PHP: 8.1
*
- * Requires at least ActivityPub plugin with version >= 3.2.2. ActivityPub plugin tested up to: 3.2.2.
+ * Requires at least ActivityPub plugin with version >= 3.2.2. ActivityPub plugin tested up to: 3.3.3.
*
* @package ActivityPub_Event_Bridge
* @license AGPL-3.0-or-later
diff --git a/assets/css/activitypub-event-bridge-admin.css b/assets/css/activitypub-event-bridge-admin.css
index b6eaae1..c89ec6e 100644
--- a/assets/css/activitypub-event-bridge-admin.css
+++ b/assets/css/activitypub-event-bridge-admin.css
@@ -1,3 +1,7 @@
+.settings_page_activitypub-event-bridge #wpcontent {
+ padding-left: 0;
+}
+
.activitypub-event-bridge-settings-page .box {
border: 1px solid #c3c4c7;
background-color: #fff;
@@ -6,8 +10,7 @@
}
.activitypub-event-bridge-settings-page .box ul.activitypub-event-bridge-list {
- list-style-type: disc;
- margin-left: 1.4rem;
+ margin-left: 0.6em;
}
.activitypub-event-bridge-settings-page .box pre {
@@ -62,9 +65,115 @@
}
.activitypub-event-bridge-settings .box h3 {
- font-size: 1.1rem!important;
+ font-size: 1.15em;
+ margin-bottom: 0em;
}
#activitypub_event_bridge_initially_activated {
display: hidden;
}
+
+/* Accordions for admin pages */
+.activitypub-event-bridge-settings-accordion {
+ border: 1px solid #c3c4c7;
+}
+
+.activitypub-event-bridge-settings-accordion-heading {
+ margin: 0;
+ border-top: 1px solid #c3c4c7;
+ font-size: inherit;
+ line-height: inherit;
+ font-weight: 600;
+ color: inherit;
+}
+
+.activitypub-event-bridge-settings-accordion-heading:first-child {
+ border-top: none;
+}
+
+.activitypub-event-bridge-settings-accordion-panel {
+ margin: 0;
+ padding: 1em 1.5em;
+ background: #fff;
+}
+
+.activitypub-event-bridge-settings-accordion-trigger {
+ background: #fff;
+ border: 0;
+ color: #2c3338;
+ cursor: pointer;
+ display: flex;
+ font-weight: 400;
+ margin: 0;
+ padding: 1em 3.5em 1em 1.5em;
+ min-height: 46px;
+ position: relative;
+ text-align: left;
+ width: 100%;
+ align-items: center;
+ justify-content: space-between;
+ -webkit-user-select: auto;
+ user-select: auto;
+}
+
+.activitypub-event-bridge-settings-accordion-trigger {
+ color: #2c3338;
+ cursor: pointer;
+ font-weight: 400;
+ text-align: left;
+}
+
+.activitypub-event-bridge-settings-accordion-trigger .title {
+ pointer-events: none;
+ font-weight: 600;
+ flex-grow: 1;
+}
+
+.activitypub-event-bridge-settings-accordion-trigger .icon,
+.activitypub-event-bridge-settings-accordion-viewed .icon {
+ border: solid #50575e medium;
+ border-width: 0 2px 2px 0;
+ height: .5rem;
+ pointer-events: none;
+ position: absolute;
+ right: 1.5em;
+ top: 50%;
+ transform: translateY(-70%) rotate(45deg);
+ width: .5rem;
+}
+
+.activitypub-event-bridge-settings-accordion-trigger[aria-expanded="true"] .icon {
+ transform: translateY(-30%) rotate(-135deg);
+}
+
+.activitypub-event-bridge-settings-accordion-trigger:active,
+.activitypub-event-bridge-settings-accordion-trigger:hover {
+ background: #f6f7f7;
+}
+
+.activitypub-event-bridge-settings-accordion-trigger:focus {
+ color: #1d2327;
+ border: none;
+ box-shadow: none;
+ outline-offset: -1px;
+ outline: 2px solid #2271b1;
+ background-color: #f6f7f7;
+}
+
+.activitypub-event-bridge-settings-inline-icon {
+ width: 1.5em;
+ height: 1.5em;
+ vertical-align: middle;
+ margin: 0 0.3em;
+}
+
+code.activitypub-event-bridge-settings-example-url {
+ display: block;
+ background: rgb(28, 29, 33);
+ padding: 8px;
+ margin: 10px 0px 10px 0;
+ border-radius: 7px;
+ color: #d5d5d6;
+ overflow-x: auto;
+ word-break: break-all;
+}
diff --git a/assets/img/activitypub.svg b/assets/img/activitypub.svg
new file mode 100644
index 0000000..f56d428
--- /dev/null
+++ b/assets/img/activitypub.svg
@@ -0,0 +1,288 @@
+
+
+
+
diff --git a/assets/img/fediverse.svg b/assets/img/fediverse.svg
new file mode 100644
index 0000000..a789df2
--- /dev/null
+++ b/assets/img/fediverse.svg
@@ -0,0 +1,17 @@
+
diff --git a/assets/img/mastodon.svg b/assets/img/mastodon.svg
new file mode 100644
index 0000000..0f8baeb
--- /dev/null
+++ b/assets/img/mastodon.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/img/mobilizon.svg b/assets/img/mobilizon.svg
new file mode 100644
index 0000000..8b5d57e
--- /dev/null
+++ b/assets/img/mobilizon.svg
@@ -0,0 +1 @@
+
diff --git a/assets/js/activitypub-event-bridge-admin.js b/assets/js/activitypub-event-bridge-admin.js
new file mode 100644
index 0000000..bf00a1c
--- /dev/null
+++ b/assets/js/activitypub-event-bridge-admin.js
@@ -0,0 +1,14 @@
+jQuery( function( $ ) {
+ // Accordion handling in various areas.
+ $( '.activitypub-event-bridge-settings-accordion' ).on( 'click', '.activitypub-event-bridge-settings-accordion-trigger', function() {
+ var isExpanded = ( 'true' === $( this ).attr( 'aria-expanded' ) );
+
+ if ( isExpanded ) {
+ $( this ).attr( 'aria-expanded', 'false' );
+ $( '#' + $( this ).attr( 'aria-controls' ) ).attr( 'hidden', true );
+ } else {
+ $( this ).attr( 'aria-expanded', 'true' );
+ $( '#' + $( this ).attr( 'aria-controls' ) ).attr( 'hidden', false );
+ }
+ } );
+} );
diff --git a/includes/activitypub/transformer/class-the-events-calendar.php b/includes/activitypub/transformer/class-the-events-calendar.php
index 9d76aaa..2fe7274 100644
--- a/includes/activitypub/transformer/class-the-events-calendar.php
+++ b/includes/activitypub/transformer/class-the-events-calendar.php
@@ -173,7 +173,7 @@ final class The_Events_Calendar extends Event {
} else {
$location->set_address( $venue->post_title );
}
- $location->set_id( $venue->permalink );
+ $location->set_id( $venue->ID );
$location->set_name( $venue->post_title );
return $location;
diff --git a/includes/admin/class-general-admin-notices.php b/includes/admin/class-general-admin-notices.php
index 43cb4f3..7ab1f2b 100644
--- a/includes/admin/class-general-admin-notices.php
+++ b/includes/admin/class-general-admin-notices.php
@@ -98,6 +98,24 @@ class General_Admin_Notices {
);
}
+ /**
+ * Warning to fix status issues first.
+ *
+ * @return string
+ */
+ public static function get_admin_notice_status_not_ok(): string {
+ return sprintf(
+ /* translators: 1: An URL to the list of supported event plugins. */
+ _x(
+ 'The Plugin ActivityPub Event Bridge is of no use, because you do not have installed and activated a supported Event Plugin.
+
For a list of supported Event Plugins see here.',
+ 'admin notice',
+ 'activitypub-event-bridge'
+ ),
+ esc_html( self::ACTIVITYPUB_EVENT_BRIDGE_SUPPORTED_EVENT_PLUGINS_URL )
+ );
+ }
+
/**
* Warning if the plugin is Active and the ActivityPub plugin is not.
*
diff --git a/includes/admin/class-health-check.php b/includes/admin/class-health-check.php
index 1f44905..5fa3f9a 100644
--- a/includes/admin/class-health-check.php
+++ b/includes/admin/class-health-check.php
@@ -107,12 +107,16 @@ class Health_Check {
/**
* Retrieves the most recently published event posts of a certain event post type.
*
- * @param string $event_post_type The post type of the events.
- * @param int $number_of_posts The maximum number of events to return.
+ * @param ?string $event_post_type The post type of the events.
+ * @param ?int $number_of_posts The maximum number of events to return.
*
* @return WP_Post[]|false Array of event posts, or false if none are found.
*/
- public static function get_most_recent_event_posts( $event_post_type, $number_of_posts = 5 ) {
+ public static function get_most_recent_event_posts( $event_post_type = null, $number_of_posts = 5 ) {
+ if ( ! $event_post_type ) {
+ $event_post_type = Setup::get_instance()->get_active_event_plugins()[0]->get_post_type();
+ }
+
$args = array(
'numberposts' => $number_of_posts,
'category' => 0,
diff --git a/includes/admin/class-settings-page.php b/includes/admin/class-settings-page.php
index 9694d98..b2dbb3e 100644
--- a/includes/admin/class-settings-page.php
+++ b/includes/admin/class-settings-page.php
@@ -3,7 +3,7 @@
* General settings class.
*
* This file contains the General class definition, which handles the "General" settings
- * page for the ActivityPub Event Extension Plugin, providing options for configuring various general settings.
+ * page for the Activitypub Event Bridge Plugin, providing options for configuring various general settings.
*
* @package ActivityPub_Event_Bridge
* @since 1.0.0
@@ -18,9 +18,9 @@ use ActivityPub_Event_Bridge\Plugins\Event_Plugin;
use ActivityPub_Event_Bridge\Setup;
/**
- * Class responsible for the ActivityPub Event Extension related Settings.
+ * Class responsible for the Activitypub Event Bridge related Settings.
*
- * Class which handles the "General" settings page for the ActivityPub Event Extension Plugin,
+ * Class which handles the "General" settings page for the Activitypub Event Bridge Plugin,
* providing options for configuring various general settings.
*
* @since 1.0.0
@@ -36,11 +36,11 @@ class Settings_Page {
*/
public static function admin_menu(): void {
\add_options_page(
- 'Activitypub Event Extension',
- __( 'ActivityPub Events', 'activitypub-event-bridge' ),
+ 'Activitypub Event Bridge',
+ __( 'ActivityPub Event Bridge', 'activitypub-event-bridge' ),
'manage_options',
self::SETTINGS_SLUG,
- array( self::STATIC, 'settings_page' )
+ array( self::STATIC, 'settings_page' ),
);
}
diff --git a/includes/class-settings.php b/includes/class-settings.php
index e5f69f0..3367dd1 100644
--- a/includes/class-settings.php
+++ b/includes/class-settings.php
@@ -3,7 +3,7 @@
* General settings class.
*
* This file contains the General class definition, which handles the "General" settings
- * page for the ActivityPub Event Extension Plugin, providing options for configuring various general settings.
+ * page for the Activitypub Event Bridge Plugin, providing options for configuring various general settings.
*
* @package ActivityPub_Event_Bridge
* @since 1.0.0
diff --git a/includes/class-setup.php b/includes/class-setup.php
index e9b602c..966d1d3 100644
--- a/includes/class-setup.php
+++ b/includes/class-setup.php
@@ -208,6 +208,16 @@ class Setup {
array(),
ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_VERSION
);
+ wp_enqueue_script(
+ 'activitypub-event-bridge-admin-script',
+ plugins_url(
+ 'assets/js/activitypub-event-bridge-admin.js',
+ ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_FILE
+ ),
+ array( 'jquery' ),
+ ACTIVITYPUB_EVENT_BRIDGE_PLUGIN_VERSION,
+ false
+ );
}
}
diff --git a/templates/welcome.php b/templates/welcome.php
index 3fbed43..cb63f5c 100644
--- a/templates/welcome.php
+++ b/templates/welcome.php
@@ -20,7 +20,17 @@ use ActivityPub_Event_Bridge\Admin\Health_Check;
)
);
-$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
+$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
+$activitypub_event_bridge_status_ok = true;
+$example_event_post = Health_Check::get_most_recent_event_posts();
+
+if ( empty( $example_event_post ) ) {
+ $example_event_post = 'https://yoursite.com/events/event-name';
+ $example_event_post_is_dummy = true;
+} else {
+ $example_event_post = \get_permalink( $example_event_post[0] );
+ $example_event_post_is_dummy = false;
+}
global $wp_filesystem;
WP_Filesystem();
@@ -31,21 +41,64 @@ WP_Filesystem();
-
+ get_plugin_name() ); ?>:
+
+ -
+ %2$s is enabled in the %1$s settings.',
+ 'admin notice',
+ 'activitypub-event-bridge'
+ ),
+ esc_html( get_plugin_data( ACTIVITYPUB_PLUGIN_FILE )['Name'] ),
+ esc_html( $active_event_plugin->get_plugin_name() ),
+ admin_url( 'options-general.php?page=activitypub&tab=settings' )
+ );
+ } else {
+ $activitypub_event_bridge_status_ok = false;
+ echo '❌ ';
+ $status_message_post_type_enabled = sprintf(
+ /* translators: 1: the name of the event plugin a admin notice is shown. 2: The name of the ActivityPub plugin. */
+ _x(
+ 'The post type for events of the plugin %2$s is not enabled in the %1$s settings.',
+ 'admin notice',
+ 'activitypub-event-bridge'
+ ),
+ esc_html( get_plugin_data( ACTIVITYPUB_PLUGIN_FILE )['Name'] ),
+ esc_html( $active_event_plugin->get_plugin_name() ),
+ admin_url( 'options-general.php?page=activitypub&tab=settings' )
+ );
+ }
+ $allowed_html = array(
+ 'a' => array(
+ 'href' => true,
+ 'title' => true,
+ ),
+ 'b' => array(),
+ 'i' => array(),
+ );
+ echo \wp_kses( $status_message_post_type_enabled, $allowed_html );
+ ?>
+
-
- get_plugin_name() ); ?>:
-
-
-
+
+
@@ -57,6 +110,118 @@ WP_Filesystem();
+
+
+
' . \esc_html__( 'Please fix the status issues above first.', 'activitypub-event-bridge' ) . '
';
+ }
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ element.
+ $activitypub_query = '' . esc_html( '?activitypub' ) . '
';
+
+ $activitypub_url_html = '' . esc_html( $activitypub_url ) . '';
+
+ // Translator comment to explain the placeholder.
+ /* translators: %1$s is the ?activitypub
string, and %2$s is the full URL of an example event */
+ $raw_string = sprintf( __( 'For more technical users, you can inspect how your event is converted into an ActivityPub object. Simply append %1$s to the end of any single event pages URL to view the raw ActivityStreams JSON data (e.g., %2$s).', 'activitypub-event-bridge' ), $activitypub_query, $activitypub_url_html );
+
+ // Allowed HTML tags in the string (only and ).
+ $allowed_html = array(
+ 'a' => array(
+ 'href' => array(),
+ 'target' => array(),
+ ),
+ 'nobr' => array(),
+ 'code' => array(),
+ );
+
+ // Output the formatted string with the allowed HTML elements.
+ echo wp_kses( $raw_string, $allowed_html );
+ ?>
+
+
+
+
+
From c8ea6e4eac08af3fc3ac04c145a1ab89ec44b2d4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Menrath?=
Date: Sat, 26 Oct 2024 16:26:26 +0200
Subject: [PATCH 13/13] First draft on wordpress.org banner image and icon
---
.wordpress-org/banner-1544x500.jpg | Bin 0 -> 241330 bytes
.wordpress-org/banner-772x250.jpg | Bin 0 -> 111767 bytes
.wordpress-org/icon-128x128.gif | Bin 0 -> 139621 bytes
.wordpress-org/icon-256x256.gif | Bin 0 -> 322501 bytes
4 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 .wordpress-org/banner-1544x500.jpg
create mode 100644 .wordpress-org/banner-772x250.jpg
create mode 100644 .wordpress-org/icon-128x128.gif
create mode 100644 .wordpress-org/icon-256x256.gif
diff --git a/.wordpress-org/banner-1544x500.jpg b/.wordpress-org/banner-1544x500.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..0f1839b0c7e5ce7db718bc1eb88fc67378c8f912
GIT binary patch
literal 241330
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