Skip to content

Instantly share code, notes, and snippets.

@Spoygg
Created Sep 10, 2020
Embed
What would you like to do?
<?php
class blcModuleManager {
/* @var blcConfigurationManager */
var $plugin_conf;
var $module_dir = '';
var $_module_cache;
var $_category_cache;
var $_category_cache_active;
var $_virtual_modules = array();
var $loaded;
var $instances;
var $default_active_modules;
/**
* The singleton instance of blcModuleManager.
*
* @var blcModuleManager
*/
private static $instance = null;
/**
* Class "constructor".
*
* @param array $default_active_modules An array of module ids specifying which modules are active by default.
* @return void
*/
function init( $default_active_modules = null ) {
$this->module_dir = realpath( dirname( __FILE__ ) . '/../modules' );
$this->plugin_conf = blc_get_configuration();
$this->default_active_modules = $default_active_modules;
$this->loaded = array();
$this->instances = array();
add_filter( 'extra_plugin_headers', array( &$this, 'inject_module_headers' ) );
}
/**
* Get an instance of the module manager.
*
* @param array|null $default_active_modules
* @return blcModuleManager
*/
//phpcs:ignore WordPress.NamingConventions.ValidFunctionName.MethodNameInvalid
static function getInstance( $default_active_modules = null ) {
if ( is_null( self::$instance ) ) {
self::$instance = new blcModuleManager();
self::$instance->init( $default_active_modules );
}
return self::$instance;
}
/**
* Retrieve a list of all installed BLC modules.
*
* This is essentially a slightly modified copy of get_plugins().
*
* @return array An associative array of module headers indexed by module ID.
*/
function get_modules() {
if ( ! isset( $this->_module_cache ) ) {
// Refresh the module cache.
$relative_path = '/' . plugin_basename( $this->module_dir );
if ( ! function_exists( 'get_plugins' ) ) {
// BUG: Potentional security flaw/bug. plugin.php is not meant to be loaded outside admin panel.
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$modules = get_plugins( $relative_path );
$this->_module_cache = array();
foreach ( $modules as $module_filename => $module_header ) {
// Figure out the module ID. If not specified, it is equal to module's filename (sans the .php)
if ( ! empty( $module_header['ModuleID'] ) ) {
$module_id = strtolower( trim( $module_header['ModuleID'] ) );
} else {
$module_id = strtolower( basename( $module_filename, '.php' ) );
}
$module_header = $this->normalize_module_header( $module_header, $module_id, $module_filename );
$this->_module_cache[ $module_id ] = $module_header;
}
$this->_category_cache = null;
}
return array_merge( $this->_module_cache, $this->_virtual_modules );
}
/**
* Retrieve modules that match a specific category, or all modules sorted by categories.
*
* If a category ID is specified, this method returns the modules that have the "ModuleCategory:"
* file header set to that value, or an empty array if no modules match that category. The
* return array is indexed by module id :
* [module_id1 => module1_data, module_id1 => module2_data, ...]
*
* If category is omitted, this method returns a list of all categories plus the modules
* they contain. Only categories that have at least one module will be included. The return
* value is an array of arrays, indexed by category ID :
* [category1 => [module1_id => module1_data, module2_id => module2_data, ...], ...]
*
* @param string $category Category id, e.g. "parser" or "container". Optional.
* @param bool $markup Apply markup to module headers. Not implemented.
* @param bool $translate Translate module headers. Defaults to false.
* @return array An array of categories or module data.
*/
function get_modules_by_category( $category = '', $markup = false, $translate = false ) {
if ( ! isset( $this->_category_cache ) ) {
$this->_category_cache = $this->sort_into_categories( $this->get_modules() );
}
if ( empty( $category ) ) {
if ( $markup || $translate ) {
// Translate/apply markup to module headers
$processed = array();
foreach ( $this->_category_cache as $category_id => $modules ) {
$processed[ $category_id ] = array();
foreach ( $modules as $module_id => $module_data ) {
if ( $translate ) {
$module_data['Name'] = _x( $module_data['Name'], 'module name', 'broken-link-checker' ); //phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
}
$processed[ $category_id ][ $module_id ] = $module_data;
}
}
return $processed;
} else {
return $this->_category_cache;
}
} else {
if ( isset( $this->_category_cache[ $category ] ) ) {
if ( $markup || $translate ) {
// Translate/apply markup to module headers
$processed = array();
foreach ( $this->_category_cache[ $category ] as $module_id => $module_data ) {
if ( $translate ) {
$module_data['Name'] = _x( $module_data['Name'], 'module name', 'broken-link-checker' ); //phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText
}
$processed[ $module_id ] = $module_data;
}
return $processed;
} else {
return $this->_category_cache[ $category ];
}
} else {
return array();
}
}
}
/**
* Retrieve active modules that match a specific category, or all active modules sorted by categories.
*
* @see blcModuleManager::get_modules_by_category()
*
* @param string $category Category id. Optional.
* @return array An associative array of categories or module data.
*/
function get_active_by_category( $category = '' ) {
if ( ! isset( $this->_category_cache_active ) ) {
$this->_category_cache_active = $this->sort_into_categories( $this->get_active_modules() );
}
if ( empty( $category ) ) {
return $this->_category_cache_active;
} else {
if ( isset( $this->_category_cache_active[ $category ] ) ) {
return $this->_category_cache_active[ $category ];
} else {
return array();
}
}
}
/**
* Get the module ids of all active modules that belong to a specific category,
* quoted and ready for use in SQL.
*
* @param string $category Category ID. If not specified, a list of all active modules will be returned.
* @return string A comma separated list of single-quoted module ids, e.g. 'module-foo','module-bar','modbaz'
*/
function get_escaped_ids( $category = '' ) {
global $wpdb;
if ( empty( $category ) ) {
$modules = $this->get_active_modules();
} else {
$modules = $this->get_active_by_category( $category );
}
$modules = array_map( 'esc_sql', array_keys( $modules ) );
$modules = "'" . implode( "', '", $modules ) . "'";
return $modules;
}
/**
* Sort a list of modules into categories. Inside each category, modules are sorted by priority (descending).
*
* @access private
*
* @param array $modules
* @return array
*/
function sort_into_categories( $modules ) {
$categories = array();
foreach ( $modules as $module_id => $module_data ) {
$cat = $module_data['ModuleCategory'];
if ( isset( $categories[ $cat ] ) ) {
$categories[ $cat ][ $module_id ] = $module_data;
} else {
$categories[ $cat ] = array( $module_id => $module_data );
}
}
foreach ( $categories as $cat => $cat_modules ) {
uasort( $categories[ $cat ], array( &$this, 'compare_priorities' ) );
}
return $categories;
}
/**
* Callback for sorting modules by priority.
*
* @access private
*
* @param array $a First module header.
* @param array $b Second module header.
* @return int
*/
function compare_priorities( $a, $b ) {
return $b['ModulePriority'] - $a['ModulePriority'];
}
/**
* Retrieve a reference to an active module.
*
* Each module is instantiated only once, so if the module was already loaded you'll get back
* a reference to the existing module object. If the module isn't loaded or instantiated yet,
* the function will do it automatically (but only for active modules).
*
* @param string $module_id Module ID.
* @param bool $autoload Optional. Whether to load the module file if it's not loaded yet. Defaults to TRUE.
* @param string $category Optional. Return the module only if it's in a specific category. Categories are ignored by default.
* @return blcModule A reference to a module object, or NULL on error.
*/
function get_module( $module_id, $autoload = true, $category = '' ) {
$no_result = null;
if ( ! is_string( $module_id ) ) {
// $backtrace = debug_backtrace();
// FB::error($backtrace, "get_module called with a non-string argument");
return $no_result;
}
if ( empty( $this->loaded[ $module_id ] ) ) {
if ( $autoload && $this->is_active( $module_id ) ) {
if ( ! $this->load_module( $module_id ) ) {
return $no_result;
}
} else {
return $no_result;
}
}
if ( ! empty( $category ) ) {
$data = $this->get_module_data( $module_id );
if ( $data['ModuleCategory'] != $category ) {
return $no_result;
}
}
$module = $this->init_module( $module_id );
return $module;
}
/**
* Retrieve the header data of a specific module.
* Uses cached module info if available.
*
* @param string $module_id
* @param bool $use_active_cache Check the active module cache before the general one. Defaults to true.
* @return array Associative array of module data, or FALSE if the specified module was not found.
*/
function get_module_data( $module_id, $use_active_cache = true ) {
// Check virtual modules
if ( isset( $this->_virtual_modules[ $module_id ] ) ) {
return $this->_virtual_modules[ $module_id ];
}
// Check active modules
if ( $use_active_cache && isset( $this->plugin_conf->options['active_modules'][ $module_id ] ) ) {
return $this->plugin_conf->options['active_modules'][ $module_id ];
}
// Otherwise, use the general module cache
if ( ! isset( $this->_module_cache ) ) {
$this->get_modules(); // Populates the cache
}
if ( isset( $this->_module_cache[ $module_id ] ) ) {
return $this->_module_cache[ $module_id ];
} else {
return false;
}
}
/**
* Retrieve a list of active modules.
*
* The list of active modules is stored in the 'active_modules' key of the
* plugin configuration object. If this key is not set, this function will
* create it and populate it using the list of default active modules passed
* to the module manager's constructor.
*
* @return array Associative array of module data indexed by module ID.
*/
function get_active_modules() {
if ( isset( $this->plugin_conf->options['active_modules'] ) ) {
return $this->plugin_conf->options['active_modules'];
}
$active = array();
$modules = $this->get_modules();
if ( is_array( $this->default_active_modules ) ) {
foreach ( $this->default_active_modules as $module_id ) {
if ( array_key_exists( $module_id, $modules ) ) {
$active[ $module_id ] = $modules[ $module_id ];
}
}
}
$this->plugin_conf->options['active_modules'] = $active;
$this->plugin_conf->save_options();
return $this->plugin_conf->options['active_modules'];
}
/**
* Determine if module is active.
*
* @param string $module_id
* @return bool
*/
function is_active( $module_id ) {
return array_key_exists( $module_id, $this->get_active_modules() );
}
/**
* Activate a module.
* Does nothing if the module is already active.
*
* @param string $module_id
* @return bool True if module was activated successfully, false otherwise.
*/
function activate( $module_id ) {
if ( $this->is_active( $module_id ) ) {
return true;
}
// Retrieve the module header
$module_data = $this->get_module_data( $module_id, false );
if ( ! $module_data ) {
return false;
}
// Attempt to load the module
if ( $this->load_module( $module_id, $module_data ) ) {
// Okay, if it loads, we can assume it works.
$this->plugin_conf->options['active_modules'][ $module_id ] = $module_data;
$this->plugin_conf->save_options();
// Invalidate the per-category active module cache
$this->_category_cache_active = null;
// Notify the module that it's been activated
$module = $this->get_module( $module_id );
if ( $module ) {
$module->activated();
}
return true;
} else {
return false;
}
}
/**
* Deactivate a module.
* Does nothing if the module is already inactive.
*
* @param string $module_id
* @return bool
*/
function deactivate( $module_id ) {
if ( ! $this->is_active( $module_id ) ) {
return true;
}
// Some modules are supposed to be always active and thus can't be deactivated
$module_data = $this->get_module_data( $module_id, false );
if ( isset( $module_data['ModuleAlwaysActive'] ) && $module_data['ModuleAlwaysActive'] ) {
return false;
}
// Notify the module that it's being deactivated
$module = $this->get_module( $module_id );
if ( $module ) {
$module->deactivated();
}
unset( $this->plugin_conf->options['active_modules'][ $module_id ] );
// Keep track of when each module was last deactivated. Used for parser resynchronization.
if ( isset( $this->plugin_conf->options['module_deactivated_when'] ) ) {
$this->plugin_conf->options['module_deactivated_when'][ $module_id ] = current_time( 'timestamp' );
} else {
$this->plugin_conf->options['module_deactivated_when'] = array(
$module_id => current_time( 'timestamp' ),
);
}
$this->plugin_conf->save_options();
$this->_category_cache_active = null; // Invalidate the by-category cache since we just changed something
return true;
}
/**
* Determine when a module was last deactivated.
*
* @param string $module_id Module ID.
* @return int Timestamp of last deactivation, or 0 if the module has never been deactivated.
*/
function get_last_deactivation_time( $module_id ) {
if ( isset( $this->plugin_conf->options['module_deactivated_when'][ $module_id ] ) ) {
return $this->plugin_conf->options['module_deactivated_when'][ $module_id ];
} else {
return 0;
}
}
/**
* Set the current list of active modules. If any of the modules are not currently active,
* they will be activated. Any currently active modules that are not on the new list will
* be deactivated.
*
* @param array $ids An array of module IDs.
* @return void
*/
function set_active_modules( $ids ) {
$current_active = array_keys( $this->get_active_modules() );
$activate = array_diff( $ids, $current_active );
$deactivate = array_diff( $current_active, $ids );
// Deactivate any modules not present in the new list
foreach ( $deactivate as $module_id ) {
$this->deactivate( $module_id );
}
// Activate modules present in the new list but not in the old list
foreach ( $activate as $module_id ) {
$this->activate( $module_id );
}
// Ensure all active modules have the latest headers
$this->refresh_active_module_cache();
// Invalidate the per-category active module cache
$this->_category_cache_active = null;
}
/**
* Send the activation message to all currently active plugins when the plugin is activated.
*
* @return void
*/
function plugin_activated() {
global $blclog;
// Ensure all active modules have the latest headers
$blclog->log( '... Updating module cache' );
$start = microtime( true );
$this->refresh_active_module_cache();
$blclog->info( sprintf( '... Cache refresh took %.3f seconds', microtime( true ) - $start ) );
// Notify them that we've been activated
$blclog->log( '... Loading modules' );
$start = microtime( true );
$this->load_modules();
$blclog->info( sprintf( '... %d modules loaded in %.3f seconds', count( $this->loaded ), microtime( true ) - $start ) );
$active = $this->get_active_modules();
foreach ( $active as $module_id => $module_data ) {
$blclog->log( sprintf( '... Notifying module "%s"', $module_id ) );
$module = $this->get_module( $module_id );
if ( $module ) {
$module->plugin_activated();
} else {
$blclog->warn( sprintf( '... Module "%s" failed to load!', $module_id ) );
}
}
}
/**
* Refresh the cached data of all active modules.
*
* @return array An updated list of active modules.
*/
function refresh_active_module_cache() {
$modules = $this->get_modules();
foreach ( $this->plugin_conf->options['active_modules'] as $module_id => $module_header ) {
if ( array_key_exists( $module_id, $modules ) ) {
$this->plugin_conf->options['active_modules'][ $module_id ] = $modules[ $module_id ];
}
}
$this->plugin_conf->save_options();
$this->_category_cache_active = null; // Invalidate the by-category active module cache
return $this->plugin_conf->options['active_modules'];
}
/**
* Load active modules.
*
* @param string $context Optional. If specified, only the modules that match this context (or the "all" context) will be loaded.
* @return void
*/
function load_modules( $context = '' ) {
$active = $this->get_active_modules();
// Avoid trying to load a virtual module before the module that registered it has been loaded.
$active = $this->put_virtual_last( $active );
foreach ( $active as $module_id => $module_data ) {
// Load the module
$should_load = ( 'all' == $module_data['ModuleContext'] ) || ( ! empty( $context ) && $module_data['ModuleContext'] == $context );
if ( $should_load ) {
$this->load_module( $module_id, $module_data );
}
}
}
/**
* Load and possibly instantiate a specific module.
*
* @access private
*
* @param string $module_id
* @param array $module_data
* @return bool True if the module was successfully loaded, false otherwise.
*/
function load_module( $module_id, $module_data = null ) {
// Only load each module once.
if ( ! empty( $this->loaded[ $module_id ] ) ) {
return true;
}
if ( ! isset( $module_data ) ) {
$module_data = $this->get_module_data( $module_id );
if ( empty( $module_data ) ) {
return false;
}
}
// Load a normal module
if ( empty( $module_data['virtual'] ) ) {
// Skip invalid and missing modules
if ( empty( $module_data['file'] ) ) {
return false;
}
// Get the full path to the module file
$filename = $this->module_dir . '/' . $module_data['file'];
if ( ! file_exists( $filename ) ) {
return false;
}
// Load it
include $filename;
$this->loaded[ $module_id ] = true;
} else {
// Virtual modules don't need to be explicitly loaded, but they must
// be registered.
if ( ! array_key_exists( $module_id, $this->_virtual_modules ) ) {
return false;
}
$this->loaded[ $module_id ] = true;
}
// Instantiate the main module class unless lazy init is on (default is off)
if ( ! array_key_exists( $module_id, $this->instances ) ) { // Only try to instantiate once
if ( ! $module_data['ModuleLazyInit'] ) {
$this->init_module( $module_id, $module_data );
}
}
return true;
}
/**
* Instantiate a certain module.
*
* @param string $module_id
* @param array $module_data Optional. The header data of the module that needs to be instantiated. If not specified, it will be retrieved automatically.
* @return object The newly instantiated module object (extends blcModule), or NULL on error.
*/
function init_module( $module_id, $module_data = null ) {
// Each module is only instantiated once.
if ( isset( $this->instances[ $module_id ] ) ) {
return $this->instances[ $module_id ];
}
if ( ! isset( $module_data ) ) {
$module_data = $this->get_module_data( $module_id );
if ( empty( $module_data ) ) {
return null;
}
}
if ( ! empty( $module_data['ModuleClassName'] ) && class_exists( $module_data['ModuleClassName'] ) ) {
$className = $module_data['ModuleClassName'];
$this->instances[ $module_id ] = new $className(
$module_id,
$module_data,
$this->plugin_conf,
$this
);
return $this->instances[ $module_id ];
};
return null;
}
function is_instantiated( $module_id ) {
return ! empty( $this->instances[ $module_id ] );
}
/**
* Register a virtual module.
*
* Virtual modules are the same as normal modules, except that they can be added
* on the fly, e.g. by other modules.
*
* @param string $module_id Module Id.
* @param string $module_data Associative array of module data. All module header fields are allowed, except ModuleID.
* @return void
*/
function register_virtual_module( $module_id, $module_data ) {
$module_data = $this->normalize_module_header( $module_data, $module_id );
$module_data['virtual'] = true;
$this->_virtual_modules[ $module_id ] = $module_data;
}
/**
* Sort an array of modules so that all virtual modules are placed at its end.
*
* @param array $modules Input array, [module_id => module_data, ...].
* @return array Sorted array.
*/
function put_virtual_last( $modules ) {
uasort( $modules, array( &$this, 'compare_virtual_flags' ) );
return $modules;
}
/**
* Callback function for sorting modules by the state of their 'virtual' flag.
*
* @param array $a Associative array of module A data
* @param array $b Associative array of module B data
* @return int
*/
function compare_virtual_flags( $a, $b ) {
if ( empty( $a['virtual'] ) ) {
return empty( $b['virtual'] ) ? 0 : -1;
} else {
return empty( $b['virtual'] ) ? 1 : 0;
}
}
/**
* Validate active modules.
*
* Validates all active modules, deactivates invalid ones and returns
* an array of deactivated modules.
*
* @return array
*/
function validate_active_modules() {
$active = $this->get_active_modules();
if ( empty( $active ) ) {
return array();
}
$invalid = array();
foreach ( $active as $module_id => $module_data ) {
$rez = $this->validate_module( $module_data );
if ( is_wp_error( $rez ) ) {
$invalid[ $module_id ] = $rez;
$this->deactivate( $module_id );
}
}
return $invalid;
}
/**
* Validate module data.
*
* Checks that the module file exists or that the module
* is a currently registered virtual module.
*
* @param array $module_data Associative array of module data.
* @return bool|WP_Error True on success, an error object if the module fails validation
*/
function validate_module( $module_data ) {
if ( empty( $module_data['ModuleID'] ) ) {
return new WP_Error( 'invalid_cached_header', 'The cached module header is invalid' );
}
if ( empty( $module_data['virtual'] ) ) {
// Normal modules must have a valid filename
if ( empty( $module_data['file'] ) ) {
return new WP_Error( 'module_not_found', 'Invalid module file' );
}
$filename = $this->module_dir . '/' . $module_data['file'];
if ( ! file_exists( $filename ) ) {
return new WP_Error( 'module_not_found', 'Module file not found' );
}
// The module file header must be in the proper format. While $module_data comes
// from cache and can be assumed to be correct, get_modules() will attempt to load
// the current headers and only return modules with semi-valid headers.
$installed = $this->get_modules();
if ( ! array_key_exists( $module_data['ModuleID'], $installed ) ) {
return new WP_Error( 'invalid_module_header', 'Invalid module header' );
}
} else {
// Virtual modules need to be currently registered
if ( ! array_key_exists( $module_data['ModuleID'], $this->_virtual_modules ) ) {
return new WP_Error( 'module_not_registered', 'The virtual module is not registered' );
}
}
return true;
}
/**
* Add BLC-module specific headers to the list of allowed plugin headers. This
* lets us use get_plugins() to retrieve the list of BLC modules.
*
* @param array $headers Currently known plugin headers.
* @return array New plugin headers.
*/
function inject_module_headers( $headers ) {
$module_headers = array(
'ModuleID',
'ModuleCategory',
'ModuleContext',
'ModuleLazyInit',
'ModuleClassName',
'ModulePriority',
'ModuleCheckerUrlPattern',
'ModuleHidden', // Don't show the module in the Settings page
'ModuleAlwaysActive', // Module can't be deactivated.
'ModuleRequiresPro', // Can only be activated in the Pro version
);
return array_merge( $headers, $module_headers );
}
/**
* Normalize a module header, using defaults where necessary.
*
* @param array $module_header Module header, as read from the module's .php file.
* @param string $module_id Module ID.
* @param string $module_filename Module filename. Optional.
* @return array Normalized module header.
*/
function normalize_module_header( $module_header, $module_id, $module_filename = '' ) {
// Default values for optional module header fields
$defaults = array(
'ModuleContext' => 'all',
'ModuleCategory' => 'other',
'ModuleLazyInit' => 'false',
'ModulePriority' => '0',
'ModuleHidden' => 'false',
'ModuleAlwaysActive' => 'false',
'ModuleRequiresPro' => 'false',
'TextDomain' => 'broken-link-checker', // For translating module headers
);
$module_header['ModuleID'] = $module_id; // Just for consistency
$module_header['file'] = $module_filename; // Used later to load the module
// Apply defaults
foreach ( $defaults as $field => $default_value ) {
if ( empty( $module_header[ $field ] ) ) {
$module_header[ $field ] = $default_value;
}
}
// Convert bool/int fields from strings to native datatypes
$module_header['ModuleLazyInit'] = $this->str_to_bool( $module_header['ModuleLazyInit'] );
$module_header['ModuleHidden'] = $this->str_to_bool( $module_header['ModuleHidden'] );
$module_header['ModuleAlwaysActive'] = $this->str_to_bool( $module_header['ModuleAlwaysActive'] );
$module_header['ModuleRequiresPro'] = $this->str_to_bool( $module_header['ModuleRequiresPro'] );
$module_header['ModulePriority'] = intval( $module_header['ModulePriority'] );
return $module_header;
}
/**
* Converts the strings "true" and "false" to boolean TRUE and FALSE, respectively.
* Any other string will yield FALSE.
*
* @param string $value "true" or "false", case-insensitive.
* @return bool
*/
function str_to_bool( $value ) {
$value = trim( strtolower( $value ) );
return 'true' == $value;
}
/**
* Generates a PHP script that calls the __() i18n function with
* the name and description of each available module. The generated
* script is used to make module headers show up in the .POT file.
*
* @access private
*
* @return string
*/
function _build_header_translation_code() {
$this->_module_cache = null; // Clear the cache
$modules = $this->get_modules();
$strings = array();
foreach ( $modules as $module_id => $module_header ) {
if ( $module_header['ModuleHidden'] || ( 'write-module-placeholders' == $module_id ) ) {
continue;
}
if ( ! empty( $module_header['Name'] ) ) {
$strings[] = sprintf(
'_x("%s", "module name", "broken-link-checker");',
str_replace( '"', '\"', $module_header['Name'] )
);
}
}
return "<?php\n" . implode( "\n", $strings ) . "\n";
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment