Permalink
Fetching contributors…
Cannot retrieve contributors at this time
4835 lines (4246 sloc) 156 KB
<?php namespace ProcessWire;
/**
* ProcessWire Modules
*
* Loads and manages all runtime modules for ProcessWire
*
* Note that when iterating, find(), or calling any other method that returns module(s), excepting get(), a ModulePlaceholder may be
* returned rather than a real Module. ModulePlaceholders are used in instances when the module may or may not be needed at runtime
* in order to save resources. As a result, anything iterating through these Modules should check to make sure it's not a ModulePlaceholder
* before using it. If it's a ModulePlaceholder, then the real Module can be instantiated/retrieved by $modules->get($className).
*
* ProcessWire 3.x, Copyright 2016 by Ryan Cramer
* https://processwire.com
*
* #pw-summary Loads and manages all modules in ProcessWire.
* #pw-body =
* The `$modules` API variable is most commonly used for getting individual modules to use their API.
* ~~~~~
* // Getting a module by name
* $m = $modules->get('MarkupPagerNav');
*
* // Getting a module by name (alternate)
* $m = $modules->MarkupPagerNav;
* ~~~~~
*
* #pw-body
*
* @todo Move all module information methods to a ModulesInfo class
* @todo Move all module loading methods to a ModulesLoad class
*
* @method void refresh() Refresh the cache that stores module files by recreating it
* @method null|Module install($class, $options = array())
* @method bool|int delete($class)
* @method bool uninstall($class)
* @method bool saveModuleConfigData($className, array $configData) Alias of saveConfig() method #pw-internal
* @method bool saveConfig($class, $data, $value = null)
* @method InputfieldWrapper|null getModuleConfigInputfields($moduleName, InputfieldWrapper $form = null) #pw-internal
* @method void moduleVersionChanged(Module $module, $fromVersion, $toVersion) #pw-internal
*
*/
class Modules extends WireArray {
/**
* Whether or not module debug mode is active
*
*/
protected $debug = false;
/**
* Flag indicating the module may have only one instance at runtime.
*
*/
const flagsSingular = 1;
/**
* Flag indicating that the module should be instantiated at runtime, rather than when called upon.
*
*/
const flagsAutoload = 2;
/**
* Flag indicating the module has more than one copy of it on the file system.
*
*/
const flagsDuplicate = 4;
/**
* When combined with flagsAutoload, indicates that the autoload is conditional
*
*/
const flagsConditional = 8;
/**
* When combined with flagsAutoload, indicates that the module's autoload state is temporarily disabled
*
*/
const flagsDisabled = 16;
/**
* Indicates module that maintains a configurable interface but with no interactive Inputfields
*
*/
const flagsNoUserConfig = 32;
/**
* Filename for module info cache file
*
*/
const moduleInfoCacheName = 'Modules.info';
/**
* Filename for verbose module info cache file
*
*/
const moduleInfoCacheVerboseName = 'ModulesVerbose.info';
/**
* Filename for uninstalled module info cache file
*
*/
const moduleInfoCacheUninstalledName = 'ModulesUninstalled.info';
/**
* Cache name for module version change cache
*
*/
const moduleLastVersionsCacheName = 'ModulesVersions.info';
/**
* Array of modules that are not currently installed, indexed by className => filename
*
*/
protected $installable = array();
/**
* An array of module database IDs indexed by: class => id
*
* Used internally for database operations
*
*/
protected $moduleIDs = array();
/**
* Full system paths where modules are stored
*
* index 0 must be the core modules path (/i.e. /wire/modules/)
*
*/
protected $paths = array();
/**
* Cached module configuration data indexed by module ID
*
* Values are integer 1 for modules that have config data but data is not yet loaded.
* Values are an array for modules have have config data and has been loaded.
*
*/
protected $configData = array();
/**
* Module created dates indexed by module ID
*
*/
protected $createdDates = array();
/**
* Have the modules been init'd() ?
*
*/
protected $initialized = false;
/**
* Becomes an array if debug mode is on
*
*/
protected $debugLog = array();
/**
* Array of moduleName => condition
*
* Condition can be either an anonymous function or a selector string to be evaluated at ready().
*
*/
protected $conditionalAutoloadModules = array();
/**
* Cache of module information
*
*/
protected $moduleInfoCache = array();
/**
* Cache of module information (verbose text) including: summary, author, href, file, core
*
*/
protected $moduleInfoCacheVerbose = array();
/**
* Cache of uninstalled module information (verbose for uninstalled) including: summary, author, href, file, core
*
* Note that this one is indexed by class name rather than by ID (since uninstalled modules have no ID)
*
*/
protected $moduleInfoCacheUninstalled = array();
/**
* Cache of module information from DB used across multiple calls temporarily by load() method
*
*/
protected $modulesTableCache = array();
/**
* Cache of namespace => path for unique module namespaces
*
* @var array|null Becomes an array once populated
*
*/
protected $moduleNamespaceCache = null;
/**
* Last known versions of modules, for version change tracking
*
* @var array of ModuleName (string) => last known version (integer|string)
*
*/
protected $modulesLastVersions = array();
/**
* Array of module ID => flags (int)
*
* @var array
*
*/
protected $moduleFlags = array();
/**
* Array of moduleName => substituteModuleName to be used when moduleName doesn't exist
*
* Primarily for providing backwards compatiblity with modules assumed installed that
* may no longer be in core.
*
* see setSubstitutes() method
*
*/
protected $substitutes = array();
/**
* Instance of ModulesDuplicates
*
* @var ModulesDuplicates
*
*/
protected $duplicates;
/**
* Module file extensions indexed by module name where value 1=.module, and 2=.module.php
*
* @var array
*
*/
protected $moduleFileExts = array();
/**
* Dir for core modules relative to root path, i.e. '/wire/modules/'
*
* @var string
*
*/
protected $coreModulesDir = '';
/**
* Properties that only appear in 'verbose' moduleInfo
*
* @var array
*
*/
protected $moduleInfoVerboseKeys = array(
'summary',
'author',
'href',
'file',
'core',
'versionStr',
'permissions',
'page',
);
/**
* Core module types that are isolated by directory
*
* @var array
*
*/
protected $coreTypes = array(
'AdminTheme',
'Fieldtype',
'Inputfield',
'Jquery',
'LanguageSupport',
'Markup',
'Process',
'Session',
'System',
'Textformatter',
);
/**
* Construct the Modules
*
* @param string $path Core modules path (you may add other paths with addPath method)
*
*/
public function __construct($path) {
$this->addPath($path);
$this->coreModulesDir = '/' . $this->wire('config')->urls->data('modules');
}
/**
* Get the ModulesDuplicates instance
*
* #pw-internal
*
* @return ModulesDuplicates
*
*/
public function duplicates() {
if(is_null($this->duplicates)) $this->duplicates = $this->wire(new ModulesDuplicates());
return $this->duplicates;
}
/**
* Add another modules path, must be called before init()
*
* #pw-internal
*
* @param string $path
*
*/
public function addPath($path) {
$this->paths[] = $path;
}
/**
* Return all assigned module root paths
*
* #pw-internal
*
* @return array of modules paths, with index 0 always being the core modules path.
*
*/
public function getPaths() {
return $this->paths;
}
/**
* Initialize modules
*
* Must be called after construct before this class is ready to use
*
* #pw-internal
*
* @see load()
*
*/
public function init() {
$this->setTrackChanges(false);
$this->loadModuleInfoCache();
$this->loadModulesTable();
foreach($this->paths as $path) {
$this->load($path);
}
$this->modulesTableCache = array(); // clear out data no longer needed
}
/**
* Modules class accepts only Module instances, per the WireArray interface
*
* #pw-internal
*
* @param Wire $item
* @return bool
*
*/
public function isValidItem($item) {
return $item instanceof Module;
}
/**
* The key/index used for each module in the array is it's class name, per the WireArray interface
*
* #pw-internal
*
* @param Wire $item
* @return int|string
*
*/
public function getItemKey($item) {
return $this->getModuleClass($item);
}
/**
* There is no blank/generic module type, so makeBlankItem returns null
*
* #pw-internal
*
*/
public function makeBlankItem() {
return null;
}
/**
* Make a new/blank WireArray
*
* #pw-internal
*
*/
public function makeNew() {
// ensures that find(), etc. operations don't initalize a new Modules() class
return $this->wire(new WireArray());
}
/**
* Make a new populated copy of a WireArray containing all the modules
*
* #pw-internal
*
* @return WireArray
*
*/
public function makeCopy() {
// ensures that find(), etc. operations don't initalize a new Modules() class
$copy = $this->makeNew();
foreach($this->data as $key => $value) $copy[$key] = $value;
$copy->resetTrackChanges($this->trackChanges());
return $copy;
}
/**
* Initialize all the modules that are loaded at boot
*
* #pw-internal
*
* @param null|array|Modules $modules
* @param array $completed
* @param int $level
*
*/
public function triggerInit($modules = null, $completed = array(), $level = 0) {
$debugKey = null;
$debugKey2 = null;
if($this->debug) {
$debugKey = $this->debugTimerStart("triggerInit$level");
$this->message("triggerInit(level=$level)");
}
$queue = array();
if(is_null($modules)) $modules = $this;
foreach($modules as $class => $module) {
if($module instanceof ModulePlaceholder) {
// skip modules that aren't autoload and those that are conditional autoload
if(!$module->autoload) continue;
if(isset($this->conditionalAutoloadModules[$class])) continue;
}
if($this->debug) $debugKey2 = $this->debugTimerStart("triggerInit$level($class)");
$info = $this->getModuleInfo($module);
$skip = false;
// module requires other modules
foreach($info['requires'] as $requiresClass) {
if(in_array($requiresClass, $completed)) continue;
$dependencyInfo = $this->getModuleInfo($requiresClass);
if(empty($dependencyInfo['autoload'])) {
// if dependency isn't an autoload one, there's no point in waiting for it
if($this->debug) $this->warning("Autoload module '$module' requires a non-autoload module '$requiresClass'");
continue;
} else if(isset($this->conditionalAutoloadModules[$requiresClass])) {
// autoload module requires another autoload module that may or may not load
if($this->debug) $this->warning("Autoload module '$module' requires a conditionally autoloaded module '$requiresClass'");
continue;
}
// dependency is autoload and required by this module, so queue this module to init later
$queue[$class] = $module;
$skip = true;
break;
}
if(!$skip) {
if($info['autoload'] !== false) {
if($info['autoload'] === true || $this->isAutoload($module)) {
$this->initModule($module);
}
}
$completed[] = $class;
}
if($this->debug) $this->debugTimerStop($debugKey2);
}
// if there is a dependency queue, go recursive till the queue is completed
if(count($queue) && $level < 3) {
$this->triggerInit($queue, $completed, $level + 1);
}
$this->initialized = true;
if($this->debug) if($debugKey) $this->debugTimerStop($debugKey);
if(!$level && (empty($this->moduleInfoCache))) { // || empty($this->moduleInfoCacheVerbose))) {
if($this->debug) $this->message("saveModuleInfoCache from triggerInit");
$this->saveModuleInfoCache();
}
}
/**
* Given a class name, return the constructed module
*
* @param string $className Module class name
* @return Module|null
*
*/
protected function newModule($className) {
$moduleName = wireClassName($className, false);
$className = wireClassName($className, true);
$debugKey = $this->debug ? $this->debugTimerStart("newModule($moduleName)") : null;
if(!class_exists($className, false)) $this->includeModule($moduleName);
if(!class_exists($className, false)) {
// attempt 2.x module in dedicated namespace or root namespace
$className = $this->getModuleNamespace($moduleName) . $moduleName;
}
if(ProcessWire::getNumInstances() > 1) {
// in a multi-instance environment, ensures that anything happening during
// the module __construct is using the right instance. necessary because the
// construct method runs before the wire instance is set to the module
$wire1 = ProcessWire::getCurrentInstance();
$wire2 = $this->wire();
if($wire1 !== $wire2) {
ProcessWire::setCurrentInstance($wire2);
} else {
$wire1 = null;
}
} else {
$wire1 = null;
$wire2 = null;
}
try {
$module = $this->wire(new $className());
} catch(\Exception $e) {
$this->error(sprintf($this->_('Failed to construct module: %s'), $className) . " - " . $e->getMessage());
$module = null;
}
if($this->debug) $this->debugTimerStop($debugKey);
if($wire1) ProcessWire::setCurrentInstance($wire1);
return $module;
}
/**
* Return a new ModulePlaceholder for the given className
*
* @param string $className Module class this placeholder will stand in for
* @param string $ns Module namespace
* @param string $file Full path and filename of $className
* @param bool $singular Is the module a singular module?
* @param bool $autoload Is the module an autoload module?
* @return ModulePlaceholder
*
*/
protected function newModulePlaceholder($className, $ns, $file, $singular, $autoload) {
$module = $this->wire(new ModulePlaceholder());
$module->setClass($className);
$module->setNamespace($ns);
$module->singular = $singular;
$module->autoload = $autoload;
$module->file = $file;
return $module;
}
/**
* Initialize a single module
*
* @param Module $module
* @param array $options
* - `clearSettings` (bool): When true, module settings will be cleared when appropriate to save space. (default=true)
* - `throw` (bool): When true, exceptions will be allowed to pass through. (default=false)
* @return bool True on success, false on fail
* @throws \Exception Only if the `throw` option is true.
*
*/
protected function initModule(Module $module, array $options = array()) {
$result = true;
$debugKey = null;
$clearSettings = isset($options['clearSettings']) ? (bool) $options['clearSettings'] : true;
$throw = isset($options['throw']) ? (bool) $options['throw'] : false;
if($this->debug) {
static $n = 0;
$this->message("initModule (" . (++$n) . "): " . wireClassName($module));
}
// if the module is configurable, then load its config data
// and set values for each before initializing the module
$this->setModuleConfigData($module);
$moduleName = wireClassName($module, false);
$moduleID = isset($this->moduleIDs[$moduleName]) ? $this->moduleIDs[$moduleName] : 0;
if($moduleID && isset($this->modulesLastVersions[$moduleID])) {
$this->checkModuleVersion($module);
}
if(method_exists($module, 'init')) {
if($this->debug) {
$debugKey = $this->debugTimerStart("initModule($moduleName)");
}
try {
$module->init();
} catch(\Exception $e) {
if($throw) throw($e);
$this->error(sprintf($this->_('Failed to init module: %s'), $moduleName) . " - " . $e->getMessage());
$result = false;
}
if($this->debug) {
$this->debugTimerStop($debugKey);
}
}
// if module is autoload (assumed here) and singular, then
// we no longer need the module's config data, so remove it
if($clearSettings && $this->isSingular($module)) {
if(!$moduleID) $moduleID = $this->getModuleID($module);
if(isset($this->configData[$moduleID])) $this->configData[$moduleID] = 1;
}
return $result;
}
/**
* Call ready for a single module
*
* @param Module $module
* @return bool
*
*/
protected function readyModule(Module $module) {
$result = true;
if(method_exists($module, 'ready')) {
$debugKey = $this->debug ? $this->debugTimerStart("readyModule(" . $module->className() . ")") : null;
try {
$module->ready();
} catch(\Exception $e) {
$this->error(sprintf($this->_('Failed to ready module: %s'), $module->className()) . " - " . $e->getMessage());
$result = false;
}
if($this->debug) {
$this->debugTimerStop($debugKey);
static $n = 0;
$this->message("readyModule (" . (++$n) . "): " . wireClassName($module));
}
}
return $result;
}
/**
* Init conditional autoload modules, if conditions allow
*
* @return array of skipped module names
*
*/
protected function triggerConditionalAutoload() {
// conditional autoload modules that are skipped (className => 1)
$skipped = array();
// init conditional autoload modules, now that $page is known
foreach($this->conditionalAutoloadModules as $className => $func) {
if($this->debug) {
$moduleID = $this->getModuleID($className);
$flags = $this->moduleFlags[$moduleID];
$this->message("Conditional autoload: $className (flags=$flags, condition=" . (is_string($func) ? $func : 'func') . ")");
}
$load = true;
if(is_string($func)) {
// selector string
if(!$this->wire('page')->is($func)) $load = false;
} else {
// anonymous function
if(!is_callable($func)) $load = false;
else if(!$func()) $load = false;
}
if($load) {
$module = $this->newModule($className);
if($module) {
$this->set($className, $module);
if($this->initModule($module)) {
if($this->debug) $this->message("Conditional autoload: $className LOADED");
} else {
if($this->debug) $this->warning("Failed conditional autoload: $className");
}
}
} else {
$skipped[$className] = $className;
if($this->debug) $this->message("Conditional autoload: $className SKIPPED");
}
}
// clear this out since we don't need it anymore
$this->conditionalAutoloadModules = array();
return $skipped;
}
/**
* Trigger all modules 'ready' method, if they have it.
*
* This is to indicate to them that the API environment is fully ready and $page is in fuel.
*
* This is triggered by ProcessPageView::ready
*
* #pw-internal
*
*/
public function triggerReady() {
$debugKey = $this->debug ? $this->debugTimerStart("triggerReady") : null;
$skipped = $this->triggerConditionalAutoload();
// trigger ready method on all applicable modules
foreach($this as $module) {
/** @var Module $module */
if($module instanceof ModulePlaceholder) continue;
// $info = $this->getModuleInfo($module);
// if($info['autoload'] === false) continue;
// if(!$this->isAutoload($module)) continue;
$class = $this->getModuleClass($module);
if(isset($skipped[$class])) continue;
$id = $this->moduleIDs[$class];
if(!($this->moduleFlags[$id] & self::flagsAutoload)) continue;
if(!method_exists($module, 'ready')) continue;
$this->readyModule($module);
}
if($this->debug) $this->debugTimerStop($debugKey);
}
/**
* Retrieve the installed module info as stored in the database
*
*/
protected function loadModulesTable() {
$database = $this->wire('database');
// we use SELECT * so that this select won't be broken by future DB schema additions
// Currently: id, class, flags, data, with created added at sysupdate 7
$query = $database->prepare("SELECT * FROM modules ORDER BY class", "modules.loadModulesTable()"); // QA
$query->execute();
/** @noinspection PhpAssignmentInConditionInspection */
while($row = $query->fetch(\PDO::FETCH_ASSOC)) {
$moduleID = (int) $row['id'];
$flags = (int) $row['flags'];
$class = $row['class'];
$this->moduleIDs[$class] = $moduleID;
$this->moduleFlags[$moduleID] = $flags;
$loadSettings = ($flags & self::flagsAutoload) || ($flags & self::flagsDuplicate) || ($class == 'SystemUpdater');
if($loadSettings) {
// preload config data for autoload modules since we'll need it again very soon
$data = strlen($row['data']) ? wireDecodeJSON($row['data']) : array();
$this->configData[$moduleID] = $data;
// populate information about duplicates, if applicable
if($flags & self::flagsDuplicate) $this->duplicates()->addFromConfigData($class, $data);
} else if(!empty($row['data'])) {
// indicate that it has config data, but not yet loaded
$this->configData[$moduleID] = 1;
}
if(isset($row['created']) && $row['created'] != '0000-00-00 00:00:00') {
$this->createdDates[$moduleID] = $row['created'];
}
unset($row['data']); // info we don't want stored in modulesTableCache
$this->modulesTableCache[$class] = $row;
}
$query->closeCursor();
}
/**
* Given a disk path to the modules, determine all installed modules and keep track of all uninstalled (installable) modules.
*
* @param string $path
*
*/
protected function load($path) {
$debugKey = $this->debug ? $this->debugTimerStart("load($path)") : null;
$installed =& $this->modulesTableCache;
$modulesLoaded = array();
$modulesDelayed = array();
$modulesRequired = array();
$rootPath = $this->wire('config')->paths->root;
$basePath = substr($path, strlen($rootPath));
foreach($this->findModuleFiles($path, true) as $pathname) {
$pathname = trim($pathname);
if(empty($pathname)) continue;
$basename = basename($pathname);
list($moduleName, $ext) = explode('.', $basename, 2); // i.e. "module.php" or "module"
$this->moduleFileExts[$moduleName] = $ext === 'module' ? 1 : 2;
// @todo next, remove the 'file' property from verbose module info since it is redundant
$requires = array();
$name = $moduleName;
$moduleName = $this->loadModule($path, $pathname, $requires, $installed);
if(!$this->wire('config')->paths->get($name)) $this->setConfigPaths($name, dirname($basePath . $pathname));
if(!$moduleName) continue;
if(count($requires)) {
// module not loaded because it required other module(s) not yet loaded
foreach($requires as $requiresModuleName) {
if(!isset($modulesRequired[$requiresModuleName])) $modulesRequired[$requiresModuleName] = array();
if(!isset($modulesDelayed[$moduleName])) $modulesDelayed[$moduleName] = array();
// queue module for later load
$modulesRequired[$requiresModuleName][$moduleName] = $pathname;
$modulesDelayed[$moduleName][] = $requiresModuleName;
}
continue;
}
// module was successfully loaded
$modulesLoaded[$moduleName] = 1;
$loadedNames = array($moduleName);
// now determine if this module had any other modules waiting on it as a dependency
/** @noinspection PhpAssignmentInConditionInspection */
while($moduleName = array_shift($loadedNames)) {
// iternate through delayed modules that require this one
if(empty($modulesRequired[$moduleName])) continue;
foreach($modulesRequired[$moduleName] as $delayedName => $delayedPathName) {
$loadNow = true;
if(isset($modulesDelayed[$delayedName])) {
foreach($modulesDelayed[$delayedName] as $requiresModuleName) {
if(!isset($modulesLoaded[$requiresModuleName])) {
$loadNow = false;
}
}
}
if(!$loadNow) continue;
// all conditions satisified to load delayed module
unset($modulesDelayed[$delayedName], $modulesRequired[$moduleName][$delayedName]);
$unused = array();
$loadedName = $this->loadModule($path, $delayedPathName, $unused, $installed);
if(!$loadedName) continue;
$modulesLoaded[$loadedName] = 1;
$loadedNames[] = $loadedName;
}
}
}
if(count($modulesDelayed)) {
foreach($modulesDelayed as $moduleName => $requiredNames) {
$this->error("Module '$moduleName' dependency not fulfilled for: " . implode(', ', $requiredNames), Notice::debug);
}
}
if($this->debug) $this->debugTimerStop($debugKey);
}
/**
* Load a module into memory (companion to load bootstrap method)
*
* @param string $basepath Base path of modules being processed (path provided to the load method)
* @param string $pathname
* @param array $requires This method will populate this array with required dependencies (class names) if present.
* @param array $installed Array of installed modules info, indexed by module class name
* @return string Returns module name (classname)
*
*/
protected function loadModule($basepath, $pathname, array &$requires, array &$installed) {
$pathname = $basepath . $pathname;
$dirname = dirname($pathname);
$filename = basename($pathname);
$basename = basename($filename, '.php');
$basename = basename($basename, '.module');
$requires = array();
$duplicates = $this->duplicates();
$moduleInfo = null;
// check if module has duplicate files, where one to use has already been specified to use first
$currentFile = $duplicates->getCurrent($basename); // returns the current file in use, if more than one
if($currentFile) {
// there is a duplicate file in use
$file = rtrim($this->wire('config')->paths->root, '/') . $currentFile;
if(file_exists($file) && $pathname != $file) {
// file in use is different from the file we are looking at
// check if this is a new/yet unknown duplicate
if(!$duplicates->hasDuplicate($basename, $pathname)) {
// new duplicate
$duplicates->recordDuplicate($basename, $pathname, $file, $installed);
}
return '';
}
}
// check if module has already been loaded, or maybe we've got duplicates
if(wireClassExists($basename, false)) {
$module = parent::get($basename);
$dir = rtrim($this->wire('config')->paths->$basename, '/');
if($module && $dir && $dirname != $dir) {
$duplicates->recordDuplicate($basename, $pathname, "$dir/$filename", $installed);
return '';
}
if($module) return $basename;
}
// if the filename doesn't end with .module or .module.php, then stop and move onto the next
if(!strpos($filename, '.module') || (substr($filename, -7) !== '.module' && substr($filename, -11) !== '.module.php')) return false;
// if the filename doesn't start with the requested path, then continue
if(strpos($pathname, $basepath) !== 0) return '';
// if the file isn't there, it was probably uninstalled, so ignore it
if(!file_exists($pathname)) return '';
// if the module isn't installed, then stop and move on to next
if(!array_key_exists($basename, $installed)) {
$this->installable[$basename] = $pathname;
return '';
}
$info = $installed[$basename];
$this->setConfigPaths($basename, $dirname);
$module = null;
$autoload = false;
if($info['flags'] & self::flagsAutoload) {
// this is an Autoload module.
// include the module and instantiate it but don't init() it,
// because it will be done by Modules::init()
$moduleInfo = $this->getModuleInfo($basename);
// determine if module has dependencies that are not yet met
if(count($moduleInfo['requires'])) {
foreach($moduleInfo['requires'] as $requiresClass) {
$nsRequiresClass = $this->getModuleClass($requiresClass, true);
if(!wireClassExists($nsRequiresClass, false)) {
$requiresInfo = $this->getModuleInfo($requiresClass);
if(!empty($requiresInfo['error'])
|| $requiresInfo['autoload'] === true
|| !$this->isInstalled($requiresClass)) {
// we only handle autoload===true since load() only instantiates other autoload===true modules
$requires[] = $requiresClass;
}
}
}
if(count($requires)) {
// module has unmet requirements
return $basename;
}
}
// if not defined in getModuleInfo, then we'll accept the database flag as enough proof
// since the module may have defined it via an isAutoload() function
if(!isset($moduleInfo['autoload'])) $moduleInfo['autoload'] = true;
/** @var bool|string|callable $autoload */
$autoload = $moduleInfo['autoload'];
if($autoload === 'function') {
// function is stored by the moduleInfo cache to indicate we need to call a dynamic function specified with the module itself
$i = $this->getModuleInfoExternal($basename);
if(empty($i)) {
$this->includeModuleFile($pathname, $basename);
$className = $moduleInfo['namespace'] . $basename;
if(method_exists($className, 'getModuleInfo')) {
$i = $className::getModuleInfo();
} else {
$i = array();
}
}
$autoload = isset($i['autoload']) ? $i['autoload'] : true;
unset($i);
}
// check for conditional autoload
if(!is_bool($autoload) && (is_string($autoload) || is_callable($autoload)) && !($info['flags'] & self::flagsDisabled)) {
// anonymous function or selector string
$this->conditionalAutoloadModules[$basename] = $autoload;
$this->moduleIDs[$basename] = $info['id'];
$autoload = true;
} else if($autoload) {
$this->includeModuleFile($pathname, $basename);
if(!($info['flags'] & self::flagsDisabled)) {
$module = $this->newModule($basename);
}
}
}
if(is_null($module)) {
// placeholder for a module, which is not yet included and instantiated
if(!$moduleInfo) $moduleInfo = $this->getModuleInfo($basename);
$module = $this->newModulePlaceholder($basename, $moduleInfo['namespace'], $pathname, $info['flags'] & self::flagsSingular, $autoload);
}
$this->moduleIDs[$basename] = $info['id'];
$this->set($basename, $module);
return $basename;
}
/**
* Find new module files in the given $path
*
* If $readCache is true, this will perform the find from the cache
*
* @param string $path Path to the modules
* @param bool $readCache Optional. If set to true, then this method will attempt to read modules from the cache.
* @param int $level For internal recursive use.
* @return array Array of module files
*
*/
protected function findModuleFiles($path, $readCache = false, $level = 0) {
static $startPath;
static $callNum = 0;
$callNum++;
$config = $this->wire('config');
$cache = $this->wire('cache');
$cacheName = '';
if($level == 0) {
$startPath = $path;
$cacheName = "Modules." . str_replace($config->paths->root, '', $path);
if($readCache && $cache) {
$cacheContents = $cache->get($cacheName);
if($cacheContents !== null) {
if(empty($cacheContents) && $callNum === 1) {
// don't accept empty cache for first path (/wire/modules/)
} else {
$cacheContents = explode("\n", trim($cacheContents));
return $cacheContents;
}
}
}
}
$files = array();
try {
$dir = new \DirectoryIterator($path);
} catch(\Exception $e) {
$this->trackException($e, false, true);
$dir = null;
}
if($dir) foreach($dir as $file) {
if($file->isDot()) continue;
$filename = $file->getFilename();
$pathname = $file->getPathname();
if(DIRECTORY_SEPARATOR != '/') {
$pathname = str_replace(DIRECTORY_SEPARATOR, '/', $pathname);
$filename = str_replace(DIRECTORY_SEPARATOR, '/', $filename);
}
if(strpos($pathname, '/.') !== false) {
$pos = strrpos(rtrim($pathname, '/'), '/');
if($pathname[$pos+1] == '.') continue; // skip hidden files and dirs
}
// if it's a directory with a .module file in it named the same as the dir, then descend into it
if($file->isDir() && ($level < 1 || (is_file("$pathname/$filename.module") || is_file("$pathname/$filename.module.php")))) {
$files = array_merge($files, $this->findModuleFiles($pathname, false, $level + 1));
}
// if the filename doesn't end with .module or .module.php, then stop and move onto the next
if(!strpos($filename, '.module')) continue;
if(substr($filename, -7) !== '.module' && substr($filename, -11) !== '.module.php') {
continue;
}
$files[] = str_replace($startPath, '', $pathname);
}
if($level == 0 && $dir !== null) {
if($cache && $cacheName) $cache->save($cacheName, implode("\n", $files), WireCache::expireNever);
}
return $files;
}
/**
* Setup entries in config->urls and config->paths for the given module
*
* @param string $moduleName
* @param string $path
*
*/
protected function setConfigPaths($moduleName, $path) {
$config = $this->wire('config');
$rootPath = $config->paths->root;
if(strpos($path, $rootPath) === 0) {
// if root path included, strip it out
$path = substr($path, strlen($config->paths->root));
}
$path = rtrim($path, '/') . '/';
$config->paths->set($moduleName, $path);
$config->urls->set($moduleName, $path);
}
/**
* Get the requested Module
*
* - If the module is not installed, but is installable, it will be installed, instantiated, and initialized.
* If you don't want that behavior, call `$modules->isInstalled('ModuleName')` as a conditional first.
* - You can also get/load a module by accessing it directly, like `$modules->ModuleName`.
* - To get a module with additional options, use `$modules->getModule($name, $options)` instead.
*
* ~~~~~
* // Get the MarkupAdminDataTable module
* $table = $modules->get('MarkupAdminDataTable');
*
* // You can also do this
* $table = $modules->MarkupAdminDataTable;
* ~~~~~
*
* @param string|int $key Module name (also accepts database ID)
* @return Module|_Module|null Returns a Module or null if not found
* @throws WirePermissionException If module requires a particular permission the user does not have
* @see Modules::getModule(), Modules::isInstalled()
*
*/
public function get($key) {
// If the module is a ModulePlaceholder, then it will be converted to the real module (included, instantiated, initialized).
return $this->getModule($key);
}
/**
* Attempt to find a substitute for moduleName and return module if found or null if not
*
* @param $moduleName
* @param array $options See getModule() options
* @return Module|null
*
*/
protected function getSubstituteModule($moduleName, array $options = array()) {
$module = null;
$options['noSubstitute'] = true; // prevent recursion
while(isset($this->substitutes[$moduleName]) && !$module) {
$substituteName = $this->substitutes[$moduleName];
$module = $this->getModule($substituteName, $options);
if(!$module) $moduleName = $substituteName;
}
return $module;
}
/**
* Get the requested Module (with options)
*
* This is the same as `$modules->get()` except that you can specify additional options to modify default behavior.
* These are the options you can specify in the `$options` array argument:
*
* - `noPermissionCheck` (bool): Specify true to disable module permission checks (and resulting exception). (default=false)
* - `noInstall` (bool): Specify true to prevent a non-installed module from installing from this request. (default=false)
* - `noInit` (bool): Specify true to prevent the module from being initialized. (default=false)
* - `noSubstitute` (bool): Specify true to prevent inclusion of a substitute module. (default=false)
* - `noCache` (bool): Specify true to prevent module instance from being cached for later getModule() calls. (default=false)
* - `noThrow` (bool): Specify true to prevent exceptions from being thrown on permission or fatal error. (default=false)
* - `returnError` (bool): Return an error message (string) on error, rather than null. (default=false)
*
* If the module is not installed, but is installable, it will be installed, instantiated, and initialized.
* If you don't want that behavior, call `$modules->isInstalled('ModuleName')` as a condition first, OR specify
* true for the `noInstall` option in the `$options` argument.
*
* @param string|int $key Module name or database ID.
* @param array $options Optional settings to change load behavior, see method description for details.
* @return Module|_Module|null|string Returns ready-to-use module or NULL|string if not found (string if `returnError` option used).
* @throws WirePermissionException|\Exception If module requires a particular permission the user does not have
* @see Modules::get()
*
*/
public function getModule($key, array $options = array()) {
$module = null;
$needsInit = false;
$error = '';
if(empty($key)) {
return empty($options['returnError']) ? null : "No module specified";
}
// check for optional module ID and convert to classname if found
if(ctype_digit("$key")) {
$moduleID = (int) $key;
if(!$key = array_search($key, $this->moduleIDs)) {
return empty($options['returnError']) ? null : "Unable to find module ID $moduleID";
}
} else {
$key = wireClassName($key, false);
}
$module = parent::get($key);
if(!$module) {
if(empty($options['noSubstitute'])) {
if($this->isInstallable($key) && empty($options['noInstall'])) {
// module is on file system and may be installed, no need to substitute
} else {
$module = $this->getSubstituteModule($key, $options);
if($module) return $module; // returned module is ready to use
}
} else {
$error = "Module '$key' not found and substitute not allowed (noSubstitute=true)";
}
}
if($module) {
// check if it's a placeholder, and if it is then include/instantiate/init the real module
// OR check if it's non-singular, so that a new instance is created
if($module instanceof ModulePlaceholder || !$this->isSingular($module)) {
$placeholder = $module;
$class = $this->getModuleClass($placeholder);
try {
if($module instanceof ModulePlaceholder) $this->includeModule($module);
$module = $this->newModule($class);
} catch(\Exception $e) {
if(empty($options['noThrow'])) throw $e;
return empty($options['returnError']) ? null : "Module '$key' - " . $e->getMessage();
}
// if singular, save the instance so it can be used in later calls
if($module && $this->isSingular($module) && empty($options['noCache'])) $this->set($key, $module);
$needsInit = true;
}
} else if(empty($options['noInstall'])) {
// module was not available to get, see if we can install it
if(array_key_exists($key, $this->getInstallable())) {
// check if the request is for an uninstalled module
// if so, install it and return it
try {
$module = $this->install($key);
} catch(\Exception $e) {
if(empty($options['noThrow'])) throw $e;
if(!empty($options['returnError'])) return "Module '$key' install failed: " . $e->getMessage();
}
$needsInit = true;
if(!$module) $error = "Module '$key' not installed and install failed";
} else {
$error = "Module '$key' is not present or listed as installable";
}
} else {
$error = "Module '$key' is not present and not installable (noInstall=true)";
}
if(!$module) {
if(!$error) $error = "Unable to get module '$key'";
return empty($options['returnError']) ? null : $error;
}
if(empty($options['noPermissionCheck'])) {
// check that user has permission required to use module
if(!$this->hasPermission($module, $this->wire('user'), $this->wire('page'))) {
$error = $this->_('You do not have permission to execute this module') . ' - ' . wireClassName($module);
if(empty($options['noThrow'])) throw new WirePermissionException($error);
return empty($options['returnError']) ? null : $error;
}
}
// skip autoload modules because they have already been initialized in the load() method
// unless they were just installed, in which case we need do init now
if($needsInit && empty($options['noInit'])) {
// if the module is configurable, then load it's config data
// and set values for each before initializing the module
try {
if(!$this->initModule($module, array('clearSettings' => false, 'throw' => true))) {
return empty($options['returnError']) ? null : "Module '$module' failed init";
$module = null;
}
} catch(\Exception $e) {
if(empty($options['noThrow'])) throw $e;
return empty($options['returnError']) ? null : "Module '$module' throw Exception on init - " . $e->getMessage();
}
}
return $module;
}
/**
* Check if user has permission for given module
*
* #pw-internal
*
* @param string|object $moduleName Module instance or module name
* @param User $user Optionally specify different user to consider than current.
* @param Page $page Optionally specify different page to consider than current.
* @param bool $strict If module specifies no permission settings, assume no permission.
* - Default (false) is to assume permission when module doesn't say anything about it.
* - Process modules (for instance) generally assume no permission when it isn't specifically defined
* (though this method doesn't get involved in that, leaving you to specify $strict instead).
*
* @return bool
*
*/
public function hasPermission($moduleName, User $user = null, Page $page = null, $strict = false) {
if(is_object($moduleName)) {
$module = $moduleName;
$className = $module->className(true);
$moduleName = $module->className(false);
} else {
$module = null;
// $className = wireClassName($moduleName, true);
$className = $this->getModuleClass($moduleName, true); // ???
$moduleName = wireClassName($moduleName, false);
}
$info = $this->getModuleInfo($module ? $module : $moduleName);
if(empty($info['permission']) && empty($info['permissionMethod'])) return $strict ? false : true;
if(is_null($user)) $user = $this->wire('user');
if($user && $user->isSuperuser()) return true;
if(!empty($info['permission'])) {
if(!$user->hasPermission($info['permission'])) return false;
}
if(!empty($info['permissionMethod'])) {
// module specifies a static method to call for permission
if(is_null($page)) $page = $this->wire('page');
$data = array(
'wire' => $this->wire(),
'page' => $page,
'user' => $user,
'info' => $info,
);
$method = $info['permissionMethod'];
$this->includeModule($moduleName);
return $className::$method($data);
}
return true;
}
/**
* Get the requested module and reset cache + install it if necessary.
*
* This is exactly the same as get() except that this one will rebuild the modules cache if
* it doesn't find the module at first. If the module is on the file system, this
* one will return it in some instances that a regular get() can't.
*
* #pw-internal
*
* @param string|int $key Module className or database ID
* @return Module|null
*
*/
public function getInstall($key) {
$module = $this->get($key);
if(!$module) {
$this->refresh();
$module = $this->getModule($key);
}
return $module;
}
/**
* Include the file for a given module, but don't instantiate it
*
* #pw-internal
*
* @param ModulePlaceholder|Module|string Expects a ModulePlaceholder or className
* @param string $file Optionally specify the module filename if you already know it
* @return bool true on success, false on fail or unknown
*
*/
public function includeModule($module, $file = '') {
$className = '';
if(is_string($module)) {
$className = $module;
} else if(is_object($module)) {
if($module instanceof ModulePlaceholder) {
$className = $module->className();
} else if($module instanceof Module) {
return true; // already included
}
} else {
$className = $this->getModuleClass($module);
}
if(!$className) return false;
if(class_exists($className, false)) {
// already included
return true;
}
// determine if namespace was requested with module
$namespace = wireClassName($className, 1);
// moduleName is className without namespace
$moduleName = $namespace === null ? $className : wireClassName($className, false);
// attempt to retrieve module
$module = parent::get($moduleName);
if($module) {
// module found, check to make sure it actually points to a module
if(!$module instanceof Module) $module = false;
} else if($moduleName) {
// unable to retrieve module, may be an uninstalled module
if(!$file) {
$file = $this->getModuleFile($moduleName, array('fast' => true));
if(!$file) $file = $this->getModuleFile($moduleName, array('fast' => false));
}
if($file) {
$this->includeModuleFile($file, $moduleName);
// now check to see if included file resulted in presence of module class
if(class_exists($className)) {
$module = true;
} else {
if(!$namespace) $namespace = $this->getModuleNamespace($moduleName, array('file' => $file));
$nsClassName = trim($namespace, "\\") . "\\$moduleName";
if(class_exists($nsClassName, false)) {
// successful include module
$module = true;
}
}
}
}
if($module === true) {
// great
return true;
} else if(!$module) {
return false;
} else if($module instanceof ModulePlaceholder) {
$this->includeModuleFile($module->file, $moduleName);
return true;
} else if($module instanceof Module) {
// it's already been included, since we have a real module
return true;
} else {
return false;
}
}
/**
* Include the given filename
*
* @param string $file
* @param string $moduleName
*
*/
protected function includeModuleFile($file, $moduleName) {
$wire1 = ProcessWire::getCurrentInstance();
$wire2 = $this->wire();
// check if there is more than one PW instance active
if($wire1 !== $wire2) {
// multi-instance is active, don't autoload module if class already exists
// first do a fast check, which should catch any core modules
if(class_exists(__NAMESPACE__ . "\\$moduleName", false)) return;
// next do a slower check, figuring out namespace
$ns = $this->getModuleNamespace($moduleName, array('file' => $file));
$className = trim($ns, "\\") . "\\$moduleName";
if(class_exists($className, false)) return;
// if this point is reached, module is not yet in memory in either instance
// temporarily set the $wire instance to 2nd instance during include()
ProcessWire::setCurrentInstance($wire2);
}
// get compiled version (if it needs compilation)
$file = $this->compile($moduleName, $file);
if($file) {
/** @noinspection PhpIncludeInspection */
include_once($file);
}
// set instance back, if multi-instance
if($wire1 !== $wire2) ProcessWire::setCurrentInstance($wire1);
}
/**
* Find modules based on a selector string
*
* #pw-internal Almost always recommend using findByPrefix() instead
*
* @param string $selector Selector string
* @return Modules WireArray of found modules, instantiated and ready-to-use
*
*/
public function find($selector) {
// ensures any ModulePlaceholders are loaded in the returned result.
$a = parent::find($selector);
if($a) {
foreach($a as $key => $value) {
$a[$key] = $this->get($value->className());
}
}
return $a;
}
/**
* Find modules matching the given prefix (i.e. “Inputfield”)
*
* By default this method returns module class names matching the given prefix.
* To instead retrieve instantiated (ready-to-use) modules, specify boolean true
* for the second argument.
*
* ~~~~~
* // Retrieve array of all Textformatter module names
* $items = $modules->findByPrefix('Textformatter');
*
* // Retrieve array of all Textformatter modules (ready to use)
* $items = $modules->findByPrefix('Textformatter', true);
* ~~~~~
*
* @param string $prefix Specify prefix, i.e. "Process", "Fieldtype", "Inputfield", etc.
* @param bool $instantiate Specify true to return Module instances, or false to return class names (default=false)
* @return array Returns array of module class names or Module objects. In either case, array indexes are class names.
*
*/
public function findByPrefix($prefix, $instantiate = false) {
$results = array();
foreach($this as $key => $value) {
$className = wireClassName($value->className(), false);
if(strpos($className, $prefix) !== 0) continue;
if($instantiate) {
$results[$className] = $this->get($className);
} else {
$results[$className] = $className;
}
}
return $results;
}
/**
* Get an associative array [name => path] for all modules that aren’t currently installed.
*
* #pw-internal
*
* @return array Array of elements with $moduleName => $pathName
*
*/
public function getInstallable() {
return $this->installable;
}
/**
* Is the given module name installed?
*
* @param string $class Just a module class name, or optionally: `ModuleClassName>=1.2.3` (operator and version)
* @return bool True if installed, false if not
*
*/
public function isInstalled($class) {
if(is_object($class)) $class = $this->getModuleClass($class);
$operator = null;
$requiredVersion = null;
$currentVersion = null;
if(!ctype_alnum($class)) {
// class has something other than just a classname, likely operator + version
if(preg_match('/^([a-zA-Z0-9_]+)\s*([<>=!]+)\s*([\d.]+)$/', $class, $matches)) {
$class = $matches[1];
$operator = $matches[2];
$requiredVersion = $matches[3];
}
}
if($class === 'PHP' || $class === 'ProcessWire') {
$installed = true;
if(!is_null($requiredVersion)) {
$currentVersion = $class === 'PHP' ? PHP_VERSION : $this->wire('config')->version;
}
} else {
$installed = parent::get($class) !== null;
if($installed && !is_null($requiredVersion)) {
$info = $this->getModuleInfo($class);
$currentVersion = $info['version'];
}
}
if($installed && !is_null($currentVersion)) {
$installed = $this->versionCompare($currentVersion, $requiredVersion, $operator);
}
return $installed;
}
/**
* Is the given module name installable? (i.e. not already installed)
*
* #pw-internal
*
* @param string $class Module class name
* @param bool $now Is module installable RIGHT NOW? This makes it check that all dependencies are already fulfilled (default=false)
* @return bool True if module is installable, false if not
*
*/
public function isInstallable($class, $now = false) {
$installable = array_key_exists($class, $this->installable);
if(!$installable) return false;
if($now) {
$requires = $this->getRequiresForInstall($class);
if(count($requires)) return false;
}
return $installable;
}
/**
* Install the given module name
*
* #pw-group-manipulation
*
* @param string $class Module name (class name)
* @param array|bool $options Optional associative array that can contain any of the following:
* - `dependencies` (boolean): When true, dependencies will also be installed where possible. Specify false to prevent installation of uninstalled modules. (default=true)
* - `resetCache` (boolean): When true, module caches will be reset after installation. (default=true)
* - `force` (boolean): Force installation, even if dependencies can't be met.
* @return null|Module Returns null if unable to install, or ready-to-use Module object if successfully installed.
* @throws WireException
*
*/
public function ___install($class, $options = array()) {
$defaults = array(
'dependencies' => true,
'resetCache' => true,
'force' => false,
);
if(is_bool($options)) {
// dependencies argument allowed instead of $options, for backwards compatibility
$dependencies = $options;
$options = array('dependencies' => $dependencies);
}
$options = array_merge($defaults, $options);
$dependencyOptions = $options;
$dependencyOptions['resetCache'] = false;
if(!$this->isInstallable($class)) return null;
$requires = $this->getRequiresForInstall($class);
if(count($requires)) {
$error = '';
$installable = false;
if($options['dependencies']) {
$installable = true;
foreach($requires as $requiresModule) {
if(!$this->isInstallable($requiresModule)) $installable = false;
}
if($installable) {
foreach($requires as $requiresModule) {
if(!$this->install($requiresModule, $dependencyOptions)) {
$error = $this->_('Unable to install required module') . " - $requiresModule. ";
$installable = false;
break;
}
}
}
}
if(!$installable) {
$error = sprintf($this->_('Module %s requires: %s'), $class, implode(', ', $requires)) . ' ' . $error;
if($options['force']) {
$this->warning($this->_('Warning!') . ' ' . $error);
} else {
throw new WireException($error);
}
}
}
$languages = $this->wire('languages');
if($languages) $languages->setDefault();
$pathname = $this->installable[$class];
$this->includeModuleFile($pathname, $class);
$this->setConfigPaths($class, dirname($pathname));
$module = $this->newModule($class);
if(!$module) return null;
$flags = 0;
$database = $this->wire('database');
$moduleID = 0;
if($this->isSingular($module)) $flags = $flags | self::flagsSingular;
if($this->isAutoload($module)) $flags = $flags | self::flagsAutoload;
$sql = "INSERT INTO modules SET class=:class, flags=:flags, data=''";
if($this->wire('config')->systemVersion >=7) $sql .= ", created=NOW()";
$query = $database->prepare($sql, "modules.install($class)");
$query->bindValue(":class", $class, \PDO::PARAM_STR);
$query->bindValue(":flags", $flags, \PDO::PARAM_INT);
try {
if($query->execute()) $moduleID = (int) $database->lastInsertId();
} catch(\Exception $e) {
if($languages) $languages->unsetDefault();
$this->trackException($e, false, true);
return null;
}
$this->moduleIDs[$class] = $moduleID;
$this->add($module);
unset($this->installable[$class]);
// note: the module's install is called here because it may need to know it's module ID for installation of permissions, etc.
if(method_exists($module, '___install') || method_exists($module, 'install')) {
try {
/** @var _Module $module */
$module->install();
} catch(\PDOException $e) {
$error = $this->_('Module reported error during install') . " ($class): " . $e->getMessage();
$this->error($error);
$this->trackException($e, false, $error);
} catch(\Exception $e) {
// remove the module from the modules table if the install failed
$moduleID = (int) $moduleID;
$error = $this->_('Unable to install module') . " ($class): " . $e->getMessage();
$ee = null;
try {
$query = $database->prepare('DELETE FROM modules WHERE id=:id LIMIT 1'); // QA
$query->bindValue(":id", $moduleID, \PDO::PARAM_INT);
$query->execute();
} catch(\Exception $ee) {
$this->trackException($e, false, $error)->trackException($ee, true);
}
if($languages) $languages->unsetDefault();
if(is_null($ee)) $this->trackException($e, false, $error);
return null;
}
}
$info = $this->getModuleInfoVerbose($class, array('noCache' => true));
// if this module has custom permissions defined in its getModuleInfo()['permissions'] array, install them
foreach($info['permissions'] as $name => $title) {
$name = $this->wire('sanitizer')->pageName($name);
if(ctype_digit("$name") || empty($name)) continue; // permission name not valid
$permission = $this->wire('permissions')->get($name);
if($permission->id) continue; // permision already there
try {
$permission = $this->wire('permissions')->add($name);
$permission->title = $title;
$this->wire('permissions')->save($permission);
if($languages) $languages->unsetDefault();
$this->message(sprintf($this->_('Added Permission: %s'), $permission->name));
} catch(\Exception $e) {
if($languages) $languages->unsetDefault();
$error = sprintf($this->_('Error adding permission: %s'), $name);
$this->trackException($e, false, $error);
}
}
// check if there are any modules in 'installs' that this module didn't handle installation of, and install them
$label = $this->_('Module Auto Install');
foreach($info['installs'] as $name) {
if(!$this->isInstalled($name)) {
try {
$this->install($name, $dependencyOptions);
$this->message("$label: $name");
} catch(\Exception $e) {
$error = "$label: $name - " . $e->getMessage();
$this->trackException($e, false, $error);
}
}
}
$this->log("Installed module '$module'");
if($languages) $languages->unsetDefault();
if($options['resetCache']) $this->clearModuleInfoCache();
return $module;
}
/**
* Returns whether the module can be uninstalled
*
* #pw-internal
*
* @param string|Module $class
* @param bool $returnReason If true, the reason why it can't be uninstalled with be returned rather than boolean false.
* @return bool|string
*
*/
public function isUninstallable($class, $returnReason = false) {
$reason = '';
$reason1 = $this->_("Module is not already installed");
$namespace = $this->getModuleNamespace($class);
$class = $this->getModuleClass($class);
if(!$this->isInstalled($class)) {
$reason = $reason1 . ' (a)';
} else {
$this->includeModule($class);
if(!wireClassExists($namespace . $class, false)) {
$reason = $reason1 . " (b: $namespace$class)";
}
}
if(!$reason) {
// if the moduleInfo contains a non-empty 'permanent' property, then it's not uninstallable
$info = $this->getModuleInfo($class);
if(!empty($info['permanent'])) {
$reason = $this->_("Module is permanent");
} else {
$dependents = $this->getRequiresForUninstall($class);
if(count($dependents)) $reason = $this->_("Module is required by other modules that must be removed first");
}
if(!$reason && in_array('Fieldtype', wireClassParents($namespace . $class))) {
foreach($this->wire('fields') as $field) {
$fieldtype = wireClassName($field->type, false);
if($fieldtype == $class) {
$reason = $this->_("This module is a Fieldtype currently in use by one or more fields");
break;
}
}
}
}
if($returnReason && $reason) return $reason;
return $reason ? false : true;
}
/**
* Returns whether the module can be deleted (have it's files physically removed)
*
* #pw-internal
*
* @param string|Module $class
* @param bool $returnReason If true, the reason why it can't be removed will be returned rather than boolean false.
* @return bool|string
*
*/
public function isDeleteable($class, $returnReason = false) {
$reason = '';
$class = $this->getModuleClass($class);
$filename = isset($this->installable[$class]) ? $this->installable[$class] : null;
$dirname = dirname($filename);
if(empty($filename) || $this->isInstalled($class)) {
$reason = "Module must be uninstalled before it can be deleted.";
} else if(is_link($filename) || is_link($dirname) || is_link(dirname($dirname))) {
$reason = "Module is linked to another location";
} else if(!is_file($filename)) {
$reason = "Module file does not exist";
} else if(strpos($filename, $this->paths[0]) === 0) {
$reason = "Core modules may not be deleted.";
} else if(!is_writable($filename)) {
$reason = "We have no write access to the module file, it must be removed manually.";
}
if($returnReason && $reason) return $reason;
return $reason ? false : true;
}
/**
* Delete the given module, physically removing its files
*
* #pw-group-manipulation
*
* @param string $class Module name (class name)
* @return bool|int
* @throws WireException If module can't be deleted, exception will be thrown containing reason.
*
*/
public function ___delete($class) {
$class = $this->getModuleClass($class);
$success = false;
$reason = $this->isDeleteable($class, true);
if($reason !== true) throw new WireException($reason);
$filename = $this->installable[$class];
$basename = basename($filename);
// double check that $class is consistent with the actual $basename
if($basename === "$class.module" || $basename === "$class.module.php") {
// good, this is consistent with the format we require
} else {
throw new WireException("Unrecognized module filename format");
}
// now determine if module is the owner of the directory it exists in
// this is the case if the module class name is the same as the directory name
$path = dirname($filename); // full path to directory, i.e. .../site/modules/ProcessHello
$name = basename($path); // just name of directory that module is, i.e. ProcessHello
$parentPath = dirname($path); // full path to parent directory, i.e. ../site/modules
$backupPath = $parentPath . "/.$name"; // backup path, in case module is backed up
// first check that we are still in the /site/modules/ (or another non core modules path)
$inPath = false; // is module somewhere beneath /site/modules/ ?
$inRoot = false; // is module in /site/modules/ root? i.e. /site/modules/ModuleName.module
foreach($this->paths as $key => $modulesPath) {
if($key === 0) continue; // skip core modules path
if(strpos("$parentPath/", $modulesPath) === 0) $inPath = true;
if($modulesPath === $path) $inRoot = true;
}
$basename = basename($basename, '.php');
$basename = basename($basename, '.module');
$files = array(
"$basename.module",
"$basename.module.php",
"$basename.info.php",
"$basename.info.json",
"$basename.config.php",
"{$basename}Config.php",
);
if($inPath) {
// module is in /site/modules/[ModuleName]/
$numOtherModules = 0; // num modules in dir other than this one
$numLinks = 0; // number of symbolic links
$dirs = array("$path/");
do {
$dir = array_shift($dirs);
$this->message("Scanning: $dir", Notice::debug);
foreach(new \DirectoryIterator($dir) as $file) {
if($file->isDot()) continue;
if($file->isLink()) {
$numLinks++;
continue;
}
if($file->isDir()) {
$dirs[] = $file->getPathname();
continue;
}
if(in_array($file->getBasename(), $files)) continue; // skip known files
if(strpos($file->getBasename(), '.module') && preg_match('{(\.module|\.module\.php)$}', $file->getBasename())) {
// another module exists in this dir, so we don't want to delete that
$numOtherModules++;
}
if(preg_match('{^(' . $basename . '\.[-_.a-zA-Z0-9]+)$}', $file->getBasename(), $matches)) {
// keep track of potentially related files in case we have to delete them individually
$files[] = $matches[1];
}
}
} while(count($dirs));
if(!$inRoot && !$numOtherModules && !$numLinks) {
// the modulePath had no other modules or directories in it, so we can delete it entirely
$success = wireRmdir($path, true);
if($success) {
$this->message("Removed directory: $path", Notice::debug);
if(is_dir($backupPath)) {
if(wireRmdir($backupPath, true)) $this->message("Removed directory: $backupPath", Notice::debug);
}
$files = array();
} else {
$this->error("Failed to remove directory: $path", Notice::debug);
}
}
}
// remove module files individually
foreach($files as $file) {
$file = "$path/$file";
if(!file_exists($file)) continue;
if(unlink($file)) {
$this->message("Removed file: $file", Notice::debug);
} else {
$this->error("Unable to remove file: $file", Notice::debug);
}
}
if($success) $this->log("Deleted module '$class'");
else $this->error("Failed to delete module '$class'");
return $success;
}
/**
* Uninstall the given module name
*
* #pw-group-manipulation
*
* @param string $class Module name (class name)
* @return bool
* @throws WireException
*
*/
public function ___uninstall($class) {
$class = $this->getModuleClass($class);
$reason = $this->isUninstallable($class, true);
if($reason !== true) {
// throw new WireException("$class - Can't Uninstall - $reason");
return false;
}
// check if there are any modules still installed that this one says it is responsible for installing
foreach($this->getUninstalls($class) as $name) {
// catch uninstall exceptions at this point since original module has already been uninstalled
$label = $this->_('Module Auto Uninstall');
try {
$this->uninstall($name);
$this->message("$label: $name");
} catch(\Exception $e) {
$error = "$label: $name - " . $e->getMessage();
$this->trackException($e, false, $error);
}
}
$info = $this->getModuleInfoVerbose($class);
$module = $this->getModule($class, array(
'noPermissionCheck' => true,
'noInstall' => true,
// 'noInit' => true
));
if(!$module) return false;
// remove all hooks attached to this module
$hooks = $module instanceof Wire ? $module->getHooks() : array();
foreach($hooks as $hook) {
if($hook['method'] == 'uninstall') continue;
$this->message("Removed hook $class => " . $hook['options']['fromClass'] . " $hook[method]", Notice::debug);
$module->removeHook($hook['id']);
}
// remove all hooks attached to other ProcessWire objects
$hooks = array_merge($this->getHooks('*'), $this->wire('hooks')->getAllLocalHooks());
foreach($hooks as $hook) {
/** @var Wire $toObject */
$toObject = $hook['toObject'];
$toClass = wireClassName($toObject, false);
$toMethod = $hook['toMethod'];
if($class === $toClass && $toMethod != 'uninstall') {
$toObject->removeHook($hook['id']);
$this->message("Removed hook $class => " . $hook['options']['fromClass'] . " $hook[method]", Notice::debug);
}
}
if(method_exists($module, '___uninstall') || method_exists($module, 'uninstall')) {
// note module's uninstall method may throw an exception to abort the uninstall
/** @var _Module $module */
$module->uninstall();
}
$database = $this->wire('database');
$query = $database->prepare('DELETE FROM modules WHERE class=:class LIMIT 1'); // QA
$query->bindValue(":class", $class, \PDO::PARAM_STR);
$query->execute();
// add back to the installable list
if(class_exists("ReflectionClass")) {
$reflector = new \ReflectionClass($this->getModuleClass($module, true));
$this->installable[$class] = $reflector->getFileName();
}
unset($this->moduleIDs[$class]);
$this->remove($module);
// delete permissions installed by this module
if(isset($info['permissions']) && is_array($info['permissions'])) {
foreach($info['permissions'] as $name => $title) {
$name = $this->wire('sanitizer')->pageName($name);
if(ctype_digit("$name") || empty($name)) continue;
$permission = $this->wire('permissions')->get($name);
if(!$permission->id) continue;
try {
$this->wire('permissions')->delete($permission);
$this->message(sprintf($this->_('Deleted Permission: %s'), $name));
} catch(\Exception $e) {
$error = sprintf($this->_('Error deleting permission: %s'), $name);
$this->trackException($e, false, $error);
}
}
}
$this->log("Uninstalled module '$class'");
$this->refresh();
return true;
}
/**
* Get flags for the given module
*
* #pw-internal
*
* @param int|string|Module $class Module to add flag to
* @return int|false Returns integer flags on success, or boolean false on fail
*
*/
public function getFlags($class) {
$id = ctype_digit("$class") ? (int) $class : $this->getModuleID($class);
if(isset($this->moduleFlags[$id])) return $this->moduleFlags[$id];
if(!$id) return false;
$query = $this->wire('database')->prepare('SELECT flags FROM modules WHERE id=:id');
$query->bindValue(':id', $id, \PDO::PARAM_INT);
$query->execute();
if(!$query->rowCount()) return false;
list($flags) = $query->fetch(\PDO::FETCH_NUM);
$flags = (int) $flags;
$this->moduleFlags[$id] = $flags;
return $flags;
}
/**
* Set module flags
*
* #pw-internal
*
* @param $class
* @param $flags
* @return bool
*
*/
public function setFlags($class, $flags) {
$flags = (int) $flags;
$id = ctype_digit("$class") ? (int) $class : $this->getModuleID($class);
if(!$id) return false;
if($this->moduleFlags[$id] === $flags) return true;
$query = $this->wire('database')->prepare('UPDATE modules SET flags=:flags WHERE id=:id');
$query->bindValue(':flags', $flags);
$query->bindValue(':id', $id);
if($this->debug) $this->message("setFlags(" . $this->getModuleClass($class) . ", " . $this->moduleFlags[$id] . " => $flags)");
$this->moduleFlags[$id] = $flags;
return $query->execute();
}
/**
* Add or remove a flag from a module
*
* #pw-internal
*
* @param $class int|string|Module $class Module to add flag to
* @param $flag int Flag to add (see flags* constants)
* @param $add bool $add Specify true to add the flag or false to remove it
* @return bool True on success, false on fail
*
*/
public function setFlag($class, $flag, $add = true) {
$id = ctype_digit("$class") ? (int) $class : $this->getModuleID($class);
if(!$id) return false;
$flag = (int) $flag;
if(!$flag) return false;
$flags = $this->getFlags($id);
if($add) {
if($flags & $flag) return true; // already has the flag
$flags = $flags | $flag;
} else {
if(!($flags & $flag)) return true; // doesn't already have the flag
$flags = $flags & ~$flag;
}
$this->setFlags($id, $flags);
return true;
}
/**
* Return an array of other module class names that are uninstalled when the given one is
*
* #pw-internal
*
* The opposite of this function is found in the getModuleInfo array property 'installs'.
* Note that 'installs' and uninstalls may be different, as only modules in the 'installs' list
* that indicate 'requires' for the installer module will be uninstalled.
*
* @param $class
* @return array
*
*/
public function getUninstalls($class) {
$uninstalls = array();
$class = $this->getModuleClass($class);
if(!$class) return $uninstalls;
$info = $this->getModuleInfoVerbose($class);
// check if there are any modules still installed that this one says it is responsible for installing
foreach($info['installs'] as $name) {
// if module isn't installed, then great
if(!$this->isInstalled($name)) continue;
// if an 'installs' module doesn't indicate that it requires this one, then leave it installed
$i = $this->getModuleInfo($name);
if(!in_array($class, $i['requires'])) continue;
// add it to the uninstalls array
$uninstalls[] = $name;
}
return $uninstalls;
}
/**
* Returns the database ID of a given module class, or 0 if not found
*
* #pw-internal
*
* @param string|Module $class Module or module name
* @return int
*
*/
public function getModuleID($class) {
$id = 0;
if(is_object($class)) {
if($class instanceof Module) {
$class = $this->getModuleClass($class);
} else {
// Class is not a module
return $id;
}
}
if(isset($this->moduleIDs[$class])) {
$id = (int) $this->moduleIDs[$class];
} else foreach($this->moduleInfoCache as $key => $info) {
if($info['name'] == $class) {
$id = (int) $key;
break;
}
}
return $id;
}
/**
* Returns the module's class name.
*
* - Given a numeric database ID, returns the associated module class name or false if it doesn't exist
* - Given a Module or ModulePlaceholder instance, returns the Module's class name.
*
* If the module has a className() method then it uses that rather than PHP's get_class().
* This is important because of placeholder modules. For example, get_class would return
* 'ModulePlaceholder' rather than the correct className for a Module.
*
* #pw-internal
*
* @param string|int|Module
* @param bool $withNamespace Specify true to include the namespace in the class
* @return string|bool The Module's class name or false if not found.
* Note that 'false' is only possible if you give this method a non-Module, or an integer ID
* that doesn't correspond to a module ID.
*
*/
public function getModuleClass($module, $withNamespace = false) {
$className = '';
$namespace = '';
if($module instanceof Module) {
if(wireMethodExists($module, 'className')) {
if($withNamespace) return $module->className(true);
return $module->className();
} else {
return wireClassName($module, $withNamespace);
}
} else if(is_int($module) || ctype_digit("$module")) {
$className = array_search((int) $module, $this->moduleIDs);
} else if(is_string($module)) {
if(strpos($module, "\\") !== false) {
$namespace = wireClassName($module, 1);
$className = wireClassName($module, false);
}
// remove extensions if they were included in the module name
if(strpos($module, '.') !== false) {
$module = basename(basename($module, '.php'), '.module');
}
if(array_key_exists($module, $this->moduleIDs)) {
$className = $module;
} else if(array_key_exists($module, $this->installable)) {
$className = $module;
}
}
if($className) {
if($withNamespace) {
if($namespace) {
$className = "$namespace\\$className";
} else {
$className = $this->getModuleNamespace($className) . $className;
}
}
return $className;
}
return false;
}
/**
* Retrieve module info from ModuleName.info.json or ModuleName.info.php
*
* @param string $moduleName
* @return array
*
*/
protected function getModuleInfoExternal($moduleName) {
// if($this->debug) $this->message("getModuleInfoExternal($moduleName)");
// ...attempt to load info by info file (Module.info.php or Module.info.json)
if(!empty($this->installable[$moduleName])) {
$path = dirname($this->installable[$moduleName]) . '/';
} else {
$path = $this->wire('config')->paths->$moduleName;
}
if(empty($path)) return array();
// module exists and has a dedicated path on the file system
// we will try to get info from a PHP or JSON info file
$filePHP = $path . "$moduleName.info.php";
$fileJSON = $path . "$moduleName.info.json";
$info = array();
if(file_exists($filePHP)) {
/** @noinspection PhpIncludeInspection */
include($filePHP); // will populate $info automatically
if(!is_array($info) || !count($info)) $this->error("Invalid PHP module info file for $moduleName");
} else if(file_exists($fileJSON)) {
$info = file_get_contents($fileJSON);
$info = json_decode($info, true);
if(!$info) {
$info = array();
$this->error("Invalid JSON module info file for $moduleName");
}
}
return $info;
}
/**
* Retrieve module info from internal getModuleInfo function in the class
*
* @param Module|string $module
* @param string $namespace
* @return array
*
*/
protected function getModuleInfoInternal($module, $namespace = '') {
// if($this->debug) $this->message("getModuleInfoInternal($module)");
$info = array();
if($module instanceof ModulePlaceholder) {
$this->includeModule($module);
$module = $module->className();
}
if($module instanceof Module) {
if(method_exists($module, 'getModuleInfo')) {
$info = $module::getModuleInfo();
}
} else if($module) {
if(empty($namespace)) $namespace = $this->getModuleNamespace($module);
$className = wireClassName($namespace . $module, true);
if(!class_exists($className)) $this->includeModule($module);
if(is_callable("$className::getModuleInfo")) {
$info = call_user_func(array($className, 'getModuleInfo'));
}
}
return $info;
}
/**
* Pull module info directly from the module file's getModuleInfo without letting PHP parse it
*
* Useful for getting module info from modules that extend another module not already on the file system.
*
* @param $className
* @return array Only includes module info specified in the module file itself.
*
*/
protected function getModuleInfoInternalSafe($className) {
// future addition
// load file, preg_split by /^\s*(public|private|protected)[^;{]+function\s*([^)]*)[^{]*{/
// isolate the one that starts has getModuleInfo in matches[1]
// parse data from matches[2]
}
/**
* Retrieve module info for system properties: PHP or ProcessWire
*
* @param $moduleName
* @return array
*
*/
protected function getModuleInfoSystem($moduleName) {
$info = array();
if($moduleName === 'PHP') {
$info['id'] = 0;
$info['name'] = $moduleName;
$info['title'] = $moduleName;
$info['version'] = PHP_VERSION;
return $info;
} else if($moduleName === 'ProcessWire') {
$info['id'] = 0;
$info['name'] = $moduleName;
$info['title'] = $moduleName;
$info['version'] = $this->wire('config')->version;
$info['namespace'] = strlen(__NAMESPACE__) ? "\\" . __NAMESPACE__ . "\\" : "";
$info['requiresVersions'] = array(
'PHP' => array('>=', '5.3.8'),
'PHP_modules' => array('=', 'PDO,mysqli'),
'Apache_modules' => array('=', 'mod_rewrite'),
'MySQL' => array('>=', '5.0.15'),
);
$info['requires'] = array_keys($info['requiresVersions']);
} else {
return array();
}
$info['versionStr'] = $info['version'];
return $info;
}
/**
* Returns an associative array of information for a Module
*
* The array returned by this method includes the following:
*
* - `id` (int): module database ID.
* - `name` (string): module class name.
* - `title` (string): module title.
* - `version` (int): module version.
* - `icon` (string): Optional icon name (excluding the "fa - ") part.
* - `requires` (array): module names required by this module.
* - `requiresVersions` (array): required module versions–module name is key, value is array($operator, $version).
* - `installs` (array): module names that this module installs.
* - `permission` (string): permission name required to execute this module.
* - `autoload` (bool): true if module is autoload, false if not.
* - `singular` (bool): true if module is singular, false if not.
* - `created` (int): unix - timestamp of date/time module added to system (for uninstalled modules, it is the file date).
* - `installed` (bool): is the module currently installed? (boolean, or null when not determined)
* - `configurable` (bool|int): true or positive number when the module is configurable.
* - `namespace` (string): PHP namespace that module lives in.
*
* The following properties are also included when "verbose" mode is requested. When not in verbose mode, these properties are present but blank:
*
* - `versionStr` (string): formatted module version string.
* - `file` (string): module filename from PW installation root, or false when it can't be found.
* - `core` (bool): true when module is a core module, false when not.
* - `author` (string): module author, when specified.
* - `summary` (string): summary of what this module does.
* - `href` (string): URL to module details (when specified).
* - `permissions` (array): permissions installed by this module, associative array ('permission - name' => 'Description').
* - `page` (array): definition of page to create for Process module (see Process class)
*
* The following properties appear only for "Process" modules. See the Process class for more details:
*
* - `nav` (array): navigation definition
* - `useNavJSON` (bool): whether the Process module provides JSON navigation
* - `permissionMethod` (string|callable): method to call to determine permission
* - `page` (array): definition of page to create for Process module
*
* ~~~~~
* // example of getting module info
* $moduleInfo = $modules->getModuleInfo('InputfieldCKEditor');
*
* // example of getting verbose module info
* $moduleInfo = $modules->getModuleInfoVerbose('MarkupAdminDataTable');
* ~~~~~
*
* @param string|Module|int $class May be class name, module instance, or module ID.
* Specify "*" or "all" to retrieve module info for all modules.
* @param array $options Optional options to modify behavior of what gets returned
* - `verbose` (bool): Makes the info also include additional properties (they will be usually blank without this option specified).
* - `noCache` (bool): prevents use of cache to retrieve the module info.
* - `noInclude` (bool): prevents include() of the module file, applicable only if it hasn't already been included.
* @return array Associative array of module information
* @throws WireException when a module exists but has no means of returning module info
* @see Modules::getModuleInfoVerbose()
* @todo move all getModuleInfo methods to their own ModuleInfo class and break this method down further.
*
*/
public function getModuleInfo($class, array $options = array()) {
if(!isset($options['verbose'])) $options['verbose'] = false;
if(!isset($options['noCache'])) $options['noCache'] = false;
$info = array();
$module = $class;
$moduleName = $this->getModuleClass($module);
$moduleID = (string) $this->getModuleID($module); // typecast to string for cache
$fromCache = false; // was the data loaded from cache?
static $infoTemplate = array(
// module database ID
'id' => 0,
// module class name
'name' => '',
// module title
'title' => '',
// module version
'version' => 0,
// module version (always formatted string)
'versionStr' => '0.0.0',
// who authored the module? (included in 'verbose' mode only)
'author' => '',
// summary of what this module does (included in 'verbose' mode only)
'summary' => '',
// URL to module details (included in 'verbose' mode only)
'href' => '',
// Optional name of icon representing this module (currently font-awesome icon names, excluding the "fa-" portion)
'icon' => '',
// this method converts this to array of module names, regardless of how the module specifies it
'requires' => array(),
// module name is key, value is array($operator, $version). Note 'requiresVersions' index is created by this function.
'requiresVersions' => array(),
// array of module class names
'installs' => array(),
// permission required to execute this module
'permission' => '',
// permissions automatically installed/uninstalled with this module. array of ('permission-name' => 'Description')
'permissions' => array(),
// true if module is autoload, false if not. null=unknown
'autoload' => null,
// true if module is singular, false if not. null=unknown
'singular' => null,
// unix-timestamp date/time module added to system (for uninstalled modules, it is the file date)
'created' => 0,
// is the module currently installed? (boolean, or null when not determined)
'installed' => null,
// this is set to true when the module is configurable, false when it's not, and null when it's not determined
'configurable' => null,
// namespace that module lives in (string)
'namespace' => null,
// verbose mode only: this is set to the module filename (from PW installation root), false when it can't be found, null when it hasn't been determined
'file' => null,
// verbose mode only: this is set to true when the module is a core module, false when it's not, and null when it's not determined
'core' => null,
// other properties that may be present, but are optional, for Process modules:
// 'nav' => array(), // navigation definition: see Process.php
// 'useNavJSON' => bool, // whether the Process module provides JSON navigation
// 'page' => array(), // page to create for Process module: see Process.php
// 'permissionMethod' => string or callable // method to call to determine permission: see Process.php
);
if($module instanceof Module) {
// module is an instance
// $moduleName = method_exists($module, 'className') ? $module->className() : get_class($module);
// return from cache if available
if(empty($options['noCache']) && !empty($this->moduleInfoCache[$moduleID])) {
$info = $this->moduleInfoCache[$moduleID];
$fromCache = true;
} else {
$info = $this->getModuleInfoExternal($moduleName);
if(!count($info)) $info = $this->getModuleInfoInternal($module);
}
} else if($module == 'PHP' || $module == 'ProcessWire') {
// module is a system
$info = $this->getModuleInfoSystem($module);
return array_merge($infoTemplate, $info);
} else if($module === '*' || $module === 'all') {
if(empty($this->moduleInfoCache)) $this->loadModuleInfoCache();
$modulesInfo = $this->moduleInfoCache;
if($options['verbose']) {
if(empty($this->moduleInfoCacheVerbose)) $this->loadModuleInfoCacheVerbose();
foreach($this->moduleInfoCacheVerbose as $moduleID => $moduleInfoVerbose) {
$modulesInfo[$moduleID] = array_merge($modulesInfo[$moduleID], $moduleInfoVerbose);
}
}
return $modulesInfo;
} else {
// module is a class name or ID
if(ctype_digit("$module")) $module = $moduleName;
// return from cache if available
if(empty($options['noCache']) && !empty($this->moduleInfoCache[$moduleID])) {
$info = $this->moduleInfoCache[$moduleID];
$fromCache = true;
} else if(empty($options['noCache']) && $moduleID == 0) {
// uninstalled module
if(!count($this->moduleInfoCacheUninstalled)) $this->loadModuleInfoCacheVerbose(true);
if(isset($this->moduleInfoCacheUninstalled[$moduleName])) {
$info = $this->moduleInfoCacheUninstalled[$moduleName];
$fromCache = true;
}
}
if(!$fromCache) {
$namespace = $this->getModuleNamespace($moduleName);
if(class_exists($namespace . $moduleName, false)) {
// module is already in memory, check external first, then internal
$info = $this->getModuleInfoExternal($moduleName);
if(!count($info)) $info = $this->getModuleInfoInternal($moduleName, $namespace);
} else {
// module is not in memory, check external first, then internal
$info = $this->getModuleInfoExternal($moduleName);
if(!count($info)) {
if(isset($this->installable[$moduleName])) $this->includeModuleFile($this->installable[$moduleName], $moduleName);
// info not available externally, attempt to locate it interally
$info = $this->getModuleInfoInternal($moduleName, $namespace);
}
}
}
}
if(!$fromCache && !count($info)) {
$info = $infoTemplate;
$info['title'] = $module;
$info['summary'] = 'Inactive';
$info['error'] = 'Unable to locate module';
return $info;
}
$info = array_merge($infoTemplate, $info);
$info['id'] = (int) $moduleID;
if($fromCache) {
if($options['verbose']) {
if(empty($this->moduleInfoCacheVerbose)) $this->loadModuleInfoCacheVerbose();
if(!empty($this->moduleInfoCacheVerbose[$moduleID])) {
$info = array_merge($info, $this->moduleInfoCacheVerbose[$moduleID]);
}
}
// populate defaults for properties omitted from cache
if(is_null($info['autoload'])) $info['autoload'] = false;
if(is_null($info['singular'])) $info['singular'] = false;
if(is_null($info['configurable'])) $info['configurable'] = false;
if(is_null($info['core'])) $info['core'] = false;
if(is_null($info['installed'])) $info['installed'] = true;
if(is_null($info['namespace'])) $info['namespace'] = strlen(__NAMESPACE__) ? "\\" . __NAMESPACE__ . "\\" : "";
if(!empty($info['requiresVersions'])) $info['requires'] = array_keys($info['requiresVersions']);
if($moduleName == 'SystemUpdater') $info['configurable'] = 1; // fallback, just in case
// we skip everything else when module comes from cache since we can safely assume the checks below
// are already accounted for in the cached module info
} else {
// if $info[requires] or $info[installs] isn't already an array, make it one
if(!is_array($info['requires'])) {
$info['requires'] = str_replace(' ', '', $info['requires']); // remove whitespace
if(strpos($info['requires'], ',') !== false) {
$info['requires'] = explode(',', $info['requires']);
} else {
$info['requires'] = array($info['requires']);
}
}
// populate requiresVersions
foreach($info['requires'] as $key => $class) {
if(!ctype_alnum($class)) {
// has a version string
list($class, $operator, $version) = $this->extractModuleOperatorVersion($class);
$info['requires'][$key] = $class; // convert to just class
} else {
// no version string
$operator = '>=';
$version = 0;
}
$info['requiresVersions'][$class] = array($operator, $version);
}
// what does it install?
if(!is_array($info['installs'])) {
$info['installs'] = str_replace(' ', '', $info['installs']); // remove whitespace
if(strpos($info['installs'], ',') !== false) {
$info['installs'] = explode(',', $info['installs']);
} else {
$info['installs'] = array($info['installs']);
}
}
// misc
$info['versionStr'] = $this->formatVersion($info['version']); // versionStr
$info['name'] = $moduleName; // module name
// module configurable?
$configurable = $this->isConfigurable($moduleName, false);
if($configurable === true || is_int($configurable) && $configurable > 1) {
// configurable via ConfigurableModule interface
// true=static, 2=non-static, 3=non-static $data, 4=non-static wrap,
// 19=non-static getModuleConfigArray, 20=static getModuleConfigArray
$info['configurable'] = $configurable;
} else if($configurable) {
// configurable via external file: ModuleName.config.php or ModuleNameConfig.php file
$info['configurable'] = basename($configurable);
} else {
// not configurable
$info['configurable'] = false;
}
// created date
if(isset($this->createdDates[$moduleID])) $info['created'] = strtotime($this->createdDates[$moduleID]);
$info['installed'] = isset($this->installable[$moduleName]) ? false : true;
if(!$info['installed'] && !$info['created'] && isset($this->installable[$moduleName])) {
// uninstalled modules get their created date from the file or dir that they are in (whichever is newer)
$pathname = $this->installable[$moduleName];
$filemtime = (int) filemtime($pathname);
$dirname = dirname($pathname);
$dirmtime = substr($dirname, -7) == 'modules' || strpos($dirname, $this->paths[0]) !== false ? 0 : (int) filemtime($dirname);
$info['created'] = $dirmtime > $filemtime ? $dirmtime : $filemtime;
}
// namespace
if($info['core']) {
// default namespace, assumed since all core modules are in default namespace
$info['namespace'] = strlen(__NAMESPACE__) ? "\\" . __NAMESPACE__ . "\\" : "";
} else {
$info['namespace'] = $this->getModuleNamespace($moduleName, array(
'file' => $info['file'],
'noCache' => $options['noCache']
));
}
if(!$options['verbose']) foreach($this->moduleInfoVerboseKeys as $key) unset($info[$key]);
}
if(is_null($info['namespace'])) {
$info['namespace'] = strlen(__NAMESPACE__) ? "\\" . __NAMESPACE__ . "\\" : "";
}
if(empty($info['created']) && isset($this->createdDates[$moduleID])) {
$info['created'] = strtotime($this->createdDates[$moduleID]);
}
if($options['verbose']) {
// the file property is not stored in the verbose cache, but provided as a verbose key
$info['file'] = $this->getModuleFile($moduleName);
if($info['file']) $info['core'] = strpos($info['file'], $this->coreModulesDir) !== false; // is it core?
}
// if($this->debug) $this->message("getModuleInfo($moduleName) " . ($fromCache ? "CACHE" : "NO-CACHE"));
return $info;
}
/**
* Returns a verbose array of information for a Module
*
* This is the same as what's returned by `Modules::getModuleInfo()` except that it has the following additional properties:
*
* - `versionStr` (string): formatted module version string.
* - `file` (string): module filename from PW installation root, or false when it can't be found.
* - `core` (bool): true when module is a core module, false when not.
* - `author` (string): module author, when specified.
* - `summary` (string): summary of what this module does.
* - `href` (string): URL to module details (when specified).
* - `permissions` (array): permissions installed by this module, associative array ('permission - name' => 'Description').
* - `page` (array): definition of page to create for Process module (see Process class)
*
* @param string|Module|int $class May be class name, module instance, or module ID
* @param array $options Optional options to modify behavior of what gets returned:
* - `noCache` (bool): prevents use of cache to retrieve the module info
* - `noInclude` (bool): prevents include() of the module file, applicable only if it hasn't already been included
* @return array Associative array of module information
* @throws WireException when a module exists but has no means of returning module info
* @see Modules::getModuleInfo()
*
*/
public function getModuleInfoVerbose($class, array $options = array()) {
$options['verbose'] = true;
$info = $this->getModuleInfo($class, $options);
return $info;
}
/**
* Get an array of all unique, non-default, non-root module namespaces mapped to directory names
*
* #pw-internal
*
* @return array
*
*/
public function getNamespaces() {
if(!is_null($this->moduleNamespaceCache)) return $this->moduleNamespaceCache;
$defaultNamespace = strlen(__NAMESPACE__) ? "\\" . __NAMESPACE__ . "\\" : "";
$namespaces = array();
foreach($this->moduleInfoCache as $moduleID => $info) {
if(!isset($info['namespace']) || $info['namespace'] === $defaultNamespace || $info['namespace'] === "\\") continue;
$moduleName = $info['name'];
$namespaces[$info['namespace']] = $this->wire('config')->paths->$moduleName;
}
$this->moduleNamespaceCache = $namespaces;
return $namespaces;
}
/**
* Get the namespace for the given module
*
* #pw-internal
*
* @param string|Module $moduleName
* @param array $options
* - `file` (string): Known module path/file, as an optimization.
* - `noCache` (bool): Specify true to force reload namespace info directly from module file.
* @return null|string Returns namespace, or NULL if unable to determine. Namespace is ready to use in a string (i.e. has trailing slashes)
*
*/
public function getModuleNamespace($moduleName, $options = array()) {
$defaults = array(
'file' => null,
'noCache' => false,
);
$namespace = null;
$options = array_merge($defaults, $options);
if(is_object($moduleName) || strpos($moduleName, "\\") !== false) {
$className = is_object($moduleName) ? get_class($moduleName) : $moduleName;
$parts = explode("\\", $className);
array_pop($parts);
$namespace = count($parts) ? implode("\\", $parts) : "";
$namespace = $namespace == "" ? "\\" : "\\$namespace\\";
return $namespace;
}
if(empty($options['noCache'])) {
$moduleID = $this->getModuleID($moduleName);
$info = isset($this->moduleInfoCache[$moduleID]) ? $this->moduleInfoCache[$moduleID] : null;
if($info && isset($info['namespace'])) {
return $info['namespace'];
}
}
if(empty($options['file'])) {
$options['file'] = $this->getModuleFile($moduleName);
}
if(strpos($options['file'], $this->coreModulesDir) !== false) {
// all core modules use \ProcessWire\ namespace
$namespace = strlen(__NAMESPACE__) ? __NAMESPACE__ . "\\" : "";
return $namespace;
}
if(!$options['file'] || !file_exists($options['file'])) {
return null;
}
$namespace = $this->getFileNamespace($options['file']);
return $namespace;
}
/**
* Get the namespace used in the given .php or .module file
*
* #pw-internal
*
* @param string $file
* @return string Includes leading and trailing backslashes where applicable
*
*/
public function getFileNamespace($file) {
$namespace = $this->wire('files')->getNamespace($file);
if($namespace !== "\\") $namespace = "\\" . trim($namespace, "\\") . "\\";
return $namespace;
}
/**
* Get the class defined in the file (or optionally the 'extends' or 'implements')
*
* #pw-internal
*
* @param string $file
* @return array Returns array with these indexes:
* 'class' => string (class without namespace)
* 'className' => string (class with namespace)
* 'extends' => string
* 'namespace' => string
* 'implements' => array
*
*/
public function getFileClassInfo($file) {
$value = array(
'class' => '',
'className' => '',
'extends' => '',
'namespace' => '',
'implements' => array()
);
if(!is_file($file)) return $value;
$data = file_get_contents($file);
if(!strpos($data, 'class')) return $value;
if(!preg_match('/^\s*class\s+(.+)$/m', $data, $matches)) return $value;
if(strpos($matches[1], "\t") !== false) $matches[1] = str_replace("\t", " ", $matches[1]);
$parts = explode(' ', trim($matches[1]));
foreach($parts as $key => $part) {
if(empty($part)) unset($parts[$key]);
}
$className = array_shift($parts);
if(strpos($className, '\\') !== false) {
$className = trim($className, '\\');
$a = explode('\\', $className);
$value['className'] = "\\$className\\";
$value['class'] = array_pop($a);
$value['namespace'] = '\\' . implode('\\', $a) . '\\';
} else {
$value['className'] = '\\' . $className;
$value['class'] = $className;
$value['namespace'] = '\\';
}
while(count($parts)) {
$next = array_shift($parts);
if($next == 'extends') {
$value['extends'] = array_shift($parts);
} else if($next == 'implements') {
$implements = array_shift($parts);
if(strlen($implements)) {
$implements = str_replace(' ', '', $implements);
$value['implements'] = explode(',', $implements);
}
}
}
return $value;
}
/**
* Alias of getConfig() for backwards compatibility
*
* #pw-internal
*
* @param string|Module $className
* @return array
*
*/
public function getModuleConfigData($className) {
return $this->getConfig($className);
}
/**
* Given a module name, return an associative array of configuration data for it
*
* - Applicable only for modules that support configuration.
* - Configuration data is stored encoded in the database "modules" table "data" field.
*
* ~~~~~~
* // Getting, modifying and saving module config data
* $data = $modules->getConfig('HelloWorld');
* $data['greeting'] = 'Hello World! How are you today?';
* $modules->saveConfig('HelloWorld', $data);
* ~~~~~~
*
* #pw-group-configuration
* #pw-changelog 3.0.16 Changed from more verbose name `getModuleConfigData()`, which can still be used.
*
* @param string|Module $class
* @param string $property Optionally just get value for a specific property (omit to get all config)
* @return array Module configuration data
* @see Modules::saveConfig()
* @since 3.0.16 Use method getModuleConfigData() with same arguments for prior versions (can also be used on any version).
*
*/
public function getConfig($class, $property = '') {
$emptyReturn = $property ? null : array();
$className = $class;
if(is_object($className)) $className = wireClassName($className->className(), false);
if(!$id = $this->moduleIDs[$className]) return $emptyReturn;
if(!isset($this->configData[$id])) return $emptyReturn; // module has no config data
if(is_array($this->configData[$id])) {
$data = $this->configData[$id];
} else {
// first verify that module doesn't have a config file
$configurable = $this->isConfigurable($className);
if(!$configurable) return $emptyReturn;
$database = $this->wire('database');
$query = $database->prepare("SELECT data FROM modules WHERE id=:id", "modules.getConfig($className)"); // QA
$query->bindValue(":id", (int) $id, \PDO::PARAM_INT);
$query->execute();
$data = $query->fetchColumn();
$query->closeCursor();
if(strlen($data)) $data = wireDecodeJSON($data);
if(empty($data)) $data = array();
$this->configData[$id] = $data;
}
if($property) return isset($data[$property]) ? $data[$property] : null;
return $data;
}
/**
* Alias of getConfig() for backwards compatibility
*
* @param string|Module $className