Improving detection of active event plugins: use caching and don't use WordPress internal constant WP_PLUGINS_DIR (#87)
All checks were successful
PHP Code Checker / PHP Code Checker (push) Successful in 47s
PHPUnit / PHPUnit – PHP 7.4 (push) Successful in 1m4s
PHPUnit / PHPUnit – PHP 8.0 (push) Successful in 1m1s
PHPUnit / PHPUnit – PHP 8.1 (push) Successful in 1m3s
PHPUnit / PHPUnit – PHP 8.2 (push) Successful in 1m2s
PHPUnit / PHPUnit – PHP 8.3 (push) Successful in 1m5s
PHPUnit / PHPUnit – PHP 8.4 (push) Successful in 1m3s
All checks were successful
PHP Code Checker / PHP Code Checker (push) Successful in 47s
PHPUnit / PHPUnit – PHP 7.4 (push) Successful in 1m4s
PHPUnit / PHPUnit – PHP 8.0 (push) Successful in 1m1s
PHPUnit / PHPUnit – PHP 8.1 (push) Successful in 1m3s
PHPUnit / PHPUnit – PHP 8.2 (push) Successful in 1m2s
PHPUnit / PHPUnit – PHP 8.3 (push) Successful in 1m5s
PHPUnit / PHPUnit – PHP 8.4 (push) Successful in 1m3s
Reviewed-on: #87 Co-authored-by: André Menrath <andre.menrath@posteo.de> Co-committed-by: André Menrath <andre.menrath@posteo.de>
This commit is contained in:
parent
44daeb5b59
commit
f8e343cf20
18 changed files with 78 additions and 34 deletions
|
@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
* Add custom summary via shortcodes
|
* Add custom summary via shortcodes
|
||||||
|
|
||||||
## [0.3.1] - 2024-11-16
|
## [0.3.2] - 2024-12-12
|
||||||
|
|
||||||
* Initial release on WordPress.org
|
* Initial release on WordPress.org
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
**Tags:** events, fediverse, activitypub, calendar
|
**Tags:** events, fediverse, activitypub, calendar
|
||||||
**Requires at least:** 6.5
|
**Requires at least:** 6.5
|
||||||
**Tested up to:** 6.7
|
**Tested up to:** 6.7
|
||||||
**Stable tag:** 0.3.1
|
**Stable tag:** 0.3.2
|
||||||
**Requires PHP:** 7.4
|
**Requires PHP:** 7.4
|
||||||
**License:** AGPL-3.0-or-later
|
**License:** AGPL-3.0-or-later
|
||||||
**License URI:** https://www.gnu.org/licenses/agpl-3.0.html
|
**License URI:** https://www.gnu.org/licenses/agpl-3.0.html
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
* Plugin Name: Event Bridge for ActivityPub
|
* Plugin Name: Event Bridge for ActivityPub
|
||||||
* Description: Integrating popular event plugins with the ActivityPub plugin.
|
* Description: Integrating popular event plugins with the ActivityPub plugin.
|
||||||
* Plugin URI: https://event-federation.eu/
|
* Plugin URI: https://event-federation.eu/
|
||||||
* Version: 0.3.1.1
|
* Version: 0.3.2
|
||||||
* Author: André Menrath
|
* Author: André Menrath
|
||||||
* Author URI: https://graz.social/@linos
|
* Author URI: https://graz.social/@linos
|
||||||
* Text Domain: event-bridge-for-activitypub
|
* Text Domain: event-bridge-for-activitypub
|
||||||
|
|
|
@ -69,7 +69,15 @@ class Event_Plugin_Admin_Notices {
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
private function do_admin_notice_post_type_not_activitypub_enabled(): void {
|
private function do_admin_notice_post_type_not_activitypub_enabled(): void {
|
||||||
$event_plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . $this->event_plugin::get_plugin_file() );
|
$all_plugins = get_plugins();
|
||||||
|
$event_plugin_file = $this->event_plugin::get_relative_plugin_file();
|
||||||
|
if ( isset( $all_plugins[ $event_plugin_file ]['Name'] ) ) {
|
||||||
|
$event_plugin_name = $all_plugins[ $event_plugin_file ]['Name'];
|
||||||
|
} elseif ( isset( get_mu_plugins()[ $event_plugin_file ]['Name'] ) ) {
|
||||||
|
$event_plugin_name = get_mu_plugins()[ $event_plugin_file ]['Name'];
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
$activitypub_plugin_data = get_plugin_data( ACTIVITYPUB_PLUGIN_FILE );
|
$activitypub_plugin_data = get_plugin_data( ACTIVITYPUB_PLUGIN_FILE );
|
||||||
$notice = sprintf(
|
$notice = sprintf(
|
||||||
/* translators: 1: the name of the event plugin a admin notice is shown. 2: The name of the ActivityPub plugin. */
|
/* translators: 1: the name of the event plugin a admin notice is shown. 2: The name of the ActivityPub plugin. */
|
||||||
|
@ -79,7 +87,7 @@ class Event_Plugin_Admin_Notices {
|
||||||
'event-bridge-for-activitypub'
|
'event-bridge-for-activitypub'
|
||||||
),
|
),
|
||||||
esc_html( $activitypub_plugin_data['Name'] ),
|
esc_html( $activitypub_plugin_data['Name'] ),
|
||||||
esc_html( $event_plugin_data['Name'] ),
|
esc_html( $event_plugin_name ),
|
||||||
admin_url( 'options-general.php?page=activitypub&tab=settings' )
|
admin_url( 'options-general.php?page=activitypub&tab=settings' )
|
||||||
);
|
);
|
||||||
$allowed_html = array(
|
$allowed_html = array(
|
||||||
|
|
|
@ -114,7 +114,9 @@ class Health_Check {
|
||||||
*/
|
*/
|
||||||
public static function get_most_recent_event_posts( $event_post_type = null, $number_of_posts = 5 ) {
|
public static function get_most_recent_event_posts( $event_post_type = null, $number_of_posts = 5 ) {
|
||||||
if ( ! $event_post_type ) {
|
if ( ! $event_post_type ) {
|
||||||
$event_post_type = Setup::get_instance()->get_active_event_plugins()[0]->get_post_type();
|
$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
|
||||||
|
$active_event_plugin = reset( $active_event_plugins );
|
||||||
|
$event_post_type = $active_event_plugin->get_post_type();
|
||||||
}
|
}
|
||||||
|
|
||||||
$args = array(
|
$args = array(
|
||||||
|
@ -148,7 +150,7 @@ class Health_Check {
|
||||||
$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
|
$active_event_plugins = Setup::get_instance()->get_active_event_plugins();
|
||||||
$info = array();
|
$info = array();
|
||||||
foreach ( $active_event_plugins as $active_event_plugin ) {
|
foreach ( $active_event_plugins as $active_event_plugin ) {
|
||||||
$event_plugin_file = $active_event_plugin->get_plugin_file();
|
$event_plugin_file = $active_event_plugin->get_relative_plugin_file();
|
||||||
$event_plugin_data = \get_plugin_data( $event_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_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';
|
$event_plugin_version = isset( $event_plugin_version['Plugin Version'] ) ? $event_plugin_version['Plugin Version'] : 'Version not found';
|
||||||
|
|
|
@ -97,6 +97,9 @@ class Settings_Page {
|
||||||
$tab = sanitize_key( $_GET['tab'] );
|
$tab = sanitize_key( $_GET['tab'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to always re-scan active event plugins, when user visits admin area of this plugin.
|
||||||
|
Setup::get_instance()->redetect_active_event_plugins();
|
||||||
|
|
||||||
switch ( $tab ) {
|
switch ( $tab ) {
|
||||||
case 'settings':
|
case 'settings':
|
||||||
$plugin_setup = Setup::get_instance();
|
$plugin_setup = Setup::get_instance();
|
||||||
|
|
|
@ -67,9 +67,8 @@ class Setup {
|
||||||
// deactivate_plugins( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE );
|
// deactivate_plugins( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE );
|
||||||
// return;
|
// return;
|
||||||
// }.
|
// }.
|
||||||
$this->active_event_plugins = self::detect_active_event_plugins();
|
|
||||||
$this->activitypub_plugin_version = self::get_activitypub_plugin_version();
|
$this->activitypub_plugin_version = self::get_activitypub_plugin_version();
|
||||||
$this->setup_hooks();
|
add_action( 'plugins_loaded', array( $this, 'setup_hooks' ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -106,8 +105,7 @@ class Setup {
|
||||||
if ( defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) ) {
|
if ( defined( 'ACTIVITYPUB_PLUGIN_VERSION' ) ) {
|
||||||
return constant( 'ACTIVITYPUB_PLUGIN_VERSION' );
|
return constant( 'ACTIVITYPUB_PLUGIN_VERSION' );
|
||||||
}
|
}
|
||||||
$version = get_file_data( WP_PLUGIN_DIR . '/activitypub/activitypub.php', array( 'Version' ) )[0];
|
return '0.0.0';
|
||||||
return $version ?? '0.0.0';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -136,23 +134,47 @@ class Setup {
|
||||||
'\Event_Bridge_For_ActivityPub\Integrations\Event_Organiser',
|
'\Event_Bridge_For_ActivityPub\Integrations\Event_Organiser',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force the re-scan for active event plugins without using the cached transient.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function redetect_active_event_plugins(): void {
|
||||||
|
delete_transient( 'event_bridge_for_activitypub_active_event_plugins' );
|
||||||
|
$this->detect_active_event_plugins();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function that checks for supported activated event plugins.
|
* Function that checks for supported activated event plugins.
|
||||||
*
|
*
|
||||||
* @return array List of supported event plugins as keys from the SUPPORTED_EVENT_PLUGINS const.
|
* @return array List of supported event plugins as keys from the SUPPORTED_EVENT_PLUGINS const.
|
||||||
*/
|
*/
|
||||||
public static function detect_active_event_plugins(): array {
|
public function detect_active_event_plugins(): array {
|
||||||
$active_event_plugins = array();
|
$active_event_plugins = get_transient( 'event_bridge_for_activitypub_active_event_plugins' );
|
||||||
|
|
||||||
|
if ( $active_event_plugins ) {
|
||||||
|
$this->active_event_plugins = $active_event_plugins;
|
||||||
|
return $active_event_plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! function_exists( 'get_plugins' ) ) {
|
||||||
|
require_once ABSPATH . 'wp-admin/includes/plugin.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
$all_plugins = array_merge( get_plugins(), get_mu_plugins() );
|
||||||
|
|
||||||
|
$active_event_plugins = array();
|
||||||
foreach ( self::EVENT_PLUGIN_CLASSES as $event_plugin_class ) {
|
foreach ( self::EVENT_PLUGIN_CLASSES as $event_plugin_class ) {
|
||||||
if ( ! class_exists( $event_plugin_class ) || ! method_exists( $event_plugin_class, 'get_plugin_file' ) ) {
|
$event_plugin_file = call_user_func( array( $event_plugin_class, 'get_relative_plugin_file' ) );
|
||||||
|
if ( ! $event_plugin_file ) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$event_plugin_file = call_user_func( array( $event_plugin_class, 'get_plugin_file' ) );
|
if ( array_key_exists( $event_plugin_file, $all_plugins ) && \is_plugin_active( $event_plugin_file ) ) {
|
||||||
if ( \is_plugin_active( $event_plugin_file ) ) {
|
$active_event_plugins[ $event_plugin_file ] = new $event_plugin_class();
|
||||||
$active_event_plugins[] = new $event_plugin_class();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
set_transient( 'event_bridge_for_activitypub_active_event_plugins', $active_event_plugins );
|
||||||
|
$this->active_event_plugins = $active_event_plugins;
|
||||||
return $active_event_plugins;
|
return $active_event_plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,9 +187,14 @@ class Setup {
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
protected function setup_hooks(): void {
|
public function setup_hooks(): void {
|
||||||
|
$this->detect_active_event_plugins();
|
||||||
|
|
||||||
register_activation_hook( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE, array( $this, 'activate' ) );
|
register_activation_hook( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE, array( $this, 'activate' ) );
|
||||||
|
|
||||||
|
add_action( 'activated_plugin', array( $this, 'redetect_active_event_plugins' ) );
|
||||||
|
add_action( 'deactivated_plugin', array( $this, 'redetect_active_event_plugins' ) );
|
||||||
|
|
||||||
add_action( 'admin_init', array( $this, 'do_admin_notices' ) );
|
add_action( 'admin_init', array( $this, 'do_admin_notices' ) );
|
||||||
add_action( 'admin_init', array( Settings::class, 'register_settings' ) );
|
add_action( 'admin_init', array( Settings::class, 'register_settings' ) );
|
||||||
add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_styles' ) );
|
add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_styles' ) );
|
||||||
|
@ -303,6 +330,7 @@ class Setup {
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function activate(): void {
|
public function activate(): void {
|
||||||
|
$this->redetect_active_event_plugins();
|
||||||
// Don't allow plugin activation, when the ActivityPub plugin is not activated yet.
|
// Don't allow plugin activation, when the ActivityPub plugin is not activated yet.
|
||||||
if ( ! $this->activitypub_plugin_is_active ) {
|
if ( ! $this->activitypub_plugin_is_active ) {
|
||||||
deactivate_plugins( plugin_basename( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE ) );
|
deactivate_plugins( plugin_basename( EVENT_BRIDGE_FOR_ACTIVITYPUB_PLUGIN_FILE ) );
|
||||||
|
|
|
@ -27,7 +27,7 @@ final class Event_Organiser extends Event_Plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'event-organiser/event-organiser.php';
|
return 'event-organiser/event-organiser.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,11 +24,11 @@ defined( 'ABSPATH' ) || exit; // @codeCoverageIgnore
|
||||||
*/
|
*/
|
||||||
abstract class Event_Plugin {
|
abstract class Event_Plugin {
|
||||||
/**
|
/**
|
||||||
* Returns the full plugin file.
|
* Returns the plugin file relative to the plugins dir.
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
abstract public static function get_plugin_file(): string;
|
abstract public static function get_relative_plugin_file(): string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the event post type of the plugin.
|
* Returns the event post type of the plugin.
|
||||||
|
@ -57,9 +57,9 @@ abstract class Event_Plugin {
|
||||||
* Get the plugins name from the main plugin-file's top-level-file-comment.
|
* Get the plugins name from the main plugin-file's top-level-file-comment.
|
||||||
*/
|
*/
|
||||||
final public static function get_plugin_name(): string {
|
final public static function get_plugin_name(): string {
|
||||||
$plugin_data = get_plugin_data( WP_PLUGIN_DIR . '/' . static::get_plugin_file() );
|
$all_plugins = array_merge( get_plugins(), get_mu_plugins() );
|
||||||
if ( isset( $plugin_data['Name'] ) ) {
|
if ( isset( $all_plugins[ static::get_relative_plugin_file() ]['Name'] ) ) {
|
||||||
return $plugin_data['Name'];
|
return $all_plugins[ static::get_relative_plugin_file() ]['Name'];
|
||||||
} else {
|
} else {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,7 +27,7 @@ final class Eventin extends Event_plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'wp-event-solution/eventin.php';
|
return 'wp-event-solution/eventin.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -33,7 +33,7 @@ final class EventPrime extends Event_Plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'eventprime-event-calendar-management/event-prime.php';
|
return 'eventprime-event-calendar-management/event-prime.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -75,6 +75,9 @@ final class EventPrime extends Event_Plugin {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine whether the current request is an EventPrime ActivityPub request.
|
* Determine whether the current request is an EventPrime ActivityPub request.
|
||||||
|
*
|
||||||
|
* Forked from https://github.com/Automattic/wordpress-activitypub/blob/trunk/includes/functions.php
|
||||||
|
* the function is_activitypub_request.
|
||||||
*/
|
*/
|
||||||
private static function is_eventprime_activitypub_request() {
|
private static function is_eventprime_activitypub_request() {
|
||||||
global $wp_query;
|
global $wp_query;
|
||||||
|
@ -192,7 +195,7 @@ final class EventPrime extends Event_Plugin {
|
||||||
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
|
* @see https://www.w3.org/wiki/SocialCG/ActivityPub/Primer/Authentication_Authorization#Authorized_fetch
|
||||||
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
|
* @see https://swicg.github.io/activitypub-http-signature/#authorized-fetch
|
||||||
*/
|
*/
|
||||||
if ( $activitypub_template && ACTIVITYPUB_AUTHORIZED_FETCH ) {
|
if ( $activitypub_template && defined( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) && constant( 'ACTIVITYPUB_AUTHORIZED_FETCH' ) ) {
|
||||||
$verification = Signature::verify_http_signature( $_SERVER );
|
$verification = Signature::verify_http_signature( $_SERVER );
|
||||||
if ( \is_wp_error( $verification ) ) {
|
if ( \is_wp_error( $verification ) ) {
|
||||||
header( 'HTTP/1.1 401 Unauthorized' );
|
header( 'HTTP/1.1 401 Unauthorized' );
|
||||||
|
|
|
@ -27,7 +27,7 @@ final class Events_Manager extends Event_Plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'events-manager/events-manager.php';
|
return 'events-manager/events-manager.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ final class GatherPress extends Event_Plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'gatherpress/gatherpress.php';
|
return 'gatherpress/gatherpress.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ final class Modern_Events_Calendar_Lite extends Event_plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'modern-events-calendar-lite/modern-events-calendar-lite.php';
|
return 'modern-events-calendar-lite/modern-events-calendar-lite.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,7 @@ final class The_Events_Calendar extends Event_plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'the-events-calendar/the-events-calendar.php';
|
return 'the-events-calendar/the-events-calendar.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ final class VS_Event_List extends Event_Plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'very-simple-event-list/vsel.php';
|
return 'very-simple-event-list/vsel.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -30,7 +30,7 @@ final class WP_Event_Manager extends Event_Plugin {
|
||||||
*
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_plugin_file(): string {
|
public static function get_relative_plugin_file(): string {
|
||||||
return 'wp-event-manager/wp-event-manager.php';
|
return 'wp-event-manager/wp-event-manager.php';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ Contributors: andremenrath
|
||||||
Tags: events, fediverse, activitypub, calendar
|
Tags: events, fediverse, activitypub, calendar
|
||||||
Requires at least: 6.5
|
Requires at least: 6.5
|
||||||
Tested up to: 6.7
|
Tested up to: 6.7
|
||||||
Stable tag: 0.3.1
|
Stable tag: 0.3.2
|
||||||
Requires PHP: 7.4
|
Requires PHP: 7.4
|
||||||
License: AGPL-3.0-or-later
|
License: AGPL-3.0-or-later
|
||||||
License URI: https://www.gnu.org/licenses/agpl-3.0.html
|
License URI: https://www.gnu.org/licenses/agpl-3.0.html
|
||||||
|
|
Loading…
Reference in a new issue