From e2179a6ec1f889bf4cc764a26716648b5d3d2b17 Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Fri, 2 Jun 2023 14:53:12 -0400 Subject: [PATCH] Major overhaul of Modules class, splitting it up into 6 other more focused classes --- wire/core/Modules.php | 4734 +++-------------- wire/core/ModulesClass.php | 91 + wire/core/ModulesConfigs.php | 721 +++ wire/core/ModulesDuplicates.php | 8 + wire/core/ModulesFiles.php | 656 +++ wire/core/ModulesFlags.php | 200 + wire/core/ModulesInfo.php | 1383 +++++ wire/core/ModulesInstaller.php | 829 +++ wire/core/ModulesLoader.php | 909 ++++ .../ProcessModule/ProcessModule.module | 17 +- 10 files changed, 5565 insertions(+), 3983 deletions(-) create mode 100644 wire/core/ModulesClass.php create mode 100644 wire/core/ModulesConfigs.php create mode 100644 wire/core/ModulesFiles.php create mode 100644 wire/core/ModulesFlags.php create mode 100644 wire/core/ModulesInfo.php create mode 100644 wire/core/ModulesInstaller.php create mode 100644 wire/core/ModulesLoader.php diff --git a/wire/core/Modules.php b/wire/core/Modules.php index 15f31da1..2aa78332 100644 --- a/wire/core/Modules.php +++ b/wire/core/Modules.php @@ -5,11 +5,6 @@ * * 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 2023 by Ryan Cramer * https://processwire.com * @@ -26,9 +21,11 @@ * * #pw-body * - * @todo Move all module information methods to a ModulesInfo class - * @todo Move all module loading methods to a ModulesLoad class - * + * 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). + * * @method void refresh($showMessages = false) Refresh the cache that stores module files by recreating it * @method null|Module install($class, $options = array()) * @method bool|int delete($class) @@ -38,6 +35,20 @@ * @method InputfieldWrapper|null getModuleConfigInputfields($moduleName, InputfieldWrapper $form = null) #pw-internal * @method void moduleVersionChanged(Module $module, $fromVersion, $toVersion) #pw-internal * @method bool|string isUninstallable($class, $returnReason = false) hookable in 3.0.181+ #pw-internal + * + * @property-read array $installableFiles + * @property-read string $coreModulesDir + * @property-read string $coreModulesPath + * @property-read string $siteModulesPath + * @property-read array $moduleIDs + * @property-read array $moduleNames + * @property-read ModulesInfo $info + * @property-read ModulesLoader $loader + * @property-read ModulesFlags $flags + * @property-read ModulesFiles $files + * @property-read ModulesInstaller $installer + * @property-read ModulesConfigs $configs + * @property-read bool $refreshing * */ @@ -56,19 +67,19 @@ class Modules extends WireArray { const flagsSingular = 1; /** - * Flag indicating that the module should be instantiated at runtime, rather than when called upon. + * 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. + * 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 + * When combined with flagsAutoload, indicates that the autoload is conditional * */ const flagsConditional = 8; @@ -99,67 +110,32 @@ class Modules extends WireArray { */ const flagsSystemCache = 8192; - /** - * 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(); + protected $installableFiles = array(); /** - * An array of module database IDs indexed by: class => id - * - * Used internally for database operations + * An array of module database IDs indexed by module name/class * */ protected $moduleIDs = array(); + /** + * Array of module names/classes indexed by module ID + * + */ + protected $moduleNames = 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() ? * @@ -172,64 +148,6 @@ class Modules extends WireArray { */ 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 * @@ -247,15 +165,7 @@ class Modules extends WireArray { * @var ModulesDuplicates * */ - protected $duplicates; - - /** - * Module file extensions indexed by module name where value 1=.module, and 2=.module.php - * - * @var array - * - */ - protected $moduleFileExts = array(); + protected $duplicates = null; /** * Dir for core modules relative to root path, i.e. '/wire/modules/' @@ -265,14 +175,6 @@ class Modules extends WireArray { */ protected $coreModulesDir = ''; - /** - * Array of moduleName => order to indicate autoload order when necessary - * - * @var array - * - */ - protected $autoloadOrders = array(); - /** * Are we currently refreshing? * @@ -291,43 +193,40 @@ class Modules extends WireArray { protected $caches = array(); /** - * Properties that only appear in 'verbose' moduleInfo - * - * @var array + * @var ModulesInfo * */ - protected $moduleInfoVerboseKeys = array( - 'summary', - 'author', - 'href', - 'file', - 'core', - 'versionStr', - 'permissions', - 'searchable', - 'page', - 'license', - // 'languages', - ); - + protected $info; + /** - * Core module types that are isolated by directory - * - * @var array + * @var ModulesFlags * */ - protected $coreTypes = array( - 'AdminTheme', - 'Fieldtype', - 'Inputfield', - 'Jquery', - 'LanguageSupport', - 'Markup', - 'Process', - 'Session', - 'System', - 'Textformatter', - ); + protected $flags; + + /** + * @var ModulesFiles + * + */ + protected $files; + + /** + * @var ModulesConfigs + * + */ + protected $configs; + + /** + * @var ModulesInstaller|null + * + */ + protected $installer = null; + + /** + * @var ModulesLoader + * + */ + protected $loader = null; /** * Construct the Modules @@ -337,7 +236,7 @@ class Modules extends WireArray { */ public function __construct($path) { parent::__construct(); - $this->addPath($path); + $this->addPath($path); // paths[0] is always core modules path } /** @@ -349,6 +248,11 @@ class Modules extends WireArray { public function wired() { $this->coreModulesDir = '/' . $this->wire()->config->urls->data('modules'); parent::wired(); + $this->info = new ModulesInfo($this); + $this->flags = new ModulesFlags($this); + $this->files = new ModulesFiles($this); + $this->configs = new ModulesConfigs($this); + $this->loader = new ModulesLoader($this); } /** @@ -360,9 +264,22 @@ class Modules extends WireArray { * */ public function duplicates() { - if(is_null($this->duplicates)) $this->duplicates = $this->wire(new ModulesDuplicates()); + if($this->duplicates === null) $this->duplicates = $this->wire(new ModulesDuplicates()); return $this->duplicates; } + + /** + * Get the ModulesInstaller instance + * + * #pw-internal + * + * @return ModulesInstaller + * + */ + public function installer() { + if($this->installer === null) $this->installer = new ModulesInstaller($this); + return $this->installer; + } /** * Add another modules path, must be called before init() @@ -400,119 +317,43 @@ class Modules extends WireArray { */ public function init() { $this->setTrackChanges(false); - $this->loadModulesTable(); - $this->loadModuleInfoCache(); - if(!empty($this->autoloadOrders)) $this->preloadModules(); + $this->loader->loadModulesTable(); + $this->info->loadModuleInfoCache(); + if(isset($this->paths[1])) { + $this->loader->preloadModules($this->paths[1]); // site modules path + } foreach($this->paths as $path) { - $this->load($path); + $this->loader->loadPath($path); } - $this->modulesTableCache = array(); // clear out data no longer needed + $this->loader->loaded(); + } + + /** + * Trigger 'init' on all autoload modules, which calls their init() methods + * + * #pw-internal + * + */ + public function triggerInit() { + $this->loader->triggerInit(); } /** - * Get data from the module info cache - * - * @param string|int|null $moduleID Module ID or name or omit to get info for all modules - * @param string $property - * @param bool $verbose - * @return array|mixed|null - * @since 3.0.218 - * + * Trigger 'ready' on all autoload modules, which calls their ready() methods + * + * This is to indicate to them that the API environment is fully ready. + * + * #pw-internal + * */ - protected function moduleInfoCache($moduleID = null, $property = '', $verbose = false) { - if($verbose) { - $infos = &$this->moduleInfoCacheVerbose; - if(empty($infos)) $this->loadModuleInfoCacheVerbose(); - } else { - $infos = &$this->moduleInfoCache; - if(empty($infos)) $this->loadModuleInfoCache(); - } - if($moduleID === null) { - // get all - foreach($infos as $moduleID => $info) { - if(empty($info)) { - $info = array(); - } else if(is_array($info)) { - continue; - } else { - $info = json_decode($info, true); - } - $infos[$moduleID] = $info; - } - return $infos; - } else if($moduleID === 0) { - return $property ? null : array(); - } - if(!ctype_digit("$moduleID")) { - // convert module name to module id - $moduleName = is_object($moduleID) ? $this->getModuleClass($moduleID) : $moduleID; - if(!isset($this->moduleIDs[$moduleName])) return ($property ? null : array()); - $moduleID = $this->moduleIDs[$moduleName]; - } - $moduleID = (int) $moduleID; - if(!isset($infos[$moduleID])) return ($property ? null : array()); - $info = $infos[$moduleID]; - if(empty($info)) return ($property ? null : array()); - if(is_string($info)) { - $info = json_decode($info, true); - if(!is_array($info)) $info = array(); - $infos[$moduleID] = $info; - } - if($property) return isset($info[$property]) ? $info[$property] : null; - return $info; + public function triggerReady() { + $this->loader->triggerReady(); } - /** - * Get data from the verbose module info cache - * - * @param int|string|null $moduleID - * @param string $property - * @return array|mixed|null - * + /********************************************************************************************** + * WIREARRAY OVERRIDES + * */ - protected function moduleInfoCacheVerbose($moduleID = null, $property = '') { - return $this->moduleInfoCache($moduleID, $property, true); - } - - /** - * Include site preload modules - * - * Preload modules load before all other modules, including core modules. In order - * for a module to be a preload module, it must meet the following conditions: - * - * - Module info `autoload` value is integer of 10000 or greater, i.e. `[ 'autoload' => 10000 ]` - * - Module info `singular` value must be non-empty, i.e. `[ 'singular' => true ]` - * - Module file is located in: /site/modules/ModuleName/ModuleName.module.php - * - Module cannot load any other modules at least until ready() method called. - * - Module cannot have any `requires` dependencies to any other modules. - * - * Please note the above is specifically stating that the module must be in its - * own “site/ModuleName/” directory and have the “.module.php” extension. Using - * just the “.module” extension is not supported for preload modules. - * - * @since 3.0.173 - * - */ - protected function preloadModules() { - if(!isset($this->paths[1])) return; - arsort($this->autoloadOrders); - foreach($this->autoloadOrders as $moduleName => $order) { - if($order < 10000) break; - $info = $this->moduleInfoCache($moduleName); - if(empty($info)) continue; - //if(!isset($this->moduleIDs[$moduleName])) continue; - //$moduleID = $this->moduleIDs[$moduleName]; - //if(!isset($this->moduleInfoCache[$moduleID])) continue; - //$info = $this->moduleInfoCache[$moduleID]; - if(empty($info['singular'])) continue; - $file = $this->paths[1] . "$moduleName/$moduleName.module.php"; - if(!file_exists($file) || !$this->includeModuleFile($file, $moduleName)) continue; - if(!isset($info['namespace'])) $info['namespace'] = ''; - $className = $info['namespace'] . $moduleName; - $module = $this->newModule($className, $moduleName); - if($module) parent::set($moduleName, $module); - } - } /** * Modules class accepts only Module instances, per the WireArray interface @@ -577,771 +418,10 @@ class Modules extends WireArray { 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 - * @param string $moduleName Optional module name only (no namespace) - * @return Module|null + /********************************************************************************************** + * GETTING/LOADING MODULES * */ - protected function newModule($className, $moduleName = '') { - if(!$moduleName) { - $moduleName = wireClassName($className, false); - $className = wireClassName($className, true); - } - $debugKey = $this->debug ? $this->debugTimerStart("newModule($moduleName)") : null; - if(!class_exists($className, false)) { - $result = $this->includeModule($moduleName); - if(!$result) return null; - } - 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; - } - - 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) { - /** @var ModulePlaceholder $module */ - $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) - * - `configOnly` (bool): When true, module init() method NOT called, but config data still set (default=false) 3.0.169+ - * - `configData` (array): Extra config data merge with module’s config data (default=[]) 3.0.169+ - * - `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 - $extraConfigData = isset($options['configData']) ? $options['configData'] : null; - $this->setModuleConfigData($module, null, $extraConfigData); - - $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') && empty($options['configOnly'])) { - - 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() { - - $this->autoloadOrders = array(); - $database = $this->wire()->database; - - // skip loading dymanic caches at this stage - $skipCaches = array( - self::moduleInfoCacheUninstalledName, - self::moduleInfoCacheVerboseName - ); - - $query = $database->query( - // Currently: id, class, flags, data, with created added at sysupdate 7 - "SELECT * FROM modules " . - "WHERE class NOT IN('" . implode("','", $skipCaches) . "') " . - "ORDER BY class", - "modules.loadModulesTable()" - ); - - /** @noinspection PhpAssignmentInConditionInspection */ - while($row = $query->fetch(\PDO::FETCH_ASSOC)) { - - $moduleID = (int) $row['id']; - $flags = (int) $row['flags']; - $class = $row['class']; - - if($flags & self::flagsSystemCache) { - // system cache names are prefixed with a '.' so they load first - $this->caches[ltrim($class, '.')] = $row['data']; - continue; - } - - $this->moduleIDs[$class] = $moduleID; - $this->moduleFlags[$moduleID] = $flags; - - $autoload = $flags & self::flagsAutoload; - $loadSettings = $autoload || ($flags & self::flagsDuplicate) || ($class == 'SystemUpdater'); - - if($loadSettings) { - // preload config data for autoload modules since we'll need it again very soon - $data = strlen((string) $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']; - } - - if($autoload) { - $value = $this->moduleInfoCache($moduleID, 'autoload'); - if(!empty($value)) { - $autoload = $value; - $disabled = $flags & self::flagsDisabled; - if(is_int($autoload) && $autoload > 1 && !$disabled) { - // autoload specifies an order > 1, indicating it should load before others - $this->autoloadOrders[$class] = $autoload; - } - } - } - - unset($row['data'], $row['created']); // 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) { - - $config = $this->wire()->config; - $debugKey = $this->debug ? $this->debugTimerStart("load($path)") : null; - $installed =& $this->modulesTableCache; - $modulesLoaded = array(); - $modulesDelayed = array(); - $modulesRequired = array(); - $rootPath = $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(!$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((string) $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') === false) return false; - list(, $ext) = explode('.module', $filename, 2); - if(!empty($ext) && $ext !== '.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(!isset($installed[$basename]) && !array_key_exists($basename, $installed)) { - // array_key_exists is used as secondary to check the null case - $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() - - // determine if module has dependencies that are not yet met - $requiresClasses = $this->getModuleInfoProperty($basename, 'requires'); - if(!empty($requiresClasses)) { - foreach($requiresClasses 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 - /** @var bool|string|callable $autoload */ - $autoload = $this->moduleInfoCache($basename, 'autoload'); - if(empty($autoload)) $autoload = true; - 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); - $namespace = $this->getModuleNamespace($basename); - $className = $namespace . $basename; - if(method_exists($className, 'getModuleInfo')) { - $i = $className::getModuleInfo(); - } else { - $i = $this->getModuleInfo($className); - } - } - $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)) { - if($this->refreshing) { - $module = parent::get($basename); - } else if(isset($this->autoloadOrders[$basename]) && $this->autoloadOrders[$basename] >= 10000) { - $module = parent::get($basename); // preloaded module - } - if(!$module) $module = $this->newModule($basename); - } - } - } - - if($module === null) { - // placeholder for a module, which is not yet included and instantiated - // if(!$moduleInfo) $moduleInfo = $this->getModuleInfo($basename); - $ns = $moduleInfo ? $moduleInfo['namespace'] : $this->moduleInfoCache($basename, 'namespace'); - if(empty($namespace)) $ns = __NAMESPACE__ . "\\"; - $singular = $info['flags'] & self::flagsSingular; - $module = $this->newModulePlaceholder($basename, $ns, $pathname, $singular, $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; - static $prependFiles = array(); - - $callNum++; - $config = $this->wire()->config; - $cacheName = ''; - - if($level == 0) { - $startPath = $path; - $cacheName = "Modules." . str_replace($config->paths->root, '', $path); - if($readCache) { - $cacheContents = $this->getCache($cacheName); - if($cacheContents) return explode("\n", trim($cacheContents)); - } - } - - $files = array(); - $autoloadOrders = null; - - if(count($this->autoloadOrders) && $path !== $config->paths->modules) { - $autoloadOrders = &$this->autoloadOrders; - } - - 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); - } - - 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 - $extension = $file->getExtension(); - if($extension !== 'module' && $extension !== 'php') continue; - list($moduleName, $extension) = explode('.', $filename, 2); - if($extension !== 'module' && $extension !== 'module.php') continue; - - $pathname = str_replace($startPath, '', $pathname); - - if($autoloadOrders !== null && isset($autoloadOrders[$moduleName])) { - $prependFiles[$pathname] = $autoloadOrders[$moduleName]; - } else { - $files[] = $pathname; - } - } - - if($level == 0 && $dir !== null) { - if(!empty($prependFiles)) { - // one or more non-core modules must be loaded first in a specific order - arsort($prependFiles); - $files = array_merge(array_keys($prependFiles), $files); - $prependFiles = array(); - } - if($cacheName) { - $this->saveCache($cacheName, implode("\n", $files)); - } - } - - 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 @@ -1369,29 +449,7 @@ class Modules extends WireArray { // 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) * @@ -1434,7 +492,7 @@ class Modules extends WireArray { // 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)) { + if(!isset($this->moduleNames[$moduleID])) { return empty($options['returnError']) ? null : "Unable to find module ID $moduleID"; } } else { @@ -1447,7 +505,7 @@ class Modules extends WireArray { if(!$module && !$moduleID) { // make non case-sensitive for module name ($key) $lowerKey = strtolower($key); - foreach(array_keys($this->moduleIDs) as $className) { + foreach($this->moduleNames as $className) { if(strtolower($className) !== $lowerKey) continue; $module = parent::get($className); break; @@ -1560,7 +618,7 @@ class Modules extends WireArray { $initOptions['clearSettings'] = false; $initOptions['throw'] = true; try { - if(!$this->initModule($module, $initOptions)) { + if(!$this->loader->initModule($module, $initOptions)) { return empty($options['returnError']) ? null : "Module '$module' failed init"; } } catch(\Exception $e) { @@ -1571,6 +629,102 @@ class Modules extends WireArray { return $module; } + + /** + * Get the requested module and reset cache + install it if necessary. + * + * This is exactly the same as getModule() 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->getModule($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 = '') { + return $this->loader->includeModule($module, $file); + } + + /** + * Create a new Module instance + * + * Given a class name, return the constructed module or null + * + * #pw-internal + * + * @param string $className Module class name + * @param string $moduleName Optional module name only (no namespace) + * @return Module|null + * + */ + public function newModule($className, $moduleName = '') { + + if(!$moduleName) { + $moduleName = wireClassName($className, false); + $className = wireClassName($className, true); + } + + $debugKey = $this->debug ? $this->debugTimerStart("newModule($moduleName)") : null; + + if(!class_exists($className, false)) { + $result = $this->includeModule($moduleName); + if(!$result) return null; + } + + if(!class_exists($className, false)) { + // attempt 2.x module in dedicated namespace or root namespace + $className = $this->info->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; + } + + 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; + } + /** * Check if user has permission for given module @@ -1589,242 +743,13 @@ class Modules extends WireArray { * */ 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); - if(method_exists($className, $method)) return $className::$method($data); - return false; - } - - return true; + return $this->loader->hasPermission($moduleName, $user, $page, $strict); } - - /** - * 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 = ''; - $moduleName = ''; - - if(is_string($module)) { - $moduleName = ctype_alnum($module) ? $module : wireClassName($module); - $className = wireClassName($module, true); - } else if(is_object($module)) { - if($module instanceof ModulePlaceholder) { - $moduleName = $module->className(); - $className = $module->className(true); - } else if($module instanceof Module) { - return true; // already included - } - } else { - $moduleName = $this->getModuleClass($module, false); - $className = $this->getModuleClass($module, true); - } - - if(!$className) return false; - - // already included - if(class_exists($className, false)) return true; - // 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) { - // This is reached for any of the following: - // 1. an uninstalled module - // 2. an installed module that has changed locations - // 3. a module outside the \ProcessWire\ namespace - // 4. a module that does not exist - $fast = true; - if(!$file) { - // determine module file, if not already provided to the method - $file = $this->getModuleFile($moduleName, array('fast' => true)); - if(!$file) { - $fast = false; - $file = $this->getModuleFile($moduleName, array('fast' => false)); - } - // still can't figure out what file is? fail - if(!$file) return false; - } - - if(!$this->includeModuleFile($file, $moduleName)) { - // module file failed to include(), try to identify and include file again - if($fast) { - $filePrev = $file; - $file = $this->getModuleFile($moduleName, array('fast' => false)); - if($file && $file !== $filePrev) { - if($this->includeModuleFile($file, $moduleName)) { - // module is missing a module file - return false; - } - } - } else { - // we already tried this earlier, no point in doing it again - } - } - - // now check to see if included file resulted in presence of module class - if(class_exists($className)) { - // module in ProcessWire namespace - $module = true; - } else { - // module in root namespace or some other namespace - $namespace = (string) $this->getModuleNamespace($moduleName, array('file' => $file)); - $className = trim($namespace, "\\") . "\\$moduleName"; - if(class_exists($className, false)) { - // successful include module - $module = true; - } - } - } - - if($module === true) { - // great - return true; - - } else if(!$module) { - // darn - 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 - * @return bool - * + /********************************************************************************************** + * FINDER METHODS + * */ - 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 true; - // 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 true; - // 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 */ - $success = @include_once($file); - } else { - $success = false; - } - - // set instance back, if multi-instance - if($wire1 !== $wire2) ProcessWire::setCurrentInstance($wire1); - - return (bool) $success; - } - - /** - * Find modules based on a selector string - * - * #pw-internal Almost always recommend using findByPrefix() instead - * - * @param string $selector Selector string - * @return 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”) @@ -1915,10 +840,14 @@ class Modules extends WireArray { } // check if any verbose properties are part of the find - if(!$verbose) foreach($properties as $property) { - if(!in_array($property, $this->moduleInfoVerboseKeys)) continue; - $verbose = true; - break; + + if(!$verbose) { + $moduleInfoVerboseKeys = $this->info->moduleInfoVerboseKeys; + foreach($properties as $property) { + if(!in_array($property, $moduleInfoVerboseKeys)) continue; + $verbose = true; + break; + } } $moduleInfoOptions = array( @@ -1976,9 +905,34 @@ class Modules extends WireArray { return $results; } + + /** + * Find modules based on a selector string + * + * #pw-internal Almost always recommend using findByPrefix() instead + * + * @param string $selector Selector string + * @return 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; + } + + /********************************************************************************************** + * INSTALLER METHODS + * + */ /** - * Get an associative array [name => path] for all modules that aren’t currently installed. + * Get an associative array [name => filename] for all modules that aren’t currently installed. * * #pw-internal * @@ -1986,20 +940,22 @@ class Modules extends WireArray { * */ public function getInstallable() { - return $this->installable; + return $this->installableFiles; } /** * 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); + $class = (string) $class; + if(empty($class)) return false; $operator = null; @@ -2017,25 +973,24 @@ class Modules extends WireArray { if($class === 'PHP' || $class === 'ProcessWire') { $installed = true; - if(!is_null($requiredVersion)) { + if($requiredVersion !== null) { $currentVersion = $class === 'PHP' ? PHP_VERSION : $this->wire()->config->version; } } else { $installed = parent::get($class) !== null; - if($installed && !is_null($requiredVersion)) { - $info = $this->getModuleInfo($class); + if($installed && $requiredVersion !== null) { + $info = $this->info->getModuleInfo($class); $currentVersion = $info['version']; } } - - if($installed && !is_null($currentVersion)) { - $installed = $this->versionCompare($currentVersion, $requiredVersion, $operator); + + if($installed && $currentVersion !== null) { + $installed = $this->installer()->versionCompare($currentVersion, $requiredVersion, $operator); } - + return $installed; } - /** * Is the given module name installable? (i.e. not already installed) * @@ -2047,16 +1002,31 @@ class Modules extends WireArray { * */ public function isInstallable($class, $now = false) { - if(!array_key_exists($class, $this->installable)) return false; - if(!wireInstanceOf($class, 'Module')) { - $nsClass = $this->getModuleClass($class, true); - if(!wireInstanceOf($nsClass, 'ProcessWire\\Module')) return false; + return $this->installer()->isInstallable($class, $now); + } + + /** + * Return installable file pathname for given class or null if not present + * + * #pw-internal + * + * @param string|null $class Module class name or omit to get all in array + * @param null|string|bool Specify string to set, boolean false to unset, otherwise omit + * @return string|null|array + * @since 3.0.219 + * + */ + public function installableFile($class = null, $setPath = null) { + if($class === null) return $this->installableFiles; + if($setPath !== null) { + if($setPath === false) { + unset($this->installableFiles[$class]); + } else if(is_string($setPath)) { + $this->installableFiles[$class] = $setPath; + } + return $setPath; } - if($now) { - $requires = $this->getRequiresForInstall($class); - if(count($requires)) return false; - } - return true; + return isset($this->installableFiles[$class]) ? $this->installableFiles[$class] : null; } /** @@ -2074,164 +1044,7 @@ class Modules extends WireArray { * */ 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); - } - } - } - - $database = $this->wire()->database; - $languages = $this->wire()->languages; - $config = $this->wire()->config; - - 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; - $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($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 its 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)); - - $sanitizer = $this->wire()->sanitizer; - $permissions = $this->wire()->permissions; - - // if this module has custom permissions defined in its getModuleInfo()['permissions'] array, install them - foreach($info['permissions'] as $name => $title) { - $name = $sanitizer->pageName($name); - if(ctype_digit("$name") || empty($name)) continue; // permission name not valid - $permission = $permissions->get($name); - if($permission->id) continue; // permision already there - try { - $permission = $permissions->add($name); - $permission->title = $title; - $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; + return $this->installer()->install($class, $options); } /** @@ -2245,50 +1058,11 @@ class Modules extends WireArray { * */ 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; + return $this->installer()->isUninstallable($class, $returnReason); } /** - * Returns whether the module can be deleted (have it's files physically removed) + * Returns whether the module can be deleted (have its files physically removed) * * #pw-internal * @@ -2298,32 +1072,7 @@ class Modules extends WireArray { * */ 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; + return $this->installer()->isDeleteable($class, $returnReason); } /** @@ -2337,121 +1086,9 @@ class Modules extends WireArray { * */ public function ___delete($class) { - - $config = $this->wire()->config; - $fileTools = $this->wire()->files; - - $class = $this->getModuleClass($class); - $success = true; - $reason = $this->isDeleteable($class, true); - if($reason !== true) throw new WireException($reason); - $siteModulesPath = $config->paths->siteModules; - - $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[] = $fileTools->unixDirName($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 = (bool) $fileTools->rmdir($path, true); - if($success) { - $this->message("Removed directory: $path", Notice::debug); - if(is_dir($backupPath)) { - if($fileTools->rmdir($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($fileTools->unlink($file, $siteModulesPath)) { - $this->message("Removed file: $file", Notice::debug); - } else { - $this->error("Unable to remove file: $file", Notice::debug); - } - } - - $this->log("Deleted module '$class'"); - - return $success; + return $this->installer()->delete($class); } - /** * Uninstall the given module name * @@ -2463,102 +1100,30 @@ class Modules extends WireArray { * */ 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); - - $sanitizer = $this->wire()->sanitizer; - $permissions = $this->wire()->permissions; - - // delete permissions installed by this module - if(isset($info['permissions']) && is_array($info['permissions'])) { - foreach($info['permissions'] as $name => $title) { - $name = $sanitizer->pageName($name); - if(ctype_digit("$name") || empty($name)) continue; - $permission = $permissions->get($name); - if(!$permission->id) continue; - try { - $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; + return $this->installer()->uninstall($class); } + + /** + * 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) { + return $this->installer()->getUninstalls($class); + } + + /********************************************************************************************** + * MODULE FLAGS + * + */ /** * Get flags for the given module @@ -2567,20 +1132,11 @@ class Modules extends WireArray { * * @param int|string|Module $class Module to add flag to * @return int|false Returns integer flags on success, or boolean false on fail + * @deprecated * */ 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; + return $this->flags->getFlags($class); } /** @@ -2592,11 +1148,11 @@ class Modules extends WireArray { * @param int $flag * @return bool * @since 3.0.170 + * @deprecated * */ public function hasFlag($class, $flag) { - $flags = $this->getFlags($class); - return $flags === false ? false : ($flags & $flag); + return $this->flags->hasFlag($class, $flag); } /** @@ -2607,19 +1163,11 @@ class Modules extends WireArray { * @param $class * @param $flags * @return bool + * @deprecated * */ 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(); + return $this->flags->setFlags($class, $flags); } /** @@ -2631,91 +1179,138 @@ class Modules extends WireArray { * @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 + * @deprecated * */ 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 $this->flags->setFlag($class, $flag, $add); + } + + /********************************************************************************************** + * MODULE INFO + * + */ + + /** + * Get data from the module info cache + * + * Returns array of module info if given a module ID or name. + * If module does not exist or info is not available, returns a blank array. + * If not given a module ID or name, it returns an array of all modules info. + * Returns value of property if given a property name, or null if not available. + * + * #pw-internal + * + * @param string|int|null $moduleID Module ID or name or omit to get info for all modules + * @param string $property + * @param bool $verbose + * @return array|mixed|null + * @since 3.0.218 + * + */ + public function moduleInfoCache($moduleID = null, $property = '', $verbose = false) { + return $this->info->moduleInfoCache($moduleID, $property, $verbose); } /** - * Return an array of other module class names that are uninstalled when the given one is + * Get data from the verbose module info cache + * + * #pw-internal + * + * @param int|string|null $moduleID + * @param string $property + * @return array|mixed|null + * + */ + public function moduleInfoCacheVerbose($moduleID = null, $property = '') { + return $this->info->moduleInfoCacheVerbose($moduleID, $property); + } + + + /** + * Get or set module ID from name/class * * #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 string|int|Module $name + * @param int|null|false $setID Optionally set module ID or false to unset + * @return int + * + */ + public function moduleID($name, $setID = null) { + if($setID !== null) { + if($setID === false) { + unset($this->moduleIDs[$name]); + } else { + $setID = (int) $setID; + $this->moduleIDs[$name] = $setID; + } + return $setID; + } + if(ctype_digit("$name")) return (int) $name; + if(!is_string($name)) return $this->getModuleID($name); + return isset($this->moduleIDs[$name]) ? $this->moduleIDs[$name] : 0; + } + + /** + * Get or set module name from ID * - * @param $class - * @return array + * #pw-internal + * + * @param int|string|Module $id + * @param string|null $setName + * @return string * */ - 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; + public function moduleName($id, $setName = null) { + if(!ctype_digit("$id")) { + if(is_string($id)) return $id; + $id = $this->getModuleID($id); } - - return $uninstalls; + $id = (int) $id; + if($setName) { + $this->moduleNames[$id] = (string) $setName; + return $setName; + } + return isset($this->moduleNames[$id]) ? $this->moduleNames[$id] : ''; } /** * Returns the database ID of a given module class, or 0 if not found * - * #pw-internal - * - * @param string|Module $class Module or module name + * @param string|int|Module $class Module, module name or ID * @return int * */ public function getModuleID($class) { $id = 0; + + if(ctype_digit("$class")) { + return (int) $class; + } else if(isset($this->moduleIDs["$class"])) { + return (int) $this->moduleIDs["$class"]; + } if(is_object($class)) { if($class instanceof Module) { - $class = $this->getModuleClass($class); + $class = $this->getModuleClass($class); + if(isset($this->moduleIDs[$class])) { + return (int) $this->moduleIDs[$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(is_string($info)) $info = $this->moduleInfoCache($key); // json to array - if($info['name'] == $class) { + foreach($this->info->moduleInfoCache as $key => $info) { + if(is_string($info)) { + $info = $this->info->moduleInfoCache($key); // json to array + } + if($info['name'] === $class) { $id = (int) $key; + $this->moduleIDs[$class] = $id; break; } } @@ -2724,7 +1319,7 @@ class Modules extends WireArray { } /** - * Returns the module's class name. + * Returns the module's class/name, optionally with namespace * * - 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. @@ -2755,8 +1350,8 @@ class Modules extends WireArray { return wireClassName($module, $withNamespace); } - } else if(is_int($module) || ctype_digit("$module")) { - $className = array_search((int) $module, $this->moduleIDs); + } else if(ctype_digit("$module")) { + $className = $this->moduleName((int) $module); } else if(is_string($module)) { @@ -2772,7 +1367,7 @@ class Modules extends WireArray { if(array_key_exists($module, $this->moduleIDs)) { $className = $module; - } else if(array_key_exists($module, $this->installable)) { + } else if(array_key_exists($module, $this->installableFiles)) { $className = $module; } } @@ -2782,7 +1377,7 @@ class Modules extends WireArray { if($namespace) { $className = "$namespace\\$className"; } else { - $className = $this->getModuleNamespace($className) . $className; + $className = $this->info->getModuleNamespace($className) . $className; } } return $className; @@ -2791,140 +1386,6 @@ class Modules extends WireArray { 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] - return array(); - } - */ - - /** - * 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 * @@ -2996,314 +1457,7 @@ class Modules extends WireArray { * */ public function getModuleInfo($class, array $options = array()) { - - $getAll = $class === '*' || $class === 'all'; - $getSystem = $class === 'ProcessWire' || $class === 'PHP' || $class === 'info'; - - $defaults = array( - 'verbose' => false, - 'minify' => $getAll, - 'noCache' => false, - 'noInclude' => false, - ); - - $options = array_merge($defaults, $options); - $info = array(); - $module = $class; - $moduleName = ''; - $moduleID = 0; - $fromCache = false; // was the data loaded from cache? - - if(!$getAll && !$getSystem) { - $moduleName = $this->getModuleClass($module); - $moduleID = (string) $this->getModuleID($module); // typecast to string for 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, - // verbose mode only: true when module implements SearchableModule interface, or null|false when not - 'searchable' => 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, - // verbose mode only: any translations supplied with the module - // 'languages' => 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($getAll) { - if(empty($this->moduleInfoCache)) $this->loadModuleInfoCache(); - $modulesInfo = $this->moduleInfoCache(); - if($options['verbose']) { - foreach($this->moduleInfoCacheVerbose() as $moduleID => $moduleInfoVerbose) { - if($options['noCache']) { - $modulesInfo[$moduleID] = $this->getModuleInfo($moduleID, $options); - } else { - $modulesInfo[$moduleID] = array_merge($modulesInfo[$moduleID], $moduleInfoVerbose); - } - } - } else if($options['noCache']) { - foreach(array_keys($modulesInfo) as $moduleID) { - $modulesInfo[$moduleID] = $this->getModuleInfo($moduleID, $options); - } - } - if(!$options['minify']) { - foreach($modulesInfo as $moduleID => $info) { - $modulesInfo[$moduleID] = array_merge($infoTemplate, $info); - } - } - return $modulesInfo; - - } else if($getSystem) { - // module is a system - if($class === 'info') return $infoTemplate; - $info = $this->getModuleInfoSystem($module); - return $options['minify'] ? $info : array_merge($infoTemplate, $info); - - } else 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 { - - // module is a class name or ID - if(ctype_digit("$module")) $module = $moduleName; - - // return from cache if available (as it almost always should be) - 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; - } - - if(!$options['minify']) $info = array_merge($infoTemplate, $info); - $info['id'] = (int) $moduleID; - - if($fromCache) { - // since cache is loaded at init(), this is the most common scenario - - 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($info['autoload'] === null) $info['autoload'] = false; - if($info['singular'] === null) $info['singular'] = false; - if($info['configurable'] === null) $info['configurable'] = false; - if($info['core'] === null) $info['core'] = false; - if($info['installed'] === null) $info['installed'] = true; - if($info['namespace'] === null) $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 { - // not from cache, only likely to occur when refreshing modules info caches - - // if $info[requires] 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 $info[installs] isn't already an array, make it one - 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 - if($options['verbose']) $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? - } else { - // module info may still contain verbose keys with undefined values - } - - if($options['minify']) { - // when minify, any values that match defaults from infoTemplate are removed - if(!$options['verbose']) { - foreach($this->moduleInfoVerboseKeys as $key) unset($info[$key]); - foreach($info as $key => $value) { - if(!array_key_exists($key, $infoTemplate)) continue; - if($value !== $infoTemplate[$key]) continue; - unset($info[$key]); - } - } - } - - // if($this->debug) $this->message("getModuleInfo($moduleName) " . ($fromCache ? "CACHE" : "NO-CACHE")); - - return $info; + return $this->info->getModuleInfo($class, $options); } /** @@ -3329,9 +1483,7 @@ class Modules extends WireArray { * */ public function getModuleInfoVerbose($class, array $options = array()) { - $options['verbose'] = true; - $info = $this->getModuleInfo($class, $options); - return $info; + return $this->info->getModuleInfoVerbose($class, $options); } /** @@ -3345,260 +1497,58 @@ class Modules extends WireArray { * */ public function getModuleInfoProperty($class, $property, array $options = array()) { - - if(empty($options['noCache'])) { - // shortcuts where possible - switch($property) { - case 'namespace': - return $this->getModuleNamespace($class); - case 'requires': - $v = $this->moduleInfoCache($class, 'requires'); - if(empty($v)) return array(); // early exist when known not to exist - break; // fallback to calling getModuleInfo - } - } - - if(in_array($property, $this->moduleInfoVerboseKeys)) { - $info = $this->getModuleInfoVerbose($class, $options); - $info['verbose'] = true; - } else { - $info = $this->getModuleInfo($class, $options); - } - if(!isset($info[$property]) && empty($info['verbose'])) { - // try again, just in case we can find it in verbose data - $info = $this->getModuleInfoVerbose($class, $options); - } - - return isset($info[$property]) ? $info[$property] : null; + return $this->info->getModuleInfoProperty($class, $property, $options); } - + /** * Get an array of all unique, non-default, non-root module namespaces mapped to directory names * * #pw-internal - * + * * @return array - * + * @deprecated Use $modules->info->getNamespaces() instead + * */ public function getNamespaces() { - $config = $this->wire()->config; - 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']] = $config->paths($moduleName); - } - $this->moduleNamespaceCache = $namespaces; - return $namespaces; + return $this->info->getNamespaces(); } - + /** * 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. (default=false) * - `noLoad` (bool): Specify true to prevent loading of file for namespace discovery. (default=false) Added 3.0.170 * @return null|string Returns namespace, or NULL if unable to determine. Namespace is ready to use in a string (i.e. has trailing slashes) - * + * @deprecated Use $modules->info->getModuleNamespace() instead + * */ public function getModuleNamespace($moduleName, $options = array()) { - - $defaults = array( - 'file' => null, - 'noLoad' => false, - 'noCache' => false, - ); - - $namespace = null; + return $this->info->getModuleNamespace($moduleName, $options); + } - if(is_object($moduleName) || strpos($moduleName, "\\") !== false) { - $className = is_object($moduleName) ? get_class($moduleName) : $moduleName; - if(strpos($className, "ProcessWire\\") === 0) return "ProcessWire\\"; - if(strpos($className, "\\") === false) return "\\"; - $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) { - if(isset($info['namespace'])) { - if("$info[namespace]" === "1") return __NAMESPACE__ . "\\"; - return $info['namespace']; - } else { - // if namespace not present in info then use default namespace - return __NAMESPACE__ . "\\"; - } - } - } - - $options = array_merge($defaults, $options); - - 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; - } - - if(empty($options['noLoad'])) { - $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); - } - - /** - * Return the URL where the module can be edited, configured or uninstalled - * - * If module is not installed, it returns URL to install the module. + * Is the given namespace a unique recognized module namespace? If yes, returns the path to it. If not, returns boolean false. * - * #pw-group-configuration - * - * @param string|Module $className - * @param bool $collapseInfo - * @return string - * - */ - public function getModuleEditUrl($className, $collapseInfo = true) { - if(!is_string($className)) $className = $this->getModuleClass($className); - $url = $this->wire()->config->urls->admin . 'module/'; - if(empty($className) || !$this->isInstalled($className)) return $url; - if(!$this->isInstalled($className)) return $this->getModuleInstallUrl($className); - $url .= "edit/?name=$className"; - if($collapseInfo) $url .= "&collapse_info=1"; - return $url; - } - - /** - * Get URL where an administrator can install given module name - * - * If module is already installed, it returns the URL to edit the module. - * - * #pw-group-configuration + * #pw-internal * - * @param string $className - * @return string - * @since 3.0.216 + * @param string $namespace + * @return bool|string + * @deprecated Use $modules->info->getNamespacePath() instead * */ - public function getModuleInstallUrl($className) { - if(!is_string($className)) $className = $this->getModuleClass($className); - $className = $this->wire()->sanitizer->fieldName($className); - if($this->isInstalled($className)) return $this->getModuleEditUrl($className); - return $this->wire()->config->urls->admin . "module/installConfirm?name=$className"; + public function getNamespacePath($namespace) { + return $this->info->getNamespacePath($namespace); } + + /********************************************************************************************** + * MODULE CONFIG + * + */ /** * Given a module name, return an associative array of configuration data for it @@ -3627,171 +1577,37 @@ class Modules extends WireArray { * */ public function getConfig($class, $property = '') { - - $emptyReturn = $property ? null : array(); - $className = $class; - if(is_object($className)) $className = wireClassName($className->className(), false); - if(!isset($this->moduleIDs[$className])) return $emptyReturn; - $id = $this->moduleIDs[$className]; - if(!$id) 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 { - $configable = $this->isConfigable($className); - if(!$configable) 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; + return $this->configs->getConfig($class, $property); } /** - * Get the path + filename (or optionally URL) for this module - * - * @param string|Module $class Module class name or object instance - * @param array|bool $options Options to modify default behavior: - * - `getURL` (bool): Specify true if you want to get the URL rather than file path (default=false). - * - `fast` (bool): Specify true to omit file_exists() checks (default=false). - * - `guess` (bool): Manufacture/guess a module location if one cannot be found (default=false) 3.0.170+ - * - Note: If you specify a boolean for the $options argument, it is assumed to be the $getURL property. - * @return bool|string Returns string of module file, or false on failure. - * + * Save provided configuration data for the given module + * + * - 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-group-manipulation + * #pw-changelog 3.0.16 Changed name from the more verbose saveModuleConfigData(), which will still work. + * + * @param string|Module $class Module or module name + * @param array|string $data Associative array of configuration data, or name of property you want to save. + * @param mixed|null $value If you specified a property in previous arg, the value for the property. + * @return bool True on success, false on failure + * @throws WireException + * @see Modules::getConfig() + * @since 3.0.16 Use method saveModuleConfigData() with same arguments for prior versions (can also be used on any version). + * */ - public function getModuleFile($class, $options = array()) { - - $config = $this->wire()->config; - $className = $class; - if(is_bool($options)) $options = array('getURL' => $options); - if(!isset($options['getURL'])) $options['getURL'] = false; - if(!isset($options['fast'])) $options['fast'] = false; - - $file = false; - - // first see it's an object, and if we can get the file from the object - if(is_object($className)) { - $module = $className; - if($module instanceof ModulePlaceholder) $file = $module->file; - $moduleName = $module->className(); - $className = $module->className(true); - } else { - $moduleName = wireClassName($className, false); - } - - $hasDuplicate = $this->duplicates()->hasDuplicate($moduleName); - - if(!$hasDuplicate) { - // see if we can determine it from already stored paths - $path = $config->paths($moduleName); - if($path) { - $file = $path . $moduleName . ($this->moduleFileExts[$moduleName] === 2 ? '.module.php' : '.module'); - if(!$options['fast'] && !file_exists($file)) $file = false; - } - } - - // next see if we've already got the module filename cached locally - if(!$file && isset($this->installable[$moduleName]) && !$hasDuplicate) { - $file = $this->installable[$moduleName]; - if(!$options['fast'] && !file_exists($file)) $file = false; - } - - if(!$file) { - $dupFile = $this->duplicates()->getCurrent($moduleName); - if($dupFile) { - $rootPath = $config->paths->root; - $file = rtrim($rootPath, '/') . $dupFile; - if(!file_exists($file)) { - // module in use may have been deleted, find the next available one that exists - $file = ''; - $dups = $this->duplicates()->getDuplicates($moduleName); - foreach($dups['files'] as $pathname) { - $pathname = rtrim($rootPath, '/') . $pathname; - if(file_exists($pathname)) $file = $pathname; - if($file) break; - } - } - } - } - - if(!$file) { - // see if it's a predefined core type that can be determined from the type - // this should only come into play if module has moved or had a load error - foreach($this->coreTypes as $typeName) { - if(strpos($moduleName, $typeName) !== 0) continue; - $checkFiles = array( - "$typeName/$moduleName/$moduleName.module", - "$typeName/$moduleName/$moduleName.module.php", - "$typeName/$moduleName.module", - "$typeName/$moduleName.module.php", - ); - $path1 = $config->paths->modules; - foreach($checkFiles as $checkFile) { - $file1 = $path1 . $checkFile; - if(file_exists($file1)) $file = $file1; - if($file) break; - } - if($file) break; - } - if(!$file) { - // check site modules - $checkFiles = array( - "$moduleName/$moduleName.module", - "$moduleName/$moduleName.module.php", - "$moduleName.module", - "$moduleName.module.php", - ); - $path1 = $config->paths->siteModules; - foreach($checkFiles as $checkFile) { - $file1 = $path1 . $checkFile; - if(file_exists($file1)) $file = $file1; - if($file) break; - } - } - } - - if(!$file) { - // if all the above failed, try to get it from Reflection - try { - // note we don't call getModuleClass() here because it may result in a circular reference - if(strpos($className, "\\") === false) { - $moduleID = $this->getModuleID($moduleName); - $namespace = $this->moduleInfoCache($moduleID, 'namespace'); - if(!empty($namespace)) { - $className = rtrim($namespace, "\\") . "\\$moduleName"; - } else { - $className = strlen(__NAMESPACE__) ? "\\" . __NAMESPACE__ . "\\$moduleName" : $moduleName; - } - } - $reflector = new \ReflectionClass($className); - $file = $reflector->getFileName(); - - } catch(\Exception $e) { - $file = false; - } - } - - if(!$file && !empty($options['guess'])) { - // make a guess about where module would be if we had been able to find it - $file = $config->paths('siteModules') . "$moduleName/$moduleName.module"; - } - - if($file) { - if(DIRECTORY_SEPARATOR != '/') $file = str_replace(DIRECTORY_SEPARATOR, '/', $file); - if($options['getURL']) $file = str_replace($config->paths->root, '/', $file); - } - - return $file; + public function ___saveConfig($class, $data, $value = null) { + return $this->configs->saveConfig($class, $data, $value); } /** @@ -3857,180 +1673,11 @@ class Modules extends WireArray { * @return bool|string|int See details about return values in method description. * @since 3.0.16 * - * @todo all ConfigurableModule methods need to be split out into their own class (ConfigurableModules?) - * @todo this method has two distinct parts (file and interface) that need to be split in two methods. - * */ public function isConfigurable($class, $useCache = true) { - - $className = $class; - $moduleInstance = null; - $namespace = $this->getModuleNamespace($className); - if(is_object($className)) { - $moduleInstance = $className; - $className = $this->getModuleClass($moduleInstance); - } - $nsClassName = $namespace . $className; - - if($useCache === true || $useCache === 1 || $useCache === "1") { - $info = $this->getModuleInfo($className); - // if regular module info doesn't have configurable info, attempt it from verbose module info - // should only be necessary for transition period between the 'configurable' property being - // moved from verbose to non-verbose module info (i.e. this line can be deleted after PW 2.7) - if($info['configurable'] === null) $info = $this->getModuleInfoVerbose($className); - if(!$info['configurable']) { - if($moduleInstance instanceof ConfigurableModule) { - // re-try because moduleInfo may be temporarily incorrect for this request because of change in moduleInfo format - // this is due to reports of ProcessChangelogHooks not getting config data temporarily between 2.6.11 => 2.6.12 - $this->error( - "Configurable module check failed for $className. " . - "If this error persists, please do a Modules > Refresh.", - Notice::debug - ); - $useCache = false; - } else { - return false; - } - } else { - if($info['configurable'] === true) return $info['configurable']; - if($info['configurable'] === 1 || $info['configurable'] === "1") return true; - if(is_int($info['configurable']) || ctype_digit("$info[configurable]")) return (int) $info['configurable']; - if(strpos($info['configurable'], $className) === 0) { - if(empty($info['file'])) $info['file'] = $this->getModuleFile($className); - if($info['file']) { - return dirname($info['file']) . "/$info[configurable]"; - } - } - } - } - - if($useCache !== "interface") { - // check for separate module configuration file - $dir = dirname($this->getModuleFile($className)); - if($dir) { - $files = array( - "$dir/{$className}Config.php", - "$dir/$className.config.php" - ); - $found = false; - foreach($files as $file) { - if(!is_file($file)) continue; - $config = null; // include file may override - $this->includeModuleFile($file, $className); - $classConfig = $nsClassName . 'Config'; - if(class_exists($classConfig, false)) { - $parents = wireClassParents($classConfig, false); - if(is_array($parents) && in_array('ModuleConfig', $parents)) { - $found = $file; - break; - } - } else { - // bypass include_once, because we need to read $config every time - if(is_null($config)) { - $classInfo = $this->getFileClassInfo($file); - if($classInfo['class']) { - // not safe to include because this is not just a file with a $config array - } else { - $ns = $this->getFileNamespace($file); - $file = $this->compile($className, $file, $ns); - if($file) { - /** @noinspection PhpIncludeInspection */ - include($file); - } - } - } - if(!is_null($config)) { - // included file specified a $config array - $found = $file; - break; - } - } - } - if($found) return $found; - } - } - - // if file-only check was requested and we reach this point, exit with false now - if($useCache === "file") return false; - - // ConfigurableModule interface checks - - $result = false; - - foreach(array('getModuleConfigArray', 'getModuleConfigInputfields') as $method) { - - $configurable = false; - - // if we have a module instance, use that for our check - if($moduleInstance instanceof ConfigurableModule) { - if(method_exists($moduleInstance, $method)) { - $configurable = $method; - } else if(method_exists($moduleInstance, "___$method")) { - $configurable = "___$method"; - } - } - - // if we didn't have a module instance, load the file to find what we need to know - if(!$configurable) { - if(!wireClassExists($nsClassName, false)) { - $this->includeModule($className); - } - $interfaces = wireClassImplements($nsClassName, false); - if(is_array($interfaces) && in_array('ConfigurableModule', $interfaces)) { - if(wireMethodExists($nsClassName, $method)) { - $configurable = $method; - } else if(wireMethodExists($nsClassName, "___$method")) { - $configurable = "___$method"; - } - } - } - - // if still not determined to be configurable, move on to next method - if(!$configurable) continue; - - // now determine if static or non-static - $ref = new \ReflectionMethod(wireClassName($nsClassName, true), $configurable); - - if($ref->isStatic()) { - // config method is implemented as a static method - if($method == 'getModuleConfigInputfields') { - // static getModuleConfigInputfields - $result = true; - } else { - // static getModuleConfigArray - $result = 20; - } - - } else if($method == 'getModuleConfigInputfields') { - // non-static getModuleConfigInputfields - // we allow for different arguments, so determine what it needs - $parameters = $ref->getParameters(); - if(count($parameters)) { - $param0 = reset($parameters); - if(strpos($param0, 'array') !== false || strpos($param0, '$data') !== false) { - // method requires a $data array (for compatibility with non-static version) - $result = 3; - } else if(strpos($param0, 'InputfieldWrapper') !== false || strpos($param0, 'inputfields') !== false) { - // method requires an empty InputfieldWrapper (as a convenience) - $result = 4; - } - } - // method requires no arguments - if(!$result) $result = 2; - - } else { - // non-static getModuleConfigArray - $result = 19; - } - - // if we make it here, we know we already have a result so can stop now - break; - } - - return $result; + return $this->configs->isConfigurable($class, $useCache); } - /** * Indicates whether module accepts config settings, whether interactively or API only * @@ -4038,6 +1685,8 @@ class Modules extends WireArray { * - Returns integer `30` if module accepts config settings but is not interactively configurable. * - Returns true, int or string if module is interactively configurable, see `Modules::isConfigurable()` return values. * + * #pw-internal + * * @param string|Module $class * @param bool $useCache * @return bool|int|string @@ -4045,17 +1694,7 @@ class Modules extends WireArray { * */ public function isConfigable($class, $useCache = true) { - if(is_object($class)) { - if($class instanceof ConfigModule) { - $result = 30; - } else { - $result = $this->isConfigurable($class, $useCache); - } - } else { - $result = $this->isConfigurable($class, $useCache); - if(!$result && wireInstanceOf($class, 'ConfigModule')) $result = 30; - } - return $result; + return $this->configs->isConfigable($class, $useCache); } /** @@ -4066,171 +1705,27 @@ class Modules extends WireArray { * @param $className * @param bool $useCache * @return bool|string|int + * @deprecated Please use isConfigurable() method instead * */ public function isConfigurableModule($className, $useCache = true) { - return $this->isConfigurable($className, $useCache); - } - - /** - * Populate configuration data to a ConfigurableModule - * - * If the Module has a 'setConfigData' method, it will send the array of data to that. - * Otherwise it will populate the properties individually. - * - * @param Module $module - * @param array|null $data Configuration data [key=value], or omit/null if you want it to retrieve the config data for you. - * @param array|null $extraData Additional runtime configuration data to merge (default=null) 3.0.169+ - * @return bool True if configured, false if not configurable - * - */ - protected function setModuleConfigData(Module $module, $data = null, $extraData = null) { - - $configurable = $this->isConfigable($module); - if(!$configurable) return false; - if(!is_array($data)) $data = $this->getConfig($module); - if(is_array($extraData)) $data = array_merge($data, $extraData); - - $nsClassName = $module->className(true); - $moduleName = $module->className(false); - - if(is_string($configurable) && is_file($configurable) && strpos(basename($configurable), $moduleName) === 0) { - // get defaults from ModuleConfig class if available - $className = $nsClassName . 'Config'; - $config = null; // may be overridden by included file - // $compile = strrpos($className, '\\') < 1 && $this->wire('config')->moduleCompile; - $configFile = ''; - - if(!class_exists($className, false)) { - $configFile = $this->compile($className, $configurable); - // $configFile = $compile ? $this->wire('files')->compile($configurable) : $configurable; - if($configFile) { - /** @noinspection PhpIncludeInspection */ - include_once($configFile); - } - } - - if(wireClassExists($className)) { - $parents = wireClassParents($className, false); - if(is_array($parents) && in_array('ModuleConfig', $parents)) { - $moduleConfig = $this->wire(new $className()); - if($moduleConfig instanceof ModuleConfig) { - $defaults = $moduleConfig->getDefaults(); - $data = array_merge($defaults, $data); - } - } - } else { - // the file may have already been include_once before, so $config would not be set - // so we try a regular include() next. - if(is_null($config)) { - if(!$configFile) { - $configFile = $this->compile($className, $configurable); - // $configFile = $compile ? $this->wire('files')->compile($configurable) : $configurable; - } - if($configFile) { - /** @noinspection PhpIncludeInspection */ - include($configFile); - } - } - if(is_array($config)) { - // alternatively, file may just specify a $config array - /** @var ModuleConfig $moduleConfig */ - $moduleConfig = $this->wire(new ModuleConfig()); - $moduleConfig->add($config); - $defaults = $moduleConfig->getDefaults(); - $data = array_merge($defaults, $data); - } - } - } - - if(method_exists($module, 'setConfigData') || method_exists($module, '___setConfigData')) { - /** @var _Module $module */ - $module->setConfigData($data); - return true; - } - - foreach($data as $key => $value) { - $module->$key = $value; - } - - return true; + return $this->configs->isConfigurable($className, $useCache); } /** * Alias of saveConfig() for backwards compatibility * + * * #pw-internal * * @param $className * @param array $configData * @return bool + * @deprecated Please use saveConfig() method instead * */ public function ___saveModuleConfigData($className, array $configData) { - return $this->saveConfig($className, $configData); - } - - /** - * Save provided configuration data for the given module - * - * - 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-group-manipulation - * #pw-changelog 3.0.16 Changed name from the more verbose saveModuleConfigData(), which will still work. - * - * @param string|Module $class Module or module name - * @param array|string $data Associative array of configuration data, or name of property you want to save. - * @param mixed|null $value If you specified a property in previous arg, the value for the property. - * @return bool True on success, false on failure - * @throws WireException - * @see Modules::getConfig() - * @since 3.0.16 Use method saveModuleConfigData() with same arguments for prior versions (can also be used on any version). - * - */ - public function ___saveConfig($class, $data, $value = null) { - $className = $class; - if(is_object($className)) $className = $className->className(); - $moduleName = wireClassName($className, false); - if(!$id = $this->moduleIDs[$moduleName]) throw new WireException("Unable to find ID for Module '$moduleName'"); - - if(is_string($data)) { - // a property and value have been provided - $property = $data; - $data = $this->getConfig($class); - if(is_null($value)) { - // remove the property - unset($data[$property]); - } else { - // populate the value for the property - $data[$property] = $value; - } - } else { - // data must be an associative array of configuration data - if(!is_array($data)) return false; - } - - // ensure original duplicates info is retained and validate that it is still current - $data = $this->duplicates()->getDuplicatesConfigData($moduleName, $data); - - $this->configData[$id] = $data; - $json = count($data) ? wireEncodeJSON($data, true) : ''; - $database = $this->wire()->database; - $query = $database->prepare("UPDATE modules SET data=:data WHERE id=:id", "modules.saveConfig($moduleName)"); // QA - $query->bindValue(":data", $json, \PDO::PARAM_STR); - $query->bindValue(":id", (int) $id, \PDO::PARAM_INT); - $result = $query->execute(); - // $this->log("Saved module '$moduleName' config data"); - - return $result; + return $this->configs->saveConfig($className, $configData); } /** @@ -4244,159 +1739,61 @@ class Modules extends WireArray { * */ public function ___getModuleConfigInputfields($moduleName, InputfieldWrapper $form = null) { - - $moduleName = $this->getModuleClass($moduleName); - $configurable = $this->isConfigurable($moduleName); - if(!$configurable) return null; - - /** @var InputfieldWrapper $form */ - if(is_null($form)) $form = $this->wire(new InputfieldWrapper()); - $data = $this->getConfig($moduleName); - $fields = null; - - // check for configurable module interface - $configurableInterface = $this->isConfigurable($moduleName, "interface"); - if($configurableInterface) { - if(is_int($configurableInterface) && $configurableInterface > 1 && $configurableInterface < 20) { - // non-static - /** @var ConfigurableModule|Module|_Module $module */ - if($configurableInterface === 2) { - // requires no arguments - $module = $this->getModule($moduleName); - $fields = $module->getModuleConfigInputfields(); - } else if($configurableInterface === 3) { - // requires $data array - $module = $this->getModule($moduleName, array('noInit' => true, 'noCache' => true)); - $this->setModuleConfigData($module); - $fields = $module->getModuleConfigInputfields($data); - } else if($configurableInterface === 4) { - // requires InputfieldWrapper - // we allow for option of no return statement in the method - $module = $this->getModule($moduleName); - $fields = $this->wire(new InputfieldWrapper()); /** @var InputfieldWrapper $fields */ - $fields->setParent($form); - $_fields = $module->getModuleConfigInputfields($fields); - if($_fields instanceof InputfieldWrapper) $fields = $_fields; - unset($_fields); - } else if($configurableInterface === 19) { - // non-static getModuleConfigArray method - $module = $this->getModule($moduleName); - $fields = $this->wire(new InputfieldWrapper()); /** @var InputfieldWrapper $fields */ - $fields->importArray($module->getModuleConfigArray()); - $fields->populateValues($module); - } - } else if($configurableInterface === 20) { - // static getModuleConfigArray method - $fields = $this->wire(new InputfieldWrapper()); /** @var InputfieldWrapper $fields */ - $fields->importArray(call_user_func(array(wireClassName($moduleName, true), 'getModuleConfigArray'))); - $fields->populateValues($data); - } else { - // static getModuleConfigInputfields method - $nsClassName = $this->getModuleNamespace($moduleName) . $moduleName; - $fields = call_user_func(array($nsClassName, 'getModuleConfigInputfields'), $data); - } - if($fields instanceof InputfieldWrapper) { - foreach($fields as $field) { - $form->append($field); - } - } else if($fields instanceof Inputfield) { - $form->append($fields); - } else { - $this->error("$moduleName.getModuleConfigInputfields() did not return InputfieldWrapper"); - } - } - - // check for file-based config - $file = $this->isConfigurable($moduleName, "file"); - if(!$file || !is_string($file) || !is_file($file)) { - // config is not file-based - } else { - // file-based config - $config = null; - $ns = $this->getModuleNamespace($moduleName); - $configClass = $ns . $moduleName . "Config"; - if(!class_exists($configClass)) { - $configFile = $this->compile($moduleName, $file, $ns); - if($configFile) { - /** @noinspection PhpIncludeInspection */ - include_once($configFile); - } - } - $configModule = null; - - if(wireClassExists($configClass)) { - // file contains a ModuleNameConfig class - $configModule = $this->wire(new $configClass()); - - } else { - if(is_null($config)) { - $configFile = $this->compile($moduleName, $file, $ns); - // if(!$configFile) $configFile = $compile ? $this->wire('files')->compile($file) : $file; - if($configFile) { - /** @noinspection PhpIncludeInspection */ - include($configFile); // in case of previous include_once - } - } - if(is_array($config)) { - // file contains a $config array - $configModule = $this->wire(new ModuleConfig()); - $configModule->add($config); - } - } - - if($configModule instanceof ModuleConfig) { - $defaults = $configModule->getDefaults(); - $data = array_merge($defaults, $data); - $configModule->setArray($data); - $fields = $configModule->getInputfields(); - if($fields instanceof InputfieldWrapper) { - foreach($fields as $field) { - $form->append($field); - } - foreach($data as $key => $value) { - $f = $form->getChildByName($key); - if(!$f) continue; - if($f instanceof InputfieldCheckbox && $value) { - $f->attr('checked', 'checked'); - } else { - $f->attr('value', $value); - } - } - } else { - $this->error("$configModule.getInputfields() did not return InputfieldWrapper"); - } - } - } // file-based config - - if($form) { - // determine how many visible Inputfields there are in the module configuration - // for assignment or removal of flagsNoUserConfig flag when applicable - $numVisible = 0; - foreach($form->getAll() as $inputfield) { - if($inputfield instanceof InputfieldHidden || $inputfield instanceof InputfieldWrapper) continue; - $numVisible++; - } - $flags = $this->getFlags($moduleName); - if($numVisible) { - if($flags & self::flagsNoUserConfig) { - $info = $this->getModuleInfoVerbose($moduleName); - if(empty($info['addFlag']) || !($info['addFlag'] & self::flagsNoUserConfig)) { - $this->setFlag($moduleName, self::flagsNoUserConfig, false); // remove flag - } - } - } else { - if(!($flags & self::flagsNoUserConfig)) { - if(empty($info['removeFlag']) || !($info['removeFlag'] & self::flagsNoUserConfig)) { - $this->setFlag($moduleName, self::flagsNoUserConfig, true); // add flag - } - } - } - } - - return $form; + return $this->configs->getModuleConfigInputfields($moduleName, $form); } - + /** + * Alias of getConfig() for backwards compatibility + * + * #pw-internal + * + * @param string|Module $className + * @return array + * @deprecated Please use getConfig() instead + * + */ + public function getModuleConfigData($className) { + return $this->configs->getConfig($className); + } + + /** + * Return the URL where the module can be edited, configured or uninstalled + * + * If module is not installed, it returns URL to install the module. + * + * #pw-group-configuration + * + * @param string|Module $className + * @param bool $collapseInfo + * @return string + * + */ + public function getModuleEditUrl($className, $collapseInfo = true) { + return $this->configs->getModuleEditUrl($className, $collapseInfo); + } + + /** + * Get URL where an administrator can install given module name + * + * If module is already installed, it returns the URL to edit the module. + * + * #pw-group-configuration + * + * @param string $className + * @return string + * @since 3.0.216 + * + */ + public function getModuleInstallUrl($className) { + return $this->installer()->getModuleInstallUrl($className); + } + + + /************************************************************************************************ + * TOOLS + * + */ + /** * Is the given module Singular (single instance)? * @@ -4414,14 +1811,14 @@ class Modules extends WireArray { * */ public function isSingular($module) { - $info = $this->getModuleInfo($module); + $info = $this->info->getModuleInfo($module); if(isset($info['singular'])) return $info['singular']; if(is_object($module)) { if(method_exists($module, 'isSingular')) return $module->isSingular(); } else { // singular status can't be determined if module not installed and not specified in moduleInfo - if(isset($this->installable[$module])) return null; - $this->includeModule($module); + if(isset($this->installableFiles[$module])) return null; + $this->loader->includeModule($module); $module = wireClassName($module, true); if(method_exists($module, 'isSingular')) { /** @var Module|_Module $moduleInstance */ @@ -4443,7 +1840,7 @@ class Modules extends WireArray { */ public function isAutoload($module) { - $info = $this->getModuleInfo($module); + $info = $this->info->getModuleInfo($module); $autoload = null; if(isset($info['autoload'])) { @@ -4452,19 +1849,19 @@ class Modules extends WireArray { $autoload = $info['autoload']; } else if(!is_object($module)) { - if(isset($this->installable[$module])) { + if(isset($this->installableFiles[$module])) { // module is not installed // we are not going to be able to determine if this is autoload or not - $flags = $this->getFlags($module); + $flags = $this->flags->getFlags($module); if($flags !== null) { - $autoload = $flags & self::flagsAutoload; + $autoload = $flags & Modules::flagsAutoload; } else { // unable to determine return null; } } else { // include for method exists call - $this->includeModule($module); + $this->loader->includeModule($module); $module = wireClassName($module, true); $module = $this->wire(new $module()); } @@ -4486,7 +1883,8 @@ class Modules extends WireArray { * @return bool * */ - public function isInitialized() { + public function isInitialized($set = null) { + if($set !== null) $this->initialized = $set ? true : false; return $this->initialized; } @@ -4521,14 +1919,14 @@ class Modules extends WireArray { if(isset($this->moduleIDs[$moduleName])) { $isModule = true; - } else if(isset($this->installable[$moduleName])) { + } else if(isset($this->installableFiles[$moduleName])) { $isModule = true; } else { $isModule = false; } if($isModule && $namespace) { - $actualNamespace = $this->getModuleNamespace($moduleName); + $actualNamespace = $this->info->getModuleNamespace($moduleName); if(trim("$namespace", '\\') != trim("$actualNamespace", '\\')) { $isModule = false; } @@ -4537,22 +1935,6 @@ class Modules extends WireArray { return $isModule; } - /** - * Is the given namespace a unique recognized module namespace? If yes, returns the path to it. If not, returns boolean false. - * - * #pw-internal - * - * @param string $namespace - * @return bool|string - * - */ - public function getNamespacePath($namespace) { - if($namespace === 'ProcessWire') return "ProcessWire\\"; - if(is_null($this->moduleNamespaceCache)) $this->getNamespaces(); - $namespace = "\\" . trim($namespace, "\\") . "\\"; - return isset($this->moduleNamespaceCache[$namespace]) ? $this->moduleNamespaceCache[$namespace] : false; - } - /** * Refresh the modules cache * @@ -4566,11 +1948,12 @@ class Modules extends WireArray { public function ___refresh($showMessages = false) { if($this->wire()->config->systemVersion < 6) return; $this->refreshing = true; - $this->clearModuleInfoCache($showMessages); - $this->loadModulesTable(); - foreach($this->paths as $path) $this->findModuleFiles($path, false); - foreach($this->paths as $path) $this->load($path); + $this->info->clearModuleInfoCache($showMessages); + $this->loader->loadModulesTable(); + foreach($this->paths as $path) $this->files->findModuleFiles($path, false); + foreach($this->paths as $path) $this->loader->loadPath($path); if($this->duplicates()->numNewDuplicates() > 0) $this->duplicates()->updateDuplicates(); // PR#1020 + $this->loader->loaded(); $this->refreshing = false; } @@ -4579,10 +1962,17 @@ class Modules extends WireArray { * * #pw-internal * + * @deprecated Use refresh() method instead + * */ public function resetCache() { $this->refresh(); } + + /************************************************************************************* + * DEPENDENCIES + * + */ /** * Return an array of module class names that require the given one @@ -4596,21 +1986,7 @@ class Modules extends WireArray { * */ public function getRequiredBy($class, $uninstalled = false, $installs = false) { - - $class = $this->getModuleClass($class); - $info = $this->getModuleInfo($class); - $dependents = array(); - - foreach($this as $module) { - $c = $this->getModuleClass($module); - if(!$uninstalled && !$this->isInstalled($c)) continue; - $i = $this->getModuleInfo($c); - if(!count($i['requires'])) continue; - if($installs && in_array($c, $info['installs'])) continue; - if(in_array($class, $i['requires'])) $dependents[] = $c; - } - - return $dependents; + return $this->installer()->getRequiredBy($class, $uninstalled, $installs); } /** @@ -4634,76 +2010,9 @@ class Modules extends WireArray { * */ public function getRequires($class, $onlyMissing = false, $versions = false) { - - $class = $this->getModuleClass($class); - $info = $this->getModuleInfo($class); - $requires = $info['requires']; - $currentVersion = 0; - - // quick exit if arguments permit it - if(!$onlyMissing) { - if($versions) foreach($requires as $key => $value) { - list($operator, $version) = $info['requiresVersions'][$value]; - if(empty($version)) continue; - if(ctype_digit("$version")) $version = $this->formatVersion($version); - if(!empty($version)) $requires[$key] .= "$operator$version"; - } - return $requires; - } - - foreach($requires as $key => $requiresClass) { - - if(in_array($requiresClass, $info['installs'])) { - // if this module installs the required class, then we can stop now - // and we assume it's installing the version it wants - unset($requires[$key]); - } - - list($operator, $requiresVersion) = $info['requiresVersions'][$requiresClass]; - $installed = true; - - if($requiresClass == 'PHP') { - $currentVersion = PHP_VERSION; - - } else if($requiresClass == 'ProcessWire') { - $currentVersion = $this->wire()->config->version; - - } else if($this->isInstalled($requiresClass)) { - if(!$requiresVersion) { - // if no version is specified then requirement is already met - unset($requires[$key]); - continue; - } - $i = $this->getModuleInfo($requiresClass, array('noCache' => true)); - $currentVersion = $i['version']; - } else { - // module is not installed - $installed = false; - } - - if($installed && $this->versionCompare($currentVersion, $requiresVersion, $operator)) { - // required version is installed - unset($requires[$key]); - - } else if(empty($requiresVersion)) { - // just the class name is fine - continue; - - } else if(is_null($versions)) { - // request is for no versions to be included (just class names) - $requires[$key] = $requiresClass; - - } else { - // update the requires string to clarify what version it requires - if(ctype_digit("$requiresVersion")) $requiresVersion = $this->formatVersion($requiresVersion); - $requires[$key] = "$requiresClass$operator$requiresVersion"; - } - } - - return $requires; + return $this->installer()->getRequires($class, $onlyMissing, $versions); } - /** * Compare one module version to another, returning TRUE if they match the $operator or FALSE otherwise * @@ -4716,66 +2025,7 @@ class Modules extends WireArray { * */ public function versionCompare($currentVersion, $requiredVersion, $operator) { - - if(ctype_digit("$currentVersion") && ctype_digit("$requiredVersion")) { - // integer comparison is ok - $currentVersion = (int) $currentVersion; - $requiredVersion = (int) $requiredVersion; - $result = false; - - switch($operator) { - case '=': $result = ($currentVersion == $requiredVersion); break; - case '>': $result = ($currentVersion > $requiredVersion); break; - case '<': $result = ($currentVersion < $requiredVersion); break; - case '>=': $result = ($currentVersion >= $requiredVersion); break; - case '<=': $result = ($currentVersion <= $requiredVersion); break; - case '!=': $result = ($currentVersion != $requiredVersion); break; - } - return $result; - } - - // if either version has no periods or only one, like "1.2" then format it to stanard: "1.2.0" - if(substr_count($currentVersion, '.') < 2) $currentVersion = $this->formatVersion($currentVersion); - if(substr_count($requiredVersion, '.') < 2) $requiredVersion = $this->formatVersion($requiredVersion); - - return version_compare($currentVersion, $requiredVersion, $operator); - } - - /** - * Return array of ($module, $operator, $requiredVersion) - * - * $version will be 0 and $operator blank if there are no requirements. - * - * @param string $require Module class name with operator and version string - * @return array of array($moduleClass, $operator, $version) - * - */ - protected function extractModuleOperatorVersion($require) { - - if(ctype_alnum($require)) { - // no version is specified - return array($require, '', 0); - } - - $operators = array('<=', '>=', '<', '>', '!=', '='); - $operator = ''; - foreach($operators as $o) { - if(strpos($require, $o)) { - $operator = $o; - break; - } - } - - // if no operator found, then no version is being specified - if(!$operator) return array($require, '', 0); - - // extract class and version - list($class, $version) = explode($operator, $require); - - // make version an integer if possible - if(ctype_digit("$version")) $version = (int) $version; - - return array($class, $operator, $version); + return $this->installer()->versionCompare($currentVersion, $requiredVersion, $operator); } /** @@ -4791,7 +2041,7 @@ class Modules extends WireArray { * */ public function getRequiresForInstall($class) { - return $this->getRequires($class, true); + return $this->installer()->getRequiresForInstall($class); } /** @@ -4807,7 +2057,7 @@ class Modules extends WireArray { * */ public function getRequiresForUninstall($class) { - return $this->getRequiredBy($class, false, true); + return $this->installer()->getRequiresForInstall($class); } /** @@ -4815,37 +2065,12 @@ class Modules extends WireArray { * * #pw-internal * - * @param $moduleName + * @param string $moduleName * @return array If no errors, array will be blank. If errors, array will be of strings (error messages) * */ public function getDependencyErrors($moduleName) { - - $moduleName = $this->getModuleClass($moduleName); - $info = $this->getModuleInfo($moduleName); - $errors = array(); - - if(empty($info['requires'])) return $errors; - - foreach($info['requires'] as $requiresName) { - $error = ''; - - if(!$this->isInstalled($requiresName)) { - $error = $requiresName; - - } else if(!empty($info['requiresVersions'][$requiresName])) { - list($operator, $version) = $info['requiresVersions'][$requiresName]; - $info2 = $this->getModuleInfo($requiresName); - $requiresVersion = $info2['version']; - if(!empty($version) && !$this->versionCompare($requiresVersion, $version, $operator)) { - $error = "$requiresName $operator $version"; - } - } - - if($error) $errors[] = sprintf($this->_('Failed module dependency: %s requires %s'), $moduleName, $error); - } - - return $errors; + return $this->installer()->getDependencyErrors($moduleName); } /** @@ -4872,49 +2097,7 @@ class Modules extends WireArray { * */ public function findMissingModules() { - - $missing = array(); - $unflags = array(); - - $sql = "SELECT id, class FROM modules WHERE flags & :flagsNoFile ORDER BY class"; - $query = $this->wire()->database->prepare($sql); - $query->bindValue(':flagsNoFile', self::flagsNoFile, \PDO::PARAM_INT); - $query->execute(); - - while($row = $query->fetch(\PDO::FETCH_ASSOC)) { - - $class = $row['class']; - - $file = $this->getModuleFile($class, array('fast' => true)); - - if($file && file_exists($file)) { - $unflags[] = $class; - continue; - } - - $fileAlt = $this->getModuleFile($class, array('fast' => false)); - - if($fileAlt) { - $file = $fileAlt; - if(file_exists($file)) continue; - } - - if(!$file) { - $file = $this->getModuleFile($class, array('fast' => true, 'guess' => true)); - } - - $missing[$class] = array( - 'id' => $row['id'], - 'name' => $class, - 'file' => $file, - ); - } - - foreach($unflags as $name) { - $this->setFlag($name, self::flagsNoFile, false); - } - - return $missing; + return $this->files->findMissingModules(); } /** @@ -4972,7 +2155,6 @@ class Modules extends WireArray { // as we can't really guess, but we'll try, converting 1234 to 1.2.34 } - // $version = preg_replace('/(\d)(?=\d)/', '$1.', $version); $version = substr($version, 0, 1) . '.' . substr($version, 1, 1) . '.' . @@ -4994,490 +2176,26 @@ class Modules extends WireArray { return $version; } - /** - * Load the module information cache - * - * @return bool - * - */ - protected function loadModuleInfoCache() { - - if(empty($this->modulesLastVersions)) { - $name = self::moduleLastVersionsCacheName; - $data = $this->getCache($name); - if(is_array($data)) $this->modulesLastVersions = $data; - } - - if(empty($this->moduleInfoCache)) { - $name = self::moduleInfoCacheName; - $data = $this->getCache($name); - // if module class name keys in use (i.e. ProcessModule) it's an older version of - // module info cache, so we skip over it to force its re-creation - if(is_array($data) && !isset($data['ProcessModule'])) { - $this->moduleInfoCache = $data; - return true; - } - return false; - } - - return true; - } - - /** - * Load the module information cache (verbose info: summary, author, href, file, core) - * - * @param bool $uninstalled If true, it will load the uninstalled verbose cache. - * @return bool - * - */ - protected function loadModuleInfoCacheVerbose($uninstalled = false) { - - $name = $uninstalled ? self::moduleInfoCacheUninstalledName : self::moduleInfoCacheVerboseName; - - $data = $this->getCache($name); - - if($data) { - if(is_array($data)) { - if($uninstalled) { - $this->moduleInfoCacheUninstalled = $data; - } else { - $this->moduleInfoCacheVerbose = $data; - } - } - return true; - } - - return false; - } - - /** - * Clear the module information cache - * - * @param bool|null $showMessages Specify true to show message notifications - * - */ - protected function clearModuleInfoCache($showMessages = false) { - - // $cache = $this->wire()->cache; - $versionChanges = array(); - $newModules = array(); - $moveModules = array(); - $missModules = array(); - - // record current module versions currently in moduleInfo - $moduleVersions = array(); - foreach($this->moduleInfoCache() as $id => $moduleInfo) { - if(isset($this->modulesLastVersions[$id])) { - $moduleVersions[$id] = $this->modulesLastVersions[$id]; - } else { - $moduleVersions[$id] = $moduleInfo['version']; - } - } - - // delete the caches - $this->deleteCache(self::moduleInfoCacheName); - $this->deleteCache(self::moduleInfoCacheVerboseName); - $this->deleteCache(self::moduleInfoCacheUninstalledName); - - $this->moduleInfoCache = array(); - $this->moduleInfoCacheVerbose = array(); - $this->moduleInfoCacheUninstalled = array(); - - // save new moduleInfo cache - $this->saveModuleInfoCache(); - - // compare new moduleInfo versions with the previous ones, looking for changes - foreach($this->moduleInfoCache() as $id => $moduleInfo) { - $moduleName = $moduleInfo['name']; - if(!isset($moduleVersions[$id])) { - if(isset($this->moduleIDs[$moduleName])) { - $moveModules[] = $moduleName; - } else { - $newModules[] = $moduleName; - } - continue; - } - if($moduleVersions[$id] != $moduleInfo['version']) { - $fromVersion = $this->formatVersion($moduleVersions[$id]); - $toVersion = $this->formatVersion($moduleInfo['version']); - $versionChanges[] = "$fromVersion => $toVersion: $moduleName"; - $this->modulesLastVersions[$id] = $moduleVersions[$id]; - if(strpos($moduleName, 'Fieldtype') === 0) { - // apply update now, to Fieldtype modules only (since they are loaded differently) - $this->getModule($moduleName); - } - } - } - - foreach($this->moduleIDs as $moduleName => $moduleID) { - if(isset($this->moduleInfoCache[$moduleID])) { - // module is present in moduleInfo - if($this->hasFlag($moduleID, self::flagsNoFile)) { - $file = $this->getModuleFile($moduleName, array('fast' => false)); - if($file) { - // remove flagsNoFile if file is found - $this->setFlag($moduleID, self::flagsNoFile, false); - } - } - } else { - // module is missing moduleInfo - $file = $this->getModuleFile($moduleName, array('fast' => false)); - if(!$file) { - $file = $this->getModuleFile($moduleName, array('fast' => true, 'guess' => true)); - // add flagsNoFile if file cannot be located - $missModules[] = "$moduleName => $file"; - $this->setFlag($moduleID, self::flagsNoFile, true); - } - } - } - - $this->updateModuleVersionsCache(); - - // report detected changes - $sanitizer = $this->wire()->sanitizer; - $reports = array( - array( - 'label' => $this->_('Found %d new module(s):'), - 'items' => $newModules, - ), - /* - array( - 'label' => $this->_('Found %d moved module(s):'), - 'items' => $moveModules, - ), - */ - array( - 'label' => $this->_('Found %d module(s) missing file:'), - 'items' => $missModules, - ), - array( - 'label' => $this->_('Found %d module version changes (applied when each module is loaded):'), - 'items' => $versionChanges, - ), - ); - - foreach($reports as $report) { - if(!count($report['items'])) continue; - if($showMessages) $this->message( - $sanitizer->entities1(sprintf($report['label'], count($report['items']))) . - '
' . $sanitizer->entities(implode("\n", $report['items'])) . '
', - Notice::allowMarkup | Notice::noGroup - ); - $this->log( - sprintf($report['label'], count($report['items'])) . ' ' . - implode(', ', $report['items']) - ); - } - } - - /** - * Update the cache of queued module version changes - * - */ - protected function updateModuleVersionsCache() { - foreach($this->modulesLastVersions as $id => $version) { - // clear out stale data, if present - if(!in_array($id, $this->moduleIDs)) unset($this->modulesLastVersions[$id]); - } - if(count($this->modulesLastVersions)) { - $this->saveCache(self::moduleLastVersionsCacheName, $this->modulesLastVersions); - } else { - $this->deleteCache(self::moduleLastVersionsCacheName); - } - } - - /** - * Check the module version to make sure it is consistent with our moduleInfo - * - * When not consistent, this triggers the moduleVersionChanged hook, which in turn - * triggers the $module->___upgrade($fromVersion, $toVersion) method. - * - * @param Module $module - * - */ - protected function checkModuleVersion(Module $module) { - $id = $this->getModuleID($module); - $moduleInfo = $this->getModuleInfo($module); - $lastVersion = isset($this->modulesLastVersions[$id]) ? $this->modulesLastVersions[$id] : null; - if(!is_null($lastVersion)) { - if($lastVersion != $moduleInfo['version']) { - $this->moduleVersionChanged($module, $lastVersion, $moduleInfo['version']); - unset($this->modulesLastVersions[$id]); - } - $this->updateModuleVersionsCache(); - } - } - /** * Hook called when a module's version changes * * This calls the module's ___upgrade($fromVersion, $toVersion) method. * + * #pw-internal + * * @param Module|_Module $module * @param int|string $fromVersion * @param int|string $toVersion * */ - protected function ___moduleVersionChanged(Module $module, $fromVersion, $toVersion) { - $moduleName = wireClassName($module, false); - $moduleID = $this->getModuleID($module); - $fromVersionStr = $this->formatVersion($fromVersion); - $toVersionStr = $this->formatVersion($toVersion); - $this->message($this->_('Upgrading module') . " ($moduleName: $fromVersionStr => $toVersionStr)"); - try { - if(method_exists($module, '___upgrade') || method_exists($module, 'upgrade')) { - $module->upgrade($fromVersion, $toVersion); - } - unset($this->modulesLastVersions[$moduleID]); - } catch(\Exception $e) { - $this->error("Error upgrading module ($moduleName): " . $e->getMessage()); - } + public function ___moduleVersionChanged(Module $module, $fromVersion, $toVersion) { + $this->info->moduleVersionChanged($module, $fromVersion, $toVersion); } - - /** - * Update module flags if any happen to differ from what's in the given moduleInfo - * - * @param $moduleID - * @param array $info - * - */ - protected function updateModuleFlags($moduleID, array $info) { - - $flags = (int) $this->getFlags($moduleID); - - if($info['autoload']) { - // module is autoload - if(!($flags & self::flagsAutoload)) { - // add autoload flag - $this->setFlag($moduleID, self::flagsAutoload, true); - } - if(is_string($info['autoload'])) { - // requires conditional flag - // value is either: "function", or the conditional string (like key=value) - if(!($flags & self::flagsConditional)) $this->setFlag($moduleID, self::flagsConditional, true); - } else { - // should not have conditional flag - if($flags & self::flagsConditional) $this->setFlag($moduleID, self::flagsConditional, false); - } - - } else if($info['autoload'] !== null) { - // module is not autoload - if($flags & self::flagsAutoload) { - // remove autoload flag - $this->setFlag($moduleID, self::flagsAutoload, false); - } - if($flags & self::flagsConditional) { - // remove conditional flag - $this->setFlag($moduleID, self::flagsConditional, false); - } - } - - if($info['singular']) { - if(!($flags & self::flagsSingular)) $this->setFlag($moduleID, self::flagsSingular, true); - } else { - if($flags & self::flagsSingular) $this->setFlag($moduleID, self::flagsSingular, false); - } - - // handle addFlag and removeFlag moduleInfo properties - foreach(array(0 => 'removeFlag', 1 => 'addFlag') as $add => $flagsType) { - if(empty($info[$flagsType])) continue; - if($flags & $info[$flagsType]) { - // already has the flags - if(!$add) { - // remove the flag(s) - $this->setFlag($moduleID, $info[$flagsType], false); - } - } else { - // does not have the flags - if($add) { - // add the flag(s) - $this->setFlag($moduleID, $info[$flagsType], true); - } - } - } - } - - /** - * Save the module information cache - * - */ - protected function saveModuleInfoCache() { - - if($this->debug) { - static $n = 0; - $this->message("saveModuleInfoCache (" . (++$n) . ")"); - } - - $this->moduleInfoCache = array(); - $this->moduleInfoCacheVerbose = array(); - $this->moduleInfoCacheUninstalled = array(); - - $user = $this->wire()->user; - $languages = $this->wire()->languages; - $language = null; - - if($languages) { - // switch to default language to prevent caching of translated title/summary data - $language = $user->language; - try { - if($language && $language->id && !$language->isDefault()) $user->language = $languages->getDefault(); // save - } catch(\Exception $e) { - $this->trackException($e, false, true); - } - } - foreach(array(true, false) as $installed) { - - $items = $installed ? $this : array_keys($this->installable); - - foreach($items as $module) { - - $class = is_object($module) ? $module->className() : $module; - $class = wireClassName($class, false); - $info = $this->getModuleInfo($class, array('noCache' => true, 'verbose' => true)); - $moduleID = (int) $info['id']; // note ID is always 0 for uninstalled modules - - if(!empty($info['error'])) { - if($this->debug) $this->warning("$class reported error: $info[error]"); - continue; - } - - if(!$moduleID && $installed) { - if($this->debug) $this->warning("No module ID for $class"); - continue; - } - - if(!$this->debug) unset($info['id']); // no need to double store this property since it is already the array key - - if(is_null($info['autoload'])) { - // module info does not indicate an autoload state - $info['autoload'] = $this->isAutoload($module); - - } else if(!is_bool($info['autoload']) && !is_string($info['autoload']) && wireIsCallable($info['autoload'])) { - // runtime function, identify it only with 'function' so that it can be recognized later as one that - // needs to be dynamically loaded - $info['autoload'] = 'function'; - } - - if(is_null($info['singular'])) { - $info['singular'] = $this->isSingular($module); - } - - if(is_null($info['configurable'])) { - $info['configurable'] = $this->isConfigurable($module, false); - } - - if($moduleID) $this->updateModuleFlags($moduleID, $info); - - if($installed) { - - $verboseKeys = $this->moduleInfoVerboseKeys; - $verboseInfo = array(); - - foreach($verboseKeys as $key) { - if(!empty($info[$key])) $verboseInfo[$key] = $info[$key]; - unset($info[$key]); // remove from regular moduleInfo - } - - $this->moduleInfoCache[$moduleID] = $info; - $this->moduleInfoCacheVerbose[$moduleID] = $verboseInfo; - - } else { - $this->moduleInfoCacheUninstalled[$class] = $info; - } - } - } - - $caches = array( - self::moduleInfoCacheName => 'moduleInfoCache', - self::moduleInfoCacheVerboseName => 'moduleInfoCacheVerbose', - self::moduleInfoCacheUninstalledName => 'moduleInfoCacheUninstalled', - ); - - $defaultNS = array("\\" . __NAMESPACE__ . "\\", __NAMESPACE__ . "\\"); - - foreach($caches as $cacheName => $varName) { - $data = $this->$varName; - foreach($data as $moduleID => $moduleInfo) { - foreach($moduleInfo as $key => $value) { - // remove unpopulated properties - if($key == 'installed') { - // no need to store an installed==true property - if($value) unset($data[$moduleID][$key]); - - } else if($key == 'requires' && !empty($value) && !empty($data[$moduleID]['requiresVersions'])) { - // requiresVersions has enough info to re-construct requires, so no need to store it - unset($data[$moduleID][$key]); - - } else if(($key == 'created' && empty($value)) - || ($value === 0 && ($key == 'singular' || $key == 'autoload' || $key == 'configurable')) - || ($value === null || $value === "" || $value === false) - || (is_array($value) && !count($value))) { - // no need to store these false, null, 0, or blank array properties - unset($data[$moduleID][$key]); - - } else if(($key === 'namespace' && in_array($value, $defaultNS)) || (!strlen(__NAMESPACE__) && empty($value))) { - // no need to cache default namespace in module info - unset($data[$moduleID][$key]); - - } else if($key == 'file') { - // file property is cached elsewhere so doesn't need to be included in this cache - unset($data[$moduleID][$key]); - } - } - } - $this->saveCache($cacheName, $data); - } - - // $this->log('Saved module info caches'); - - if($languages && $language) $user->language = $language; // restore - } - - /** - * Start a debug timer, only works when module debug mode is on ($this->debug) - * - * @param $note - * @return int|null Returns a key for the debug timer - * - */ - protected function debugTimerStart($note) { - if(!$this->debug) return null; - $key = count($this->debugLog); - while(isset($this->debugLog[$key])) $key++; - $this->debugLog[$key] = array( - 0 => Debug::timer("Modules$key"), - 1 => $note - ); - return $key; - } - - /** - * Stop a debug timer, only works when module debug mode is on ($this->debug) - * - * @param int $key The key returned by debugTimerStart + /************************************************************************************* + * SUBSTITUTES * */ - protected function debugTimerStop($key) { - if(!$this->debug) return; - $log = $this->debugLog[$key]; - $timerKey = $log[0]; - $log[0] = Debug::timer($timerKey); - $this->debugLog[$key] = $log; - Debug::removeTimer($timerKey); - } - - /** - * Return a log of module construct, init and ready times, active only when debug mode is on ($this->debug) - * - * #pw-internal - * - * @return array - * - */ - public function getDebugLog() { - return $this->debugLog; - } /** * Substitute one module for another, to be used only when $moduleName doesn't exist. @@ -5510,6 +2228,51 @@ class Modules extends WireArray { public function setSubstitutes(array $substitutes) { $this->substitutes = array_merge($this->substitutes, $substitutes); } + + /** + * Attempt to find a substitute for moduleName and return module if found or null if not + * + * #pw-internal + * + * @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; + } + + /************************************************************************************* + * FILES + * + */ + + /** + * Get the path + filename (or optionally URL) for this module + * + * @param string|Module $class Module class name or object instance + * @param array|bool $options Options to modify default behavior: + * - `getURL` (bool): Specify true if you want to get the URL rather than file path (default=false). + * - `fast` (bool): Specify true to omit file_exists() checks (default=false). + * - `guess` (bool): Manufacture/guess a module location if one cannot be found (default=false) 3.0.170+ + * - Note: If you specify a boolean for the $options argument, it is assumed to be the $getURL property. + * @return bool|string Returns string of module file, or false on failure. + * + */ + public function getModuleFile($class, $options = array()) { + return $this->files->getModuleFile($class, $options); + } /** * Load module related CSS and JS files (where applicable) @@ -5524,42 +2287,7 @@ class Modules extends WireArray { * */ public function loadModuleFileAssets($module) { - - $class = $this->getModuleClass($module); - static $classes = array(); - if(isset($classes[$class])) return 0; // already loaded - $config = $this->wire()->config; - $path = $config->paths($class); - $url = $config->urls($class); - $debug = $config->debug; - $version = 0; - $cnt = 0; - - foreach(array('styles' => 'css', 'scripts' => 'js') as $type => $ext) { - $fileURL = ''; - $modified = 0; - $file = "$path$class.$ext"; - $minFile = "$path$class.min.$ext"; - if(!$debug && is_file($minFile)) { - $fileURL = "$url$class.min.$ext"; - $modified = filemtime($minFile); - } else if(is_file($file)) { - $fileURL = "$url$class.$ext"; - $modified = filemtime($file); - } - if($fileURL) { - if(!$version) { - $info = $this->getModuleInfo($module, array('verbose' => false)); - $version = (int) isset($info['version']) ? $info['version'] : 0; - } - $config->$type->add("$fileURL?v=$version-$modified"); - $cnt++; - } - } - - $classes[$class] = true; - - return $cnt; + return $this->files->loadModuleFileAssets($module); } /** @@ -5571,38 +2299,27 @@ class Modules extends WireArray { * */ public function getModuleLanguageFiles($module) { - - $module = $this->getModuleClass($module); - if(empty($module)) return array(); - - $path = $this->wire()->config->paths($module); - if(empty($path)) return array(); - - $pathHidden = $path . '.languages/'; - $pathVisible = $path . 'languages/'; - - if(is_dir($pathVisible)) { - $path = $pathVisible; - } else if(is_dir($pathHidden)) { - $path = $pathHidden; - } else { - return array(); - } - - $items = array(); - $options = array( - 'extensions' => array('csv'), - 'recursive' => false, - 'excludeHidden' => true, - ); - - foreach($this->wire()->files->find($path, $options) as $file) { - $basename = basename($file, '.csv'); - $items[$basename] = $file; - } - - return $items; + return $this->files->getModuleLanguageFiles($module); } + + /** + * 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 + * @deprecated + * + */ + public function getFileNamespace($file) { + return $this->files->getFileNamespace($file); + } + + /************************************************************************************* + * DEBUG AND CLASS SUPPORT + * + */ /** * Enables use of $modules('ModuleName') @@ -5646,67 +2363,103 @@ class Modules extends WireArray { * */ public function error($text, $flags = 0) { - $this->log($text); + if(is_string($text)) $this->log($text); return parent::error($text, $flags); } + + /** + * Start a debug timer, only works when module debug mode is on ($this->debug) + * + * #pw-internal + * + * @param $note + * @return int|null Returns a key for the debug timer + * + */ + public function debugTimerStart($note) { + if(!$this->debug) return null; + $key = count($this->debugLog); + while(isset($this->debugLog[$key])) $key++; + $this->debugLog[$key] = array( + 0 => Debug::timer("Modules$key"), + 1 => $note + ); + return $key; + } /** - * Compile and return the given file for module, if allowed to do so + * Stop a debug timer, only works when module debug mode is on ($this->debug) * * #pw-internal - * - * @param Module|string $moduleName - * @param string $file Optionally specify the module filename as an optimization - * @param string|null $namespace Optionally specify namespace as an optimization - * @return string|bool + * + * @param int $key The key returned by debugTimerStart + * + */ + public function debugTimerStop($key) { + if(!$this->debug) return; + $log = $this->debugLog[$key]; + $timerKey = $log[0]; + $log[0] = Debug::timer($timerKey); + $this->debugLog[$key] = $log; + Debug::removeTimer($timerKey); + } + + /** + * Return a log of module construct, init and ready times, active only when debug mode is on ($this->debug) + * + * #pw-internal + * + * @return array + * + */ + public function getDebugLog() { + return $this->debugLog; + } + + public function getDebugData() { + return array( + 'installableFiles' => $this->installableFiles, + 'moduleIDs' => $this->moduleIDs, + 'moduleNames' => $this->moduleNames, + 'paths' => $this->paths, + 'substitutes' => $this->substitutes, + 'caches' => $this->caches, + 'loader' => $this->loader->getDebugData(), + 'configs' => $this->configs->getDebugData(), + 'installer' => $this->installer()->getDebugData(), + 'files' => $this->files->getDebugData(), + 'flags' => $this->flags->getDebugData(), + 'info' => $this->info->getDebugData(), + 'duplicates' => $this->duplicates()->getDebugData() + ); + } + + + /************************************************************************************* + * CACHES * */ - public function compile($moduleName, $file = '', $namespace = null) { - - static $allowCompile = null; - if($allowCompile === null) $allowCompile = $this->wire()->config->moduleCompile; - - // if not given a file, track it down - if(empty($file)) $file = $this->getModuleFile($moduleName); - // don't compile when module compilation is disabled - if(!$allowCompile) return $file; - - // don't compile core modules - if(strpos($file, $this->coreModulesDir) !== false) return $file; - - // if namespace not provided, get it - if(is_null($namespace)) { - if(is_object($moduleName)) { - $className = $moduleName->className(true); - $namespace = wireClassName($className, 1); - } else if(is_string($moduleName) && strpos($moduleName, "\\") !== false) { - $namespace = wireClassName($moduleName, 1); - } else { - $namespace = $this->getModuleNamespace($moduleName, array('file' => $file)); - } + /** + * Set a runtime memory cache + * + * @param string $name + * @param mixed $setValue + * @return bool|array|mixed|null + * + */ + public function memcache($name, $setValue = null) { + if($setValue) { + $this->caches[$name] = $setValue; + return true; } - - // determine if compiler should be used - if(__NAMESPACE__) { - $compile = $namespace === '\\' || empty($namespace); - } else { - $compile = trim($namespace, '\\') === 'ProcessWire'; - } - - // compile if necessary - if($compile) { - /** @var FileCompiler $compiler */ - $compiler = $this->wire(new FileCompiler(dirname($file))); - $compiledFile = $compiler->compile(basename($file)); - if($compiledFile) $file = $compiledFile; - } - - return $file; + return isset($this->caches[$name]) ? $this->caches[$name] : null; } /** * Save cache + * + * #pw-internal * * @param string $cacheName * @param string|array $data @@ -5714,7 +2467,7 @@ class Modules extends WireArray { * @since 3.0.218 * */ - protected function saveCache($cacheName, $data) { + public function saveCache($cacheName, $data) { $database = $this->wire()->database; if(!$this->saveCacheReady) { $this->saveCacheReady = true; @@ -5736,7 +2489,7 @@ class Modules extends WireArray { $query = $database->prepare($sql); $query->bindValue(':name', ".$cacheName"); $query->bindValue(':data', $data); - $query->bindValue(':flags', self::flagsSystemCache); + $query->bindValue(':flags', Modules::flagsSystemCache); return $query->execute(); } @@ -5744,13 +2497,15 @@ class Modules extends WireArray { /** * Get cache + * + * #pw-internal * * @param string $cacheName * @return string|array|bool * @since 3.0.218 * */ - protected function getCache($cacheName) { + public function getCache($cacheName) { $data = null; if(isset($this->caches[$cacheName])) { $data = $this->caches[$cacheName]; @@ -5777,13 +2532,40 @@ class Modules extends WireArray { /** * Delete cache by name + * + * #pw-internal * * @param string $cacheName * @since 3.0.218 * */ - protected function deleteCache($cacheName) { + public function deleteCache($cacheName) { $this->wire()->cache->delete($cacheName); unset($this->caches[$cacheName]); } + + /** + * Direct read-only properties + * + * @param string $name + * @return mixed + * + */ + public function __get($name) { + switch($name) { + case 'loader': return $this->loader; + case 'info': return $this->info; + case 'configs': return $this->configs; + case 'flags': return $this->flags; + case 'files': return $this->files; + case 'installableFiles': return $this->installableFiles; + case 'coreModulesDir': return $this->coreModulesDir; + case 'coreModulePath': return $this->paths[0]; + case 'siteModulePath': return isset($this->paths[1]) ? $this->paths[1] : ''; + case 'moduleIDs': return $this->moduleIDs; + case 'moduleNames': return $this->moduleNames; + case 'refreshing': return $this->refreshing; + } + return parent::__get($name); + } } diff --git a/wire/core/ModulesClass.php b/wire/core/ModulesClass.php new file mode 100644 index 00000000..56538b9c --- /dev/null +++ b/wire/core/ModulesClass.php @@ -0,0 +1,91 @@ +modules = $modules; + $modules->wire($this); + parent::__construct(); + } + + /** + * Convert given value to module ID + * + * @param string|int|Module $name + * @return int Returns 0 if module not found + * + */ + protected function moduleID($name) { + return $this->modules->moduleID($name); + } + + /** + * Convert given value to module name + * + * @param int|string|Module $id + * @return string Returns blank string if not found + * + */ + protected function moduleName($id) { + return $this->modules->moduleName($id); + } + + /** + * Save to the modules log + * + * @param string $str Message to log + * @param array|string $options Specify module name (string) or options array + * @return WireLog + * + */ + public function log($str, $options = array()) { + return $this->modules->log($str, $options); + } + + /** + * Record and log error message + * + * #pw-internal + * + * @param array|Wire|string $text + * @param int $flags + * @return Modules|WireArray + * + */ + public function error($text, $flags = 0) { + return $this->modules->error($text, $flags); + } + + public function getDebugData() { + return array(); + } +} diff --git a/wire/core/ModulesConfigs.php b/wire/core/ModulesConfigs.php new file mode 100644 index 00000000..172adfb7 --- /dev/null +++ b/wire/core/ModulesConfigs.php @@ -0,0 +1,721 @@ +configData[$moduleID] = $setData; + return array(); + } else if(isset($this->configData[$moduleID])) { + return $this->configData[$moduleID]; + } else { + return null; + } + } + + /** + * Return the URL where the module can be edited, configured or uninstalled + * + * If module is not installed, it returns URL to install the module. + * + * #pw-group-configuration + * + * @param string|Module $className + * @param bool $collapseInfo + * @return string + * + */ + public function getModuleEditUrl($className, $collapseInfo = true) { + if(!is_string($className)) $className = $this->modules->getModuleClass($className); + $url = $this->wire()->config->urls->admin . 'module/'; + if(empty($className)) return $url; + if(!$this->modules->isInstalled($className)) return $this->modules->getModuleInstallUrl($className); + $url .= "edit/?name=$className"; + if($collapseInfo) $url .= "&collapse_info=1"; + return $url; + } + + /** + * 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); + * + * // Getting just one property 'apiKey' from module config data + * @apiKey = $modules->getConfig('HelloWorld', 'apiKey'); + * ~~~~~~ + * + * #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|string|int|float Module configuration data, returns array unless a specific $property was requested + * @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); + + $id = $this->moduleID($className); + if(!$id) return $emptyReturn; + + $data = isset($this->configData[$id]) ? $this->configData[$id] : null; + if($data === null) return $emptyReturn; // module has no config data + + if(is_array($data)) { + // great + } else { + // configData===1 indicates data must be loaded from DB + $configable = $this->isConfigable($className); + if(!$configable) 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[(int) $id] = $data; + } + + if($property) return isset($data[$property]) ? $data[$property] : null; + + return $data; + } + + /** + * Is the given module interactively configurable? + * + * This method can be used to simply determine if a module is configurable (yes or no), or more specifically + * how it is configurable. + * + * ~~~~~ + * // Determine IF a module is configurable + * if($modules->isConfigurable('HelloWorld')) { + * // Module is configurable + * } else { + * // Module is NOT configurable + * } + * ~~~~~ + * ~~~~~ + * // Determine HOW a module is configurable + * $configurable = $module->isConfigurable('HelloWorld'); + * if($configurable === true) { + * // configurable in a way compatible with all past versions of ProcessWire + * } else if(is_string($configurable)) { + * // configurable via an external configuration file + * // file is identifed in $configurable variable + * } else if(is_int($configurable)) { + * // configurable via a method in the class + * // the $configurable variable contains a number with specifics + * } else { + * // module is NOT configurable + * } + * ~~~~~ + * + * ### Return value details + * + * #### If module is configurable via external configuration file: + * + * - Returns string of full path/filename to `ModuleName.config.php` file + * + * #### If module is configurable because it implements a configurable module interface: + * + * - Returns boolean `true` if module is configurable via the static `getModuleConfigInputfields()` method. + * This particular method is compatible with all past versions of ProcessWire. + * - Returns integer `2` if module is configurable via the non-static `getModuleConfigInputfields()` and requires no arguments. + * - Returns integer `3` if module is configurable via the non-static `getModuleConfigInputfields()` and requires `$data` array. + * - Returns integer `4` if module is configurable via the non-static `getModuleConfigInputfields()` and requires `InputfieldWrapper` argument. + * - Returns integer `19` if module is configurable via non-static `getModuleConfigArray()` method. + * - Returns integer `20` if module is configurable via static `getModuleConfigArray()` method. + * + * #### If module is not configurable: + * + * - Returns boolean `false` if not configurable + * + * *This method is named isConfigurableModule() in ProcessWire versions prior to to 3.0.16.* + * + * #pw-group-configuration + * + * @param Module|string $class Module name + * @param bool $useCache Use caching? This accepts a few options: + * - Specify boolean `true` to allow use of cache when available (default behavior). + * - Specify boolean `false` to disable retrieval of this property from getModuleInfo (forces a new check). + * - Specify string `interface` to check only if module implements ConfigurableModule interface. + * - Specify string `file` to check only if module has a separate configuration class/file. + * @return bool|string|int See details about return values in method description. + * @since 3.0.16 + * + * @todo this method has two distinct parts (file and interface) that need to be split in two methods. + * + */ + public function isConfigurable($class, $useCache = true) { + + $className = $class; + $moduleInstance = null; + $namespace = $this->modules->info->getModuleNamespace($className); + + if(is_object($className)) { + $moduleInstance = $className; + $className = $this->modules->getModuleClass($moduleInstance); + } + + $nsClassName = $namespace . $className; + + if($useCache === true || $useCache === 1 || $useCache === "1") { + $info = $this->modules->getModuleInfo($className); + // if regular module info doesn't have configurable info, attempt it from verbose module info + // should only be necessary for transition period between the 'configurable' property being + // moved from verbose to non-verbose module info (i.e. this line can be deleted after PW 2.7) + if($info['configurable'] === null) { + $info = $this->modules->getModuleInfoVerbose($className); + } + if(!$info['configurable']) { + if($moduleInstance instanceof ConfigurableModule) { + // re-try because moduleInfo may be temporarily incorrect for this request because of change in moduleInfo format + // this is due to reports of ProcessChangelogHooks not getting config data temporarily between 2.6.11 => 2.6.12 + $this->error( + "Configurable module check failed for $className. " . + "If this error persists, please do a Modules > Refresh.", + Notice::debug + ); + $useCache = false; + } else { + return false; + } + } else { + if($info['configurable'] === true) return $info['configurable']; + if($info['configurable'] === 1 || $info['configurable'] === "1") return true; + if(is_int($info['configurable']) || ctype_digit("$info[configurable]")) return (int) $info['configurable']; + if(strpos($info['configurable'], $className) === 0) { + if(empty($info['file'])) { + $info['file'] = $this->modules->files->getModuleFile($className); + } + if($info['file']) { + return dirname($info['file']) . "/$info[configurable]"; + } + } + } + } + + if($useCache !== "interface") { + // check for separate module configuration file + $dir = dirname($this->modules->files->getModuleFile($className)); + if($dir) { + $files = array( + "$dir/{$className}Config.php", + "$dir/$className.config.php" + ); + $found = false; + foreach($files as $file) { + if(!is_file($file)) continue; + $config = null; // include file may override + $this->modules->files->includeModuleFile($file, $className); + $classConfig = $nsClassName . 'Config'; + if(class_exists($classConfig, false)) { + $parents = wireClassParents($classConfig, false); + if(is_array($parents) && in_array('ModuleConfig', $parents)) { + $found = $file; + break; + } + } else { + // bypass include_once, because we need to read $config every time + if(is_null($config)) { + $classInfo = $this->modules->files->getFileClassInfo($file); + if($classInfo['class']) { + // not safe to include because this is not just a file with a $config array + } else { + $ns = $this->modules->files->getFileNamespace($file); + $file = $this->modules->files->compile($className, $file, $ns); + if($file) { + /** @noinspection PhpIncludeInspection */ + include($file); + } + } + } + if(!is_null($config)) { + // included file specified a $config array + $found = $file; + break; + } + } + } + if($found) return $found; + } + } + + // if file-only check was requested and we reach this point, exit with false now + if($useCache === "file") return false; + + // ConfigurableModule interface checks + + $result = false; + + foreach(array('getModuleConfigArray', 'getModuleConfigInputfields') as $method) { + + $configurable = false; + + // if we have a module instance, use that for our check + if($moduleInstance instanceof ConfigurableModule) { + if(method_exists($moduleInstance, $method)) { + $configurable = $method; + } else if(method_exists($moduleInstance, "___$method")) { + $configurable = "___$method"; + } + } + + // if we didn't have a module instance, load the file to find what we need to know + if(!$configurable) { + if(!wireClassExists($nsClassName, false)) { + $this->modules->includeModule($className); + } + $interfaces = wireClassImplements($nsClassName, false); + if(is_array($interfaces) && in_array('ConfigurableModule', $interfaces)) { + if(wireMethodExists($nsClassName, $method)) { + $configurable = $method; + } else if(wireMethodExists($nsClassName, "___$method")) { + $configurable = "___$method"; + } + } + } + + // if still not determined to be configurable, move on to next method + if(!$configurable) continue; + + // now determine if static or non-static + $ref = new \ReflectionMethod(wireClassName($nsClassName, true), $configurable); + + if($ref->isStatic()) { + // config method is implemented as a static method + if($method == 'getModuleConfigInputfields') { + // static getModuleConfigInputfields + $result = true; + } else { + // static getModuleConfigArray + $result = 20; + } + + } else if($method == 'getModuleConfigInputfields') { + // non-static getModuleConfigInputfields + // we allow for different arguments, so determine what it needs + $parameters = $ref->getParameters(); + if(count($parameters)) { + $param0 = reset($parameters); + if(strpos($param0, 'array') !== false || strpos($param0, '$data') !== false) { + // method requires a $data array (for compatibility with non-static version) + $result = 3; + } else if(strpos($param0, 'InputfieldWrapper') !== false || strpos($param0, 'inputfields') !== false) { + // method requires an empty InputfieldWrapper (as a convenience) + $result = 4; + } + } + // method requires no arguments + if(!$result) $result = 2; + + } else { + // non-static getModuleConfigArray + $result = 19; + } + + // if we make it here, we know we already have a result so can stop now + break; + } + + return $result; + } + + + /** + * Indicates whether module accepts config settings, whether interactively or API only + * + * - Returns false if module does not accept config settings. + * - Returns integer `30` if module accepts config settings but is not interactively configurable. + * - Returns true, int or string if module is interactively configurable, see `Modules::isConfigurable()` return values. + * + * @param string|Module $class + * @param bool $useCache + * @return bool|int|string + * @since 3.0.179 + * + */ + public function isConfigable($class, $useCache = true) { + if(is_object($class)) { + if($class instanceof ConfigModule) { + $result = 30; + } else { + $result = $this->isConfigurable($class, $useCache); + } + } else { + $result = $this->isConfigurable($class, $useCache); + if(!$result && wireInstanceOf($class, 'ConfigModule')) $result = 30; + } + return $result; + } + + /** + * Populate configuration data to a ConfigurableModule + * + * If the Module has a 'setConfigData' method, it will send the array of data to that. + * Otherwise it will populate the properties individually. + * + * @param Module $module + * @param array|null $data Configuration data [key=value], or omit/null if you want it to retrieve the config data for you. + * @param array|null $extraData Additional runtime configuration data to merge (default=null) 3.0.169+ + * @return bool True if configured, false if not configurable + * + */ + public function setModuleConfigData(Module $module, $data = null, $extraData = null) { + + $configurable = $this->isConfigable($module); + if(!$configurable) return false; + + if(!is_array($data)) $data = $this->getConfig($module); + if(is_array($extraData)) $data = array_merge($data, $extraData); + + $nsClassName = $module->className(true); + $moduleName = $module->className(false); + + if(is_string($configurable) && is_file($configurable) && strpos(basename($configurable), $moduleName) === 0) { + // get defaults from ModuleConfig class if available + $className = $nsClassName . 'Config'; + $config = null; // may be overridden by included file + // $compile = strrpos($className, '\\') < 1 && $this->wire('config')->moduleCompile; + $configFile = ''; + + if(!class_exists($className, false)) { + $configFile = $this->modules->files->compile($className, $configurable); + // $configFile = $compile ? $this->wire('files')->compile($configurable) : $configurable; + if($configFile) { + /** @noinspection PhpIncludeInspection */ + include_once($configFile); + } + } + + if(wireClassExists($className)) { + $parents = wireClassParents($className, false); + if(is_array($parents) && in_array('ModuleConfig', $parents)) { + $moduleConfig = $this->wire(new $className()); + if($moduleConfig instanceof ModuleConfig) { + $defaults = $moduleConfig->getDefaults(); + $data = array_merge($defaults, $data); + } + } + } else { + // the file may have already been include_once before, so $config would not be set + // so we try a regular include() next. + if(is_null($config)) { + if(!$configFile) { + $configFile = $this->modules->files->compile($className, $configurable); + // $configFile = $compile ? $this->wire('files')->compile($configurable) : $configurable; + } + if($configFile) { + /** @noinspection PhpIncludeInspection */ + include($configFile); + } + } + if(is_array($config)) { + // alternatively, file may just specify a $config array + /** @var ModuleConfig $moduleConfig */ + $moduleConfig = $this->wire(new ModuleConfig()); + $moduleConfig->add($config); + $defaults = $moduleConfig->getDefaults(); + $data = array_merge($defaults, $data); + } + } + } + + if(method_exists($module, 'setConfigData') || method_exists($module, '___setConfigData')) { + /** @var _Module $module */ + $module->setConfigData($data); + return true; + } + + foreach($data as $key => $value) { + $module->$key = $value; + } + + return true; + } + + /** + * Save provided configuration data for the given module + * + * - 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-group-manipulation + * #pw-changelog 3.0.16 Changed name from the more verbose saveModuleConfigData(), which will still work. + * + * @param string|Module $class Module or module name + * @param array|string $data Associative array of configuration data, or name of property you want to save. + * @param mixed|null $value If you specified a property in previous arg, the value for the property. + * @return bool True on success, false on failure + * @throws WireException + * @see Modules::getConfig() + * @since 3.0.16 Use method saveModuleConfigData() with same arguments for prior versions (can also be used on any version). + * + */ + public function saveConfig($class, $data, $value = null) { + + $className = $class; + if(is_object($className)) $className = $className->className(); + + $moduleName = wireClassName($className, false); + $id = $this->moduleID($moduleName); + + if(!$id) throw new WireException("Unable to find ID for Module '$moduleName'"); + + if(is_string($data)) { + // a property and value have been provided + $property = $data; + $data = $this->getConfig($class); + if(is_null($value)) { + // remove the property + unset($data[$property]); + } else { + // populate the value for the property + $data[$property] = $value; + } + } else { + // data must be an associative array of configuration data + if(!is_array($data)) return false; + } + + // ensure original duplicates info is retained and validate that it is still current + $data = $this->modules->duplicates()->getDuplicatesConfigData($moduleName, $data); + + $this->configData[$id] = $data; + $json = count($data) ? wireEncodeJSON($data, true) : ''; + $database = $this->wire()->database; + $query = $database->prepare("UPDATE modules SET data=:data WHERE id=:id", "modules.saveConfig($moduleName)"); // QA + $query->bindValue(":data", $json, \PDO::PARAM_STR); + $query->bindValue(":id", (int) $id, \PDO::PARAM_INT); + $result = $query->execute(); + // $this->log("Saved module '$moduleName' config data"); + + return $result; + } + + /** + * Get the Inputfields that configure the given module or return null if not configurable + * + * #pw-internal + * + * @param string|Module|int $moduleName + * @param InputfieldWrapper|null $form Optionally specify the form you want Inputfields appended to. + * @return InputfieldWrapper|null + * + */ + public function getModuleConfigInputfields($moduleName, InputfieldWrapper $form = null) { + + $moduleName = $this->modules->getModuleClass($moduleName); + $configurable = $this->isConfigurable($moduleName); + + if(!$configurable) return null; + + /** @var InputfieldWrapper $form */ + if(is_null($form)) $form = $this->wire(new InputfieldWrapper()); + + $data = $this->getConfig($moduleName); + $fields = null; + + // check for configurable module interface + $configurableInterface = $this->isConfigurable($moduleName, "interface"); + if($configurableInterface) { + if(is_int($configurableInterface) && $configurableInterface > 1 && $configurableInterface < 20) { + // non-static + /** @var ConfigurableModule|Module|_Module $module */ + if($configurableInterface === 2) { + // requires no arguments + $module = $this->modules->getModule($moduleName); + $fields = $module->getModuleConfigInputfields(); + } else if($configurableInterface === 3) { + // requires $data array + $module = $this->modules->getModule($moduleName, array('noInit' => true, 'noCache' => true)); + $this->setModuleConfigData($module); + $fields = $module->getModuleConfigInputfields($data); + } else if($configurableInterface === 4) { + // requires InputfieldWrapper + // we allow for option of no return statement in the method + $module = $this->modules->getModule($moduleName); + $fields = $this->wire(new InputfieldWrapper()); /** @var InputfieldWrapper $fields */ + $fields->setParent($form); + $_fields = $module->getModuleConfigInputfields($fields); + if($_fields instanceof InputfieldWrapper) $fields = $_fields; + unset($_fields); + } else if($configurableInterface === 19) { + // non-static getModuleConfigArray method + $module = $this->modules->getModule($moduleName); + $fields = $this->wire(new InputfieldWrapper()); /** @var InputfieldWrapper $fields */ + $fields->importArray($module->getModuleConfigArray()); + $fields->populateValues($module); + } + } else if($configurableInterface === 20) { + // static getModuleConfigArray method + $fields = $this->wire(new InputfieldWrapper()); /** @var InputfieldWrapper $fields */ + $fields->importArray(call_user_func(array(wireClassName($moduleName, true), 'getModuleConfigArray'))); + $fields->populateValues($data); + } else { + // static getModuleConfigInputfields method + $nsClassName = $this->modules->info->getModuleNamespace($moduleName) . $moduleName; + $fields = call_user_func(array($nsClassName, 'getModuleConfigInputfields'), $data); + } + if($fields instanceof InputfieldWrapper) { + foreach($fields as $field) { + $form->append($field); + } + } else if($fields instanceof Inputfield) { + $form->append($fields); + } else { + $this->error("$moduleName.getModuleConfigInputfields() did not return InputfieldWrapper"); + } + } + + // check for file-based config + $file = $this->isConfigurable($moduleName, "file"); + if(!$file || !is_string($file) || !is_file($file)) { + // config is not file-based + } else { + // file-based config + $config = null; + $ns = $this->modules->info->getModuleNamespace($moduleName); + $configClass = $ns . $moduleName . "Config"; + if(!class_exists($configClass)) { + $configFile = $this->modules->files->compile($moduleName, $file, $ns); + if($configFile) { + /** @noinspection PhpIncludeInspection */ + include_once($configFile); + } + } + $configModule = null; + + if(wireClassExists($configClass)) { + // file contains a ModuleNameConfig class + $configModule = $this->wire(new $configClass()); + + } else { + if(is_null($config)) { + $configFile = $this->modules->files->compile($moduleName, $file, $ns); + if($configFile) { + /** @noinspection PhpIncludeInspection */ + include($configFile); // in case of previous include_once + } + } + if(is_array($config)) { + // file contains a $config array + $configModule = $this->wire(new ModuleConfig()); + $configModule->add($config); + } + } + + if($configModule instanceof ModuleConfig) { + $defaults = $configModule->getDefaults(); + $data = array_merge($defaults, $data); + $configModule->setArray($data); + $fields = $configModule->getInputfields(); + if($fields instanceof InputfieldWrapper) { + foreach($fields as $field) { + $form->append($field); + } + foreach($data as $key => $value) { + $f = $form->getChildByName($key); + if(!$f) continue; + if($f instanceof InputfieldCheckbox && $value) { + $f->attr('checked', 'checked'); + } else { + $f->attr('value', $value); + } + } + } else { + $this->error("$configModule.getInputfields() did not return InputfieldWrapper"); + } + } + } // file-based config + + if($form) { + // determine how many visible Inputfields there are in the module configuration + // for assignment or removal of flagsNoUserConfig flag when applicable + $numVisible = 0; + foreach($form->getAll() as $inputfield) { + if($inputfield instanceof InputfieldHidden || $inputfield instanceof InputfieldWrapper) continue; + $numVisible++; + } + $flags = $this->modules->flags->getFlags($moduleName); + if($numVisible) { + if($flags & Modules::flagsNoUserConfig) { + $info = $this->modules->info->getModuleInfoVerbose($moduleName); + if(empty($info['addFlag']) || !($info['addFlag'] & Modules::flagsNoUserConfig)) { + $this->modules->flags->setFlag($moduleName, Modules::flagsNoUserConfig, false); // remove flag + } + } + } else { + if(!($flags & Modules::flagsNoUserConfig)) { + if(empty($info['removeFlag']) || !($info['removeFlag'] & Modules::flagsNoUserConfig)) { + $this->modules->flags->setFlag($moduleName, Modules::flagsNoUserConfig, true); // add flag + } + } + } + } + + return $form; + } + + public function getDebugData() { + return array( + 'configData' => $this->configData + ); + } + +} diff --git a/wire/core/ModulesDuplicates.php b/wire/core/ModulesDuplicates.php index 215d8f44..873465e5 100644 --- a/wire/core/ModulesDuplicates.php +++ b/wire/core/ModulesDuplicates.php @@ -40,6 +40,7 @@ class ModulesDuplicates extends Wire { * */ protected $numNewDuplicates = 0; + /** * Return quantity of new duplicates found while loading modules @@ -360,4 +361,11 @@ class ModulesDuplicates extends Wire { } return $configData; } + + public function getDebugData() { + return array( + 'duplicates' => $this->duplicates, + 'duplicatesUse' => $this->duplicatesUse + ); + } } diff --git a/wire/core/ModulesFiles.php b/wire/core/ModulesFiles.php new file mode 100644 index 00000000..59c6a29a --- /dev/null +++ b/wire/core/ModulesFiles.php @@ -0,0 +1,656 @@ +moduleFileExts[$class] = (int) $setValue; + return $setValue; + } + return isset($this->moduleFileExts[$class]) ? $this->moduleFileExts[$class] : 0; + } + + /** + * 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 + * + */ + public function findModuleFiles($path, $readCache = false, $level = 0) { + + static $startPath; + static $prependFiles = array(); + + $config = $this->wire()->config; + $cacheName = ''; + + if($level == 0) { + $startPath = $path; + $cacheName = "Modules." . str_replace($config->paths->root, '', $path); + if($readCache) { + $cacheContents = $this->modules->getCache($cacheName); + if($cacheContents) return explode("\n", trim($cacheContents)); + } + } + + $files = array(); + $autoloadOrders = $this->modules->loader->getAutoloadOrders(); + + if(count($autoloadOrders) && $path !== $config->paths->modules) { + // ok + } else { + $autoloadOrders = null; + } + + 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); + } + + 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 + $extension = $file->getExtension(); + if($extension !== 'module' && $extension !== 'php') continue; + list($moduleName, $extension) = explode('.', $filename, 2); + if($extension !== 'module' && $extension !== 'module.php') continue; + + $pathname = str_replace($startPath, '', $pathname); + + if($autoloadOrders !== null && isset($autoloadOrders[$moduleName])) { + $prependFiles[$pathname] = $autoloadOrders[$moduleName]; + } else { + $files[] = $pathname; + } + } + + if($level == 0 && $dir !== null) { + if(!empty($prependFiles)) { + // one or more non-core modules must be loaded first in a specific order + arsort($prependFiles); + $files = array_merge(array_keys($prependFiles), $files); + $prependFiles = array(); + } + if($cacheName) { + $this->modules->saveCache($cacheName, implode("\n", $files)); + } + } + + return $files; + } + + /** + * Get the path + filename (or optionally URL) for module + * + * @param string|Module $class Module class name or object instance + * @param array|bool $options Options to modify default behavior: + * - `getURL` (bool): Specify true if you want to get the URL rather than file path (default=false). + * - `fast` (bool): Specify true to omit file_exists() checks (default=false). + * - `guess` (bool): Manufacture/guess a module location if one cannot be found (default=false) 3.0.170+ + * - Note: If you specify a boolean for the $options argument, it is assumed to be the $getURL property. + * @return bool|string Returns string of module file, or false on failure. + * + */ + public function getModuleFile($class, $options = array()) { + + $config = $this->wire()->config; + $className = $class; + + if(is_bool($options)) $options = array('getURL' => $options); + if(!isset($options['getURL'])) $options['getURL'] = false; + if(!isset($options['fast'])) $options['fast'] = false; + + $file = false; + + // first see it's an object, and if we can get the file from the object + if(is_object($className)) { + $module = $className; + if($module instanceof ModulePlaceholder) $file = $module->file; + $moduleName = $module->className(); + $className = $module->className(true); + } else { + $moduleName = wireClassName($className, false); + } + + $hasDuplicate = $this->modules->duplicates()->hasDuplicate($moduleName); + + if(!$hasDuplicate) { + // see if we can determine it from already stored paths + $path = $config->paths($moduleName); + if($path) { + $file = $path . $moduleName . ($this->moduleFileExt($moduleName) === 2 ? '.module.php' : '.module'); + if(!$options['fast'] && !file_exists($file)) $file = false; + } + } + + // next see if we've already got the module filename cached locally + if(!$file) { + $installableFile = $this->modules->installableFile($moduleName); + if($installableFile && !$hasDuplicate) { + $file = $installableFile; + if(!$options['fast'] && !file_exists($file)) $file = false; + } + } + + if(!$file) { + $dupFile = $this->modules->duplicates()->getCurrent($moduleName); + if($dupFile) { + $rootPath = $config->paths->root; + $file = rtrim($rootPath, '/') . $dupFile; + if(!file_exists($file)) { + // module in use may have been deleted, find the next available one that exists + $file = ''; + $dups = $this->modules->duplicates()->getDuplicates($moduleName); + foreach($dups['files'] as $pathname) { + $pathname = rtrim($rootPath, '/') . $pathname; + if(file_exists($pathname)) $file = $pathname; + if($file) break; + } + } + } + } + + if(!$file) { + // see if it's a predefined core type that can be determined from the type + // this should only come into play if module has moved or had a load error + foreach($this->coreTypes as $typeName) { + if(strpos($moduleName, $typeName) !== 0) continue; + $checkFiles = array( + "$typeName/$moduleName/$moduleName.module", + "$typeName/$moduleName/$moduleName.module.php", + "$typeName/$moduleName.module", + "$typeName/$moduleName.module.php", + ); + $path1 = $config->paths->modules; + foreach($checkFiles as $checkFile) { + $file1 = $path1 . $checkFile; + if(file_exists($file1)) $file = $file1; + if($file) break; + } + if($file) break; + } + if(!$file) { + // check site modules + $checkFiles = array( + "$moduleName/$moduleName.module", + "$moduleName/$moduleName.module.php", + "$moduleName.module", + "$moduleName.module.php", + ); + $path1 = $config->paths->siteModules; + foreach($checkFiles as $checkFile) { + $file1 = $path1 . $checkFile; + if(file_exists($file1)) $file = $file1; + if($file) break; + } + } + } + + if(!$file) { + // if all the above failed, try to get it from Reflection + try { + // note we don't call getModuleClass() here because it may result in a circular reference + if(strpos($className, "\\") === false) { + $moduleID = $this->moduleID($moduleName); + $namespace = $this->modules->info->moduleInfoCache($moduleID, 'namespace'); + if(!empty($namespace)) { + $className = rtrim($namespace, "\\") . "\\$moduleName"; + } else { + $className = strlen(__NAMESPACE__) ? "\\" . __NAMESPACE__ . "\\$moduleName" : $moduleName; + } + } + $reflector = new \ReflectionClass($className); + $file = $reflector->getFileName(); + + } catch(\Exception $e) { + $file = false; + } + } + + if(!$file && !empty($options['guess'])) { + // make a guess about where module would be if we had been able to find it + $file = $config->paths('siteModules') . "$moduleName/$moduleName.module"; + } + + if($file) { + if(DIRECTORY_SEPARATOR != '/') $file = str_replace(DIRECTORY_SEPARATOR, '/', $file); + if($options['getURL']) $file = str_replace($config->paths->root, '/', $file); + } + + return $file; + } + + /** + * Include the given filename + * + * @param string $file + * @param string $moduleName + * @return bool + * + */ + public 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 true; + // next do a slower check, figuring out namespace + $ns = $this->modules->info->getModuleNamespace($moduleName, array('file' => $file)); + $className = trim($ns, "\\") . "\\$moduleName"; + if(class_exists($className, false)) return true; + // 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 */ + $success = @include_once($file); + } else { + $success = false; + } + + // set instance back, if multi-instance + if($wire1 !== $wire2) ProcessWire::setCurrentInstance($wire1); + + return (bool) $success; + } + + /** + * Compile and return the given file for module, if allowed to do so + * + * @param Module|string $moduleName + * @param string $file Optionally specify the module filename as an optimization + * @param string|null $namespace Optionally specify namespace as an optimization + * @return string|bool + * + */ + public function compile($moduleName, $file = '', $namespace = null) { + + static $allowCompile = null; + + if($allowCompile === null) $allowCompile = $this->wire()->config->moduleCompile; + + // if not given a file, track it down + if(empty($file)) $file = $this->modules->getModuleFile($moduleName); + + // don't compile when module compilation is disabled + if(!$allowCompile) return $file; + + // don't compile core modules + if(strpos($file, $this->modules->coreModulesDir) !== false) return $file; + + // if namespace not provided, get it + if(is_null($namespace)) { + if(is_object($moduleName)) { + $className = $moduleName->className(true); + $namespace = wireClassName($className, 1); + } else if(is_string($moduleName) && strpos($moduleName, "\\") !== false) { + $namespace = wireClassName($moduleName, 1); + } else { + $namespace = $this->modules->info->getModuleNamespace($moduleName, array('file' => $file)); + } + } + + // determine if compiler should be used + if(__NAMESPACE__) { + $compile = $namespace === '\\' || empty($namespace); + } else { + $compile = trim($namespace, '\\') === 'ProcessWire'; + } + + // compile if necessary + if($compile) { + /** @var FileCompiler $compiler */ + $compiler = $this->wire(new FileCompiler(dirname($file))); + $compiledFile = $compiler->compile(basename($file)); + if($compiledFile) $file = $compiledFile; + } + + return $file; + } + + /** + * Find modules that are missing their module file on the file system + * + * Return value is array: + * ~~~~~ + * [ + * 'ModuleName' => [ + * 'id' => 123, + * 'name' => 'ModuleName', + * 'file' => '/path/to/expected/file.module' + * ], + * 'ModuleName' => [ + * ... + * ] + * ]; + * ~~~~~ + * + * #pw-internal + * + * @return array + * @since 3.0.170 + * + */ + public function findMissingModules() { + + $missing = array(); + $unflags = array(); + + $sql = "SELECT id, class FROM modules WHERE flags & :flagsNoFile ORDER BY class"; + $query = $this->wire()->database->prepare($sql); + $query->bindValue(':flagsNoFile', Modules::flagsNoFile, \PDO::PARAM_INT); + $query->execute(); + + while($row = $query->fetch(\PDO::FETCH_ASSOC)) { + + $class = $row['class']; + + $file = $this->getModuleFile($class, array('fast' => true)); + + if($file && file_exists($file)) { + $unflags[] = $class; + continue; + } + + $fileAlt = $this->getModuleFile($class, array('fast' => false)); + + if($fileAlt) { + $file = $fileAlt; + if(file_exists($file)) continue; + } + + if(!$file) { + $file = $this->getModuleFile($class, array('fast' => true, 'guess' => true)); + } + + $missing[$class] = array( + 'id' => $row['id'], + 'name' => $class, + 'file' => $file, + ); + } + + foreach($unflags as $name) { + $this->modules->flags->setFlag($name, Modules::flagsNoFile, false); + } + + return $missing; + } + + /** + * Load module related CSS and JS files (where applicable) + * + * - Applies only to modules that carry class-named CSS and/or JS files, such as Process, Inputfield and ModuleJS modules. + * - Assets are populated to `$config->styles` and `$config->scripts`. + * + * #pw-internal + * + * @param Module|int|string $module Module object or class name + * @return int Returns number of files that were added + * + */ + public function loadModuleFileAssets($module) { + + $class = $this->modules->getModuleClass($module); + static $classes = array(); + if(isset($classes[$class])) return 0; // already loaded + $config = $this->wire()->config; + $path = $config->paths($class); + $url = $config->urls($class); + $debug = $config->debug; + $version = 0; + $cnt = 0; + + foreach(array('styles' => 'css', 'scripts' => 'js') as $type => $ext) { + $fileURL = ''; + $modified = 0; + $file = "$path$class.$ext"; + $minFile = "$path$class.min.$ext"; + if(!$debug && is_file($minFile)) { + $fileURL = "$url$class.min.$ext"; + $modified = filemtime($minFile); + } else if(is_file($file)) { + $fileURL = "$url$class.$ext"; + $modified = filemtime($file); + } + if($fileURL) { + if(!$version) { + $info = $this->modules->info->getModuleInfo($module, array('verbose' => false)); + $version = (int) isset($info['version']) ? $info['version'] : 0; + } + $config->$type->add("$fileURL?v=$version-$modified"); + $cnt++; + } + } + + $classes[$class] = true; + + return $cnt; + } + + /** + * Get module language translation files + * + * @param Module|string $module + * @return array Array of translation files including full path, indexed by basename without extension + * @since 3.0.181 + * + */ + public function getModuleLanguageFiles($module) { + + $module = $this->modules->getModuleClass($module); + if(empty($module)) return array(); + + $path = $this->wire()->config->paths($module); + if(empty($path)) return array(); + + $pathHidden = $path . '.languages/'; + $pathVisible = $path . 'languages/'; + + if(is_dir($pathVisible)) { + $path = $pathVisible; + } else if(is_dir($pathHidden)) { + $path = $pathHidden; + } else { + return array(); + } + + $items = array(); + $options = array( + 'extensions' => array('csv'), + 'recursive' => false, + 'excludeHidden' => true, + ); + + foreach($this->wire()->files->find($path, $options) as $file) { + $basename = basename($file, '.csv'); + $items[$basename] = $file; + } + + return $items; + } + + /** + * Setup entries in config->urls and config->paths for the given module + * + * @param string $moduleName + * @param string $path + * + */ + public 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 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; + } + + public function getDebugData() { + return array( + 'moduleFileExts' => $this->moduleFileExts + ); + } + +} diff --git a/wire/core/ModulesFlags.php b/wire/core/ModulesFlags.php new file mode 100644 index 00000000..bfac940d --- /dev/null +++ b/wire/core/ModulesFlags.php @@ -0,0 +1,200 @@ + flags (int) + * + * @var array + * + */ + protected $moduleFlags = array(); + + /** + * Get or set flags for module by module ID + * + * Omit all arguments to get flags for all modules indexed by module ID. + * + * Returns null if given module ID not found. + * + * @param int $moduleID This method only accepts module ID + * @param int $setValue Flag(s) to set + * @return array|mixed|null + * + */ + public function moduleFlags($moduleID = null, $setValue = null) { + if($moduleID === null) return $this->moduleFlags; + if(!ctype_digit("$moduleID")) $moduleID = $this->moduleID($moduleID); + if($setValue !== null) { + $this->moduleFlags[(int) $moduleID] = (int) $setValue; + } else if(isset($this->moduleFlags[$moduleID])) { + return $this->moduleFlags[$moduleID]; + } + return null; + } + + /** + * Get flags for the given module + * + * @param int|string|Module $id Module to add flag to + * @return int|false Returns integer flags on success, or boolean false on fail + * + */ + public function getFlags($id) { + $id = ctype_digit("$id") ? (int) $id : $this->modules->getModuleID($id); + 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; + } + + /** + * Does module have flag? + * + * #pw-internal + * + * @param int|string|Module $id Module ID, class name or instance + * @param int $flag + * @return bool + * @since 3.0.170 + * + */ + public function hasFlag($id, $flag) { + $flags = $this->getFlags($id); + return $flags === false ? false : ($flags & $flag); + } + + /** + * Set module flags + * + * #pw-internal + * + * @param string|int $id Module id or class + * @param $flags + * @return bool + * + */ + public function setFlags($id, $flags) { + $flags = (int) $flags; + $id = ctype_digit("$id") ? (int) $id : $this->modules->getModuleID($id); + 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->modules->getModuleClass($id) . ", " . $this->moduleFlags[$id] . " => $flags)"); + $this->moduleFlags[$id] = $flags; + return $query->execute(); + } + + /** + * Add or remove a flag from a module + * + * #pw-internal + * + * @param $id 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($id, $flag, $add = true) { + $id = ctype_digit("$id") ? (int) $id : $this->modules->getModuleID($id); + 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; + } + + /** + * Update module flags if any happen to differ from what's in the given moduleInfo + * + * @param int $moduleID + * @param array $info + * + */ + public function updateModuleFlags($moduleID, array $info) { + + $flags = (int) $this->getFlags($moduleID); + + if($info['autoload']) { + // module is autoload + if(!($flags & Modules::flagsAutoload)) { + // add autoload flag + $this->setFlag($moduleID, Modules::flagsAutoload, true); + } + if(is_string($info['autoload'])) { + // requires conditional flag + // value is either: "function", or the conditional string (like key=value) + if(!($flags & Modules::flagsConditional)) $this->setFlag($moduleID, Modules::flagsConditional, true); + } else { + // should not have conditional flag + if($flags & Modules::flagsConditional) $this->setFlag($moduleID, Modules::flagsConditional, false); + } + + } else if($info['autoload'] !== null) { + // module is not autoload + if($flags & Modules::flagsAutoload) { + // remove autoload flag + $this->setFlag($moduleID, Modules::flagsAutoload, false); + } + if($flags & Modules::flagsConditional) { + // remove conditional flag + $this->setFlag($moduleID, Modules::flagsConditional, false); + } + } + + if($info['singular']) { + if(!($flags & Modules::flagsSingular)) $this->setFlag($moduleID, Modules::flagsSingular, true); + } else { + if($flags & Modules::flagsSingular) $this->setFlag($moduleID, Modules::flagsSingular, false); + } + + // handle addFlag and removeFlag moduleInfo properties + foreach(array(0 => 'removeFlag', 1 => 'addFlag') as $add => $flagsType) { + if(empty($info[$flagsType])) continue; + if($flags & $info[$flagsType]) { + // already has the flags + if(!$add) { + // remove the flag(s) + $this->setFlag($moduleID, $info[$flagsType], false); + } + } else { + // does not have the flags + if($add) { + // add the flag(s) + $this->setFlag($moduleID, $info[$flagsType], true); + } + } + } + } + + public function getDebugData() { + return array( + 'moduleFlags' => $this->moduleFlags + ); + } +} diff --git a/wire/core/ModulesInfo.php b/wire/core/ModulesInfo.php new file mode 100644 index 00000000..db55f45e --- /dev/null +++ b/wire/core/ModulesInfo.php @@ -0,0 +1,1383 @@ + last known version (integer|string) + * + */ + protected $modulesLastVersions = array(); + + /** + * Cache of namespace => path for unique module namespaces (memory cache only) + * + * @var array|null Becomes an array once populated + * + */ + protected $moduleNamespaceCache = null; + + + /** + * Properties that only appear in 'verbose' moduleInfo + * + * @var array + * + */ + protected $moduleInfoVerboseKeys = array( + 'summary', + 'author', + 'href', + 'file', + 'core', + 'versionStr', + 'permissions', + 'searchable', + 'page', + 'license', + // 'languages', + ); + + /** + * Template for individual module info + * + * @var array + * + */ + protected $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, + // verbose mode only: true when module implements SearchableModule interface, or null|false when not + 'searchable' => 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, + // verbose mode only: any translations supplied with the module + // 'languages' => 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 + ); + + /** + * Replacement/default values to use when property is null + * + * @var array + * + */ + protected $infoNullReplacements = array( + 'autoload' => false, + 'singular' => false, + 'configurable' => false, + 'core' => false, + 'installed' => true, + 'namespace' => "\\ProcessWire\\", + ); + + /** + * Is the module info cache empty? + * + * @return bool + * + */ + public function moduleInfoCacheEmpty() { + return empty($this->moduleInfoCache); + } + + /** + * Does the module info cache have an entry for given module ID? + * + * @param int $moduleID + * @return bool + * + */ + public function moduleInfoCacheHas($moduleID) { + return isset($this->moduleInfoCache[$moduleID]); + } + + /** + * Get data from the module info cache + * + * Returns array of module info if given a module ID or name. + * If module does not exist or info is not available, returns a blank array. + * If not given a module ID or name, it returns an array of all modules info. + * Returns value of property if given a property name, or null if not available. + * + * #pw-internal + * + * @param string|int|null $moduleID Module ID or name or omit to get info for all modules + * @param string $property + * @param bool $verbose + * @return array|mixed|null + * @since 3.0.218 + * + */ + public function moduleInfoCache($moduleID = null, $property = '', $verbose = false) { + if($verbose) { + if(empty($this->moduleInfoCacheVerbose)) $this->loadModuleInfoCacheVerbose(); + $infos = &$this->moduleInfoCacheVerbose; + } else { + if(empty($this->moduleInfoCache)) $this->loadModuleInfoCache(); + $infos = &$this->moduleInfoCache; + } + if($moduleID === null) { + // get all + foreach($infos as $moduleID => $info) { + if(empty($info)) { + $info = array(); + } else if(is_array($info)) { + continue; + } else { + $info = json_decode($info, true); + } + $infos[$moduleID] = $info; + } + return $infos; + } else if($moduleID === 0) { + return $property ? null : array(); + } + if(!ctype_digit("$moduleID")) { + // convert module name to module id + $moduleID = $this->moduleID($moduleID); + if(!$moduleID) return ($property ? null : array()); + } + $moduleID = (int) $moduleID; + if(!isset($infos[$moduleID])) return ($property ? null : array()); + $info = $infos[$moduleID]; + if(empty($info)) return ($property ? null : array()); + if(is_string($info)) { + $info = json_decode($info, true); + if(!is_array($info)) $info = array(); + $infos[$moduleID] = $info; + } + if($property) return isset($info[$property]) ? $info[$property] : null; + return $info; + } + + /** + * Get data from the verbose module info cache + * + * #pw-internal + * + * @param int|string|null $moduleID + * @param string $property + * @return array|mixed|null + * + */ + public function moduleInfoCacheVerbose($moduleID = null, $property = '') { + return $this->moduleInfoCache($moduleID, $property, true); + } + + /** + * Retrieve module info from ModuleName.info.json or ModuleName.info.php + * + * @param string $moduleName + * @return array + * + */ + public function getModuleInfoExternal($moduleName) { + + // ...attempt to load info by info file (Module.info.php or Module.info.json) + $path = $this->modules->installableFile($moduleName); + if(!empty($path)) { + $path = dirname($path) . '/'; + } 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 + * + */ + public function getModuleInfoInternal($module, $namespace = '') { + + $info = array(); + + if($module instanceof ModulePlaceholder) { + $this->modules->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->modules->includeModule($module); + if(is_callable("$className::getModuleInfo")) { + $info = call_user_func(array($className, 'getModuleInfo')); + } + } + + return $info; + } + + /** + * Retrieve module info for system properties: PHP or ProcessWire + * + * @param string $moduleName + * @param array $options + * @return array + * + */ + public function getModuleInfoSystem($moduleName, array $options = array()) { + + $info = array(); + + if($moduleName === 'PHP') { + $info['id'] = 0; + $info['name'] = $moduleName; + $info['title'] = $moduleName; + $info['version'] = PHP_VERSION; + + } else if($moduleName === 'ProcessWire') { + $info['id'] = 0; + $info['name'] = $moduleName; + $info['title'] = $moduleName; + $info['version'] = $this->wire()->config->version; + $info['namespace'] = self::defaultNamespace; + $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']; + + if(empty($options['minify'])) $info = array_merge($this->infoTemplate, $info); + + 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 may be present but with empty values: + * + * - `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, and only if specified by module. + * 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 + * + * On error, an `error` index in returned array contains error message. You can also identify errors + * such as a non-existing module by the returned module info having an `id` index of `0` + * + * ~~~~~ + * // example of getting module info + * $moduleInfo = $modules->getModuleInfo('InputfieldCKEditor'); + * + * // example of getting verbose module info + * $moduleInfo = $modules->getModuleInfoVerbose('MarkupAdminDataTable'); + * ~~~~~ + * + * @param string|Module|int $class Specify one of the following: + * - Module object instance + * - Module class name (string) + * - Module ID (int) + * - To get info for ALL modules, specify `*` or `all`. + * - To get system information, specify `ProcessWire` or `PHP`. + * - To get a blank module info template, specify `info`. + * @param array $options Optional options to modify behavior of what gets returned + * - `verbose` (bool): Makes the info also include verbose properties, which are otherwise blank. (default=false) + * - `minify` (bool): Remove non-applicable and properties that match defaults? (default=false, or true when getting `all`) + * - `noCache` (bool): prevents use of cache to retrieve the module info. (default=false) + * @return array Associative array of module information. + * - On error, an `error` index is also populated with an error message. + * - When requesting a module that does not exist its `id` value will be `0` and its `name` will be blank. + * @see self::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($class === 'info') return $this->infoTemplate; + if($class === '*' || $class === 'all') return $this->getModuleInfoAll($options); + if($class === 'ProcessWire' || $class === 'PHP') return $this->getModuleInfoSystem($class, $options); + + $defaults = array( + 'verbose' => false, + 'minify' => false, + 'noCache' => false, + 'noInclude' => false, + ); + + $options = array_merge($defaults, $options); + $info = array(); + $module = $class; + $fromCache = false; // was the data loaded from cache? + $moduleName = $this->moduleName($module); + $moduleID = (string) $this->moduleID($moduleName); // typecast to string for cache + + if($module instanceof Module) { + // module is an instance + // 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 { + // module is a class name or ID + if(ctype_digit("$module")) $module = $moduleName; + + // return from cache if available (as it almost always should be) + if(empty($options['noCache']) && !empty($this->moduleInfoCache[$moduleID])) { + $info = $this->moduleInfoCache($moduleID); + $fromCache = true; + + } else if(empty($options['noCache']) && $moduleID == 0) { + // uninstalled module + if(empty($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(empty($info)) $info = $this->getModuleInfoInternal($moduleName, $namespace); + + } else { + // module is not in memory, check external first, then internal + $info = $this->getModuleInfoExternal($moduleName); + if(empty($info)) { + $installableFile = $this->modules->installableFile($moduleName); + if($installableFile) { + $this->modules->files->includeModuleFile($installableFile, $moduleName); + } + // info not available externally, attempt to locate it interally + $info = $this->getModuleInfoInternal($moduleName, $namespace); + } + } + } + } + + if(!$fromCache && empty($info)) { + return array_merge($this->infoTemplate, array( + 'title' => $module, + 'summary' => 'Inactive', + 'error' => 'Unable to locate module', + )); + } + + $info['id'] = (int) $moduleID; + + if(!$options['minify']) $info = array_merge($this->infoTemplate, $info); + + if($fromCache) { + // since cache is loaded at init(), this is the most common scenario + + 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 + foreach($this->infoNullReplacements as $key => $value) { + if($info[$key] === null) $info[$key] = $value; + } + + 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 { + // not from cache, only likely to occur when refreshing modules info caches + + // if $info[requires] 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 $info[installs] isn't already an array, make it one + 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 + if($options['verbose']) { + $info['versionStr'] = $this->modules->formatVersion($info['version']); // versionStr + } + + $info['name'] = $moduleName; // module name + + // module configurable? + $configurable = $this->modules->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 + $createdDate = $this->modules->loader->createdDate($moduleID); + if($createdDate) $info['created'] = strtotime($createdDate); + + $installableFile = $this->modules->installableFile($moduleName); + $info['installed'] = $installableFile ? false : true; + + if(!$info['installed'] && !$info['created'] && $installableFile) { + // uninstalled modules get their created date from the file or dir that they are in (whichever is newer) + $pathname = $installableFile; + $filemtime = (int) filemtime($pathname); + $dirname = dirname($pathname); + $coreModulesPath = $this->modules->coreModulesPath; + $dirmtime = substr($dirname, -7) == 'modules' || strpos($dirname, $coreModulesPath) !== 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'] = self::defaultNamespace; + } 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($info['namespace'] === null) $info['namespace'] = self::defaultNamespace; + + if(empty($info['created'])) { + $createdDate = $this->modules->loader->createdDate($moduleID); + if($createdDate) { + $info['created'] = strtotime($createdDate); + } + } + + if($options['verbose']) { + // the file property is not stored in the verbose cache, but provided as a verbose key + $info['file'] = $this->modules->getModuleFile($moduleName); + if($info['file']) $info['core'] = strpos($info['file'], $this->modules->coreModulesDir) !== false; // is it core? + } else { + // module info may still contain verbose keys with undefined values + } + + if($options['minify']) { + // when minify, any values that match defaults from infoTemplate are removed + if(!$options['verbose']) { + foreach($this->moduleInfoVerboseKeys as $key) unset($info[$key]); + foreach($info as $key => $value) { + if(!array_key_exists($key, $this->infoTemplate)) continue; + if($value !== $this->infoTemplate[$key]) continue; + unset($info[$key]); + } + } + } + + return $info; + } + + /** + * Get info arrays for all modules indexed by module name + * + * @param array $options See options for getModuleInfo() method + * @return array + * + */ + public function getModuleInfoAll(array $options = array()) { + $defaults = array( + 'verbose' => false, + 'noCache' => false, + 'minify' => true, + ); + $options = array_merge($defaults, $options); + if(!count($this->moduleInfoCache)) $this->loadModuleInfoCache(); + $modulesInfo = $this->moduleInfoCache(); + if($options['verbose']) { + foreach($this->moduleInfoCacheVerbose() as $moduleID => $moduleInfoVerbose) { + if($options['noCache']) { + $modulesInfo[$moduleID] = $this->getModuleInfo($moduleID, $options); + } else { + $modulesInfo[$moduleID] = array_merge($modulesInfo[$moduleID], $moduleInfoVerbose); + } + } + } else if($options['noCache']) { + foreach(array_keys($modulesInfo) as $moduleID) { + $modulesInfo[$moduleID] = $this->getModuleInfo($moduleID, $options); + } + } + if(!$options['minify']) { + foreach($modulesInfo as $moduleID => $info) { + $modulesInfo[$moduleID] = array_merge($this->infoTemplate, $info); + } + } + return $modulesInfo; + } + + /** + * 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 + * @see Modules::getModuleInfo() + * + */ + public function getModuleInfoVerbose($class, array $options = array()) { + $options['verbose'] = true; + $info = $this->getModuleInfo($class, $options); + return $info; + } + + /** + * Get just a single property of module info + * + * @param Module|string $class Module instance or module name + * @param string $property Name of property to get + * @param array $options Additional options (see getModuleInfo method for options) + * @return mixed|null Returns value of property or null if not found + * @since 3.0.107 + * + */ + public function getModuleInfoProperty($class, $property, array $options = array()) { + + if(empty($options['noCache'])) { + // shortcuts where possible + switch($property) { + case 'namespace': + return $this->getModuleNamespace($class); + case 'requires': + $v = $this->moduleInfoCache($class, 'requires'); + if(empty($v)) return array(); // early exit when known not to exist + break; // fallback to calling getModuleInfo + } + } + + if(in_array($property, $this->moduleInfoVerboseKeys)) { + $info = $this->getModuleInfoVerbose($class, $options); + $info['verbose'] = true; + } else { + $info = $this->getModuleInfo($class, $options); + } + if(!isset($info[$property]) && empty($info['verbose'])) { + // try again, just in case we can find it in verbose data + $info = $this->getModuleInfoVerbose($class, $options); + } + + return isset($info[$property]) ? $info[$property] : null; + } + + /** + * Return array of ($module, $operator, $requiredVersion) + * + * $version will be 0 and $operator blank if there are no requirements. + * + * @param string $require Module class name with operator and version string + * @return array of array($moduleClass, $operator, $version) + * + */ + protected function extractModuleOperatorVersion($require) { + + if(ctype_alnum($require)) { + // no version is specified + return array($require, '', 0); + } + + $operators = array('<=', '>=', '<', '>', '!=', '='); + $operator = ''; + foreach($operators as $o) { + if(strpos($require, $o)) { + $operator = $o; + break; + } + } + + // if no operator found, then no version is being specified + if(!$operator) return array($require, '', 0); + + // extract class and version + list($class, $version) = explode($operator, $require); + + // make version an integer if possible + if(ctype_digit("$version")) $version = (int) $version; + + return array($class, $operator, $version); + } + + /** + * Load the module information cache + * + * #pw-internal + * + * @return bool + * + */ + public function loadModuleInfoCache() { + + if(empty($this->modulesLastVersions)) { + $name = self::moduleLastVersionsCacheName; + $data = $this->modules->getCache($name); + if(is_array($data)) $this->modulesLastVersions = $data; + } + + if(empty($this->moduleInfoCache)) { + $name = self::moduleInfoCacheName; + $data = $this->modules->getCache($name); + // if module class name keys in use (i.e. ProcessModule) it's an older version of + // module info cache, so we skip over it to force its re-creation + if(is_array($data) && !isset($data['ProcessModule'])) { + $this->moduleInfoCache = $data; + return true; + } + return false; + } + + return true; + } + + /** + * Load the module information cache (verbose info: summary, author, href, file, core) + * + * #pw-internal + * + * @param bool $uninstalled If true, it will load the uninstalled verbose cache. + * @return bool + * + */ + public function loadModuleInfoCacheVerbose($uninstalled = false) { + + $name = $uninstalled ? self::moduleInfoCacheUninstalledName : self::moduleInfoCacheVerboseName; + + $data = $this->modules->getCache($name); + + if($data) { + if(is_array($data)) { + if($uninstalled) { + $this->moduleInfoCacheUninstalled = $data; + } else { + $this->moduleInfoCacheVerbose = $data; + } + } + return true; + } + + return false; + } + + /** + * Save the module information cache + * + */ + public function saveModuleInfoCache() { + + if($this->debug) { + static $n = 0; + $this->message("saveModuleInfoCache (" . (++$n) . ")"); + } + + $this->moduleInfoCache = array(); + $this->moduleInfoCacheVerbose = array(); + $this->moduleInfoCacheUninstalled = array(); + + $user = $this->wire()->user; + $languages = $this->wire()->languages; + $language = null; + + if($languages) { + // switch to default language to prevent caching of translated title/summary data + $language = $user->language; + try { + if($language && $language->id && !$language->isDefault()) $user->language = $languages->getDefault(); // save + } catch(\Exception $e) { + $this->trackException($e, false, true); + } + } + + $installableFiles = $this->modules->installableFiles; + + foreach(array(true, false) as $installed) { + + $items = $installed ? $this->modules : array_keys($installableFiles); + + foreach($items as $module) { + + $class = is_object($module) ? $module->className() : $module; + $class = wireClassName($class, false); + $info = $this->getModuleInfo($class, array('noCache' => true, 'verbose' => true)); + $moduleID = (int) $info['id']; // note ID is always 0 for uninstalled modules + + if(!empty($info['error'])) { + if($this->debug) $this->warning("$class reported error: $info[error]"); + continue; + } + + if(!$moduleID && $installed) { + if($this->debug) $this->warning("No module ID for $class"); + continue; + } + + if(!$this->debug) unset($info['id']); // no need to double store this property since it is already the array key + + if(is_null($info['autoload'])) { + // module info does not indicate an autoload state + $info['autoload'] = $this->modules->isAutoload($module); + + } else if(!is_bool($info['autoload']) && !is_string($info['autoload']) && wireIsCallable($info['autoload'])) { + // runtime function, identify it only with 'function' so that it can be recognized later as one that + // needs to be dynamically loaded + $info['autoload'] = 'function'; + } + + if(is_null($info['singular'])) { + $info['singular'] = $this->modules->isSingular($module); + } + + if(is_null($info['configurable'])) { + $info['configurable'] = $this->modules->isConfigurable($module, false); + } + + if($moduleID) $this->modules->flags->updateModuleFlags($moduleID, $info); + + if($installed) { + + $verboseKeys = $this->moduleInfoVerboseKeys; + $verboseInfo = array(); + + foreach($verboseKeys as $key) { + if(!empty($info[$key])) $verboseInfo[$key] = $info[$key]; + unset($info[$key]); // remove from regular moduleInfo + } + + $this->moduleInfoCache[$moduleID] = $info; + $this->moduleInfoCacheVerbose[$moduleID] = $verboseInfo; + + } else { + $this->moduleInfoCacheUninstalled[$class] = $info; + } + } + } + + $caches = array( + self::moduleInfoCacheName => 'moduleInfoCache', + self::moduleInfoCacheVerboseName => 'moduleInfoCacheVerbose', + self::moduleInfoCacheUninstalledName => 'moduleInfoCacheUninstalled', + ); + + $defaultTrimNS = trim(self::defaultNamespace, "\\"); + + foreach($caches as $cacheName => $varName) { + $data = $this->$varName; + foreach($data as $moduleID => $moduleInfo) { + foreach($moduleInfo as $key => $value) { + // remove unpopulated properties + if($key == 'installed') { + // no need to store an installed==true property + if($value) unset($data[$moduleID][$key]); + + } else if($key == 'requires' && !empty($value) && !empty($data[$moduleID]['requiresVersions'])) { + // requiresVersions has enough info to re-construct requires, so no need to store it + unset($data[$moduleID][$key]); + + } else if(($key == 'created' && empty($value)) + || ($value === 0 && ($key == 'singular' || $key == 'autoload' || $key == 'configurable')) + || ($value === null || $value === "" || $value === false) + || (is_array($value) && !count($value))) { + // no need to store these false, null, 0, or blank array properties + unset($data[$moduleID][$key]); + + } else if($key === 'namespace' && (empty($value) || trim($value, "\\") === $defaultTrimNS)) { + // no need to cache default namespace in module info + unset($data[$moduleID][$key]); + + } else if($key === 'file') { + // file property is cached elsewhere so doesn't need to be included in this cache + unset($data[$moduleID][$key]); + } + } + } + $this->modules->saveCache($cacheName, $data); + } + + // $this->log('Saved module info caches'); + + if($languages && $language) $user->language = $language; // restore + } + + /** + * Clear the module information cache + * + * @param bool|null $showMessages Specify true to show message notifications + * + */ + public function clearModuleInfoCache($showMessages = false) { + + $sanitizer = $this->wire()->sanitizer; + + $versionChanges = array(); + $upgradeLinks = array(); + $newModules = array(); + $moveModules = array(); + $missModules = array(); + + // record current module versions currently in moduleInfo + $moduleVersions = array(); + foreach($this->moduleInfoCache() as $id => $moduleInfo) { + if(isset($this->modulesLastVersions[$id])) { + $moduleVersions[$id] = $this->modulesLastVersions[$id]; + } else { + $moduleVersions[$id] = $moduleInfo['version']; + } + } + + // delete the caches + $this->modules->deleteCache(self::moduleInfoCacheName); + $this->modules->deleteCache(self::moduleInfoCacheVerboseName); + $this->modules->deleteCache(self::moduleInfoCacheUninstalledName); + + $this->moduleInfoCache = array(); + $this->moduleInfoCacheVerbose = array(); + $this->moduleInfoCacheUninstalled = array(); + + // save new moduleInfo cache + $this->saveModuleInfoCache(); + + // compare new moduleInfo versions with the previous ones, looking for changes + foreach($this->moduleInfoCache() as $id => $moduleInfo) { + $moduleName = $moduleInfo['name']; + if(!isset($moduleVersions[$id])) { + if($this->modules->moduleID($moduleName)) { + $moveModules[] = $moduleName; + } else { + $newModules[] = $moduleName; + } + continue; + } + if($moduleVersions[$id] != $moduleInfo['version']) { + $fromVersion = $this->modules->formatVersion($moduleVersions[$id]); + $toVersion = $this->modules->formatVersion($moduleInfo['version']); + $versionChanges[$moduleName] = "$fromVersion => $toVersion: $moduleName"; + $editUrl = $this->modules->configs->getModuleEditUrl($moduleName, false); + $this->modulesLastVersions[$id] = $moduleVersions[$id]; + if(strpos($moduleName, 'Fieldtype') === 0) { + // apply update now, to Fieldtype modules only (since they are loaded differently) + $this->modules->getModule($moduleName); + } else { + $upgradeLinks[$moduleName] = "" . + $sanitizer->entities1($this->_('Apply now')) . ""; + } + } + } + + foreach($this->modules->moduleIDs as $moduleName => $moduleID) { + if(isset($this->moduleInfoCache[$moduleID])) { + // module is present in moduleInfo + if($this->modules->flags->hasFlag($moduleID, Modules::flagsNoFile)) { + $file = $this->modules->getModuleFile($moduleName, array('fast' => false)); + if($file) { + // remove flagsNoFile if file is found + $this->modules->flags->setFlag($moduleID, Modules::flagsNoFile, false); + } + } + } else { + // module is missing moduleInfo + $file = $this->modules->getModuleFile($moduleName, array('fast' => false)); + if(!$file) { + $file = $this->modules->getModuleFile($moduleName, array('fast' => true, 'guess' => true)); + // add flagsNoFile if file cannot be located + $missModules[] = "$moduleName => $file"; + $this->modules->flags->setFlag($moduleID, Modules::flagsNoFile, true); + } + } + } + + $this->updateModuleVersionsCache(); + + // report detected changes + $reports = array( + array( + 'label' => $this->_('Found %d new module(s):'), + 'items' => $newModules, + ), + /* + array( + 'label' => $this->_('Found %d moved module(s):'), + 'items' => $moveModules, + ), + */ + array( + 'label' => $this->_('Found %d module(s) missing file:'), + 'items' => $missModules, + ), + array( + 'label' => $this->_('Found %d module version changes (applied when each module is loaded):'), + 'items' => $versionChanges, + ), + ); + + $qty = 0; + + foreach($reports as $report) { + if(!count($report['items'])) continue; + if($showMessages) { + $items = array(); + foreach($report['items'] as $moduleName => $item) { + $item = $sanitizer->entities($item); + if(isset($upgradeLinks[$moduleName])) $item .= " - " . $upgradeLinks[$moduleName]; + $items[] = $item; + } + $this->message( + $sanitizer->entities1(sprintf($report['label'], count($items))) . + '
' . implode("\n", $items) . '
', + 'icon-plug markup nogroup' + ); + $qty++; + } + $this->log( + sprintf($report['label'], count($report['items'])) . ' ' . + implode(', ', $report['items']) + ); + } + if($qty) { + /** @var JqueryUI $jQueryUI */ + $jQueryUI = $this->modules->getModule('JqueryUI'); + if($jQueryUI) $jQueryUI->use('modal'); + } + } + + /** + * Update the cache of queued module version changes + * + */ + protected function updateModuleVersionsCache() { + $moduleIDs = $this->modules->moduleIDs; + foreach($this->modulesLastVersions as $id => $version) { + // clear out stale data, if present + if(!in_array($id, $moduleIDs)) unset($this->modulesLastVersions[$id]); + } + $this->modules->saveCache(self::moduleLastVersionsCacheName, $this->modulesLastVersions); + } + + /** + * Check the module version to make sure it is consistent with our moduleInfo + * + * When not consistent, this triggers the moduleVersionChanged hook, which in turn + * triggers the $module->___upgrade($fromVersion, $toVersion) method. + * + * @param Module $module + * + */ + public function checkModuleVersion(Module $module) { + $id = (string) $this->modules->getModuleID($module); + $moduleInfo = $this->getModuleInfo($module); + if(!isset($this->modulesLastVersions[$id])) return; + $lastVersion = $this->modulesLastVersions[$id]; + if($lastVersion === $moduleInfo['version']) return; + // calling the one from $modules rather than $this is intentional + $this->modules->moduleVersionChanged($module, $lastVersion, $moduleInfo['version']); + } + + /** + * @param int|null $id + * @return string|null|array + * + */ + public function modulesLastVersions($id = null) { + if($id === null) return $this->modulesLastVersions; + return isset($this->modulesLastVersions[$id]) ? $this->modulesLastVersions[$id] : null; + } + + /** + * Module version changed + * + * This calls the module's ___upgrade($fromVersion, $toVersion) method. + * + * @param Module|_Module $module + * @param int|string $fromVersion + * @param int|string $toVersion + * + */ + public function moduleVersionChanged(Module $module, $fromVersion, $toVersion) { + $moduleName = wireClassName($module, false); + $moduleID = $this->modules->getModuleID($module); + $fromVersionStr = $this->modules->formatVersion($fromVersion); + $toVersionStr = $this->modules->formatVersion($toVersion); + $this->message($this->_('Upgrading module') . " ($moduleName: $fromVersionStr => $toVersionStr)"); + try { + if(method_exists($module, '___upgrade') || method_exists($module, 'upgrade')) { + $module->upgrade($fromVersion, $toVersion); + } + unset($this->modulesLastVersions[$moduleID]); + $this->updateModuleVersionsCache(); + } catch(\Exception $e) { + $this->error("Error upgrading module ($moduleName): " . $e->getMessage()); + } + } + + /** + * Get an array of all unique, non-default, non-root module namespaces mapped to directory names + * + * @return array + * + */ + public function getNamespaces() { + $config = $this->wire()->config; + 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']] = $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. (default=false) + * - `noLoad` (bool): Specify true to prevent loading of file for namespace discovery. (default=false) Added 3.0.170 + * @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, + 'noLoad' => false, + 'noCache' => false, + ); + + $namespace = null; + + if(is_object($moduleName) || strpos($moduleName, "\\") !== false) { + $className = is_object($moduleName) ? get_class($moduleName) : $moduleName; + if(strpos($className, "ProcessWire\\") === 0) return "ProcessWire\\"; + if(strpos($className, "\\") === false) return "\\"; + $parts = explode("\\", $className); + array_pop($parts); + $namespace = count($parts) ? implode("\\", $parts) : ""; + $namespace = $namespace == "" ? "\\" : "\\$namespace\\"; + return $namespace; + } + + if(empty($options['noCache'])) { + $moduleID = $this->modules->getModuleID($moduleName); + $info = isset($this->moduleInfoCache[$moduleID]) ? $this->moduleInfoCache($moduleID) : null; + if($info) { + if(isset($info['namespace'])) { + if("$info[namespace]" === "1") return __NAMESPACE__ . "\\"; + return $info['namespace']; + } else { + // if namespace not present in info then use default namespace + return __NAMESPACE__ . "\\"; + } + } + } + + $options = array_merge($defaults, $options); + + if(empty($options['file'])) { + $options['file'] = $this->modules->getModuleFile($moduleName); + } + + if(strpos($options['file'], $this->modules->coreModulesDir) !== false) { + // all core modules use \ProcessWire\ namespace + $namespace = strlen(__NAMESPACE__) ? __NAMESPACE__ . "\\" : ""; + return $namespace; + } + + if(!$options['file'] || !file_exists($options['file'])) { + return null; + } + + if(empty($options['noLoad'])) { + $namespace = $this->modules->files->getFileNamespace($options['file']); + } + + return $namespace; + } + + /** + * Is the given namespace a unique recognized module namespace? If yes, returns the path to it. If not, returns boolean false. + * + * #pw-internal + * + * @param string $namespace + * @return bool|string + * + */ + public function getNamespacePath($namespace) { + if($namespace === 'ProcessWire') return "ProcessWire\\"; + if(is_null($this->moduleNamespaceCache)) $this->getNamespaces(); + $namespace = "\\" . trim($namespace, "\\") . "\\"; + return isset($this->moduleNamespaceCache[$namespace]) ? $this->moduleNamespaceCache[$namespace] : false; + } + + public function __get($name) { + switch($name) { + case 'moduleInfoCache': return $this->moduleInfoCache; + case 'moduleInfoCacheVerbose': return $this->moduleInfoCacheVerbose; + case 'moduleInfoCacheUninstalled': return $this->moduleInfoCacheUninstalled; + case 'moduleInfoVerboseKeys': return $this->moduleInfoVerboseKeys; + case 'modulesLastVersions': return $this->modulesLastVersions; + } + return parent::__get($name); + } + + public function getDebugData() { + return array( + 'moduleInfoCache' => $this->moduleInfoCache, + 'moduleInfoCacheVerbose' => $this->moduleInfoCacheVerbose, + 'moduleInfoCacheUninstalled' => $this->moduleInfoCacheUninstalled, + 'modulesLastVersions' => $this->modulesLastVersions, + 'moduleNamespaceCache' => $this->moduleNamespaceCache + ); + } +} diff --git a/wire/core/ModulesInstaller.php b/wire/core/ModulesInstaller.php new file mode 100644 index 00000000..7eebb263 --- /dev/null +++ b/wire/core/ModulesInstaller.php @@ -0,0 +1,829 @@ + path] for all modules that aren’t currently installed. + * + * #pw-internal + * + * @return array Array of elements with $moduleName => $pathName + * + */ + public function getInstallable() { + return $this->modules->getInstallable(); + } + + /** + * 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) { + $installableFiles = $this->modules->installableFiles; + if(!array_key_exists($class, $installableFiles)) return false; + if(!wireInstanceOf($class, 'Module')) { + $nsClass = $this->modules->getModuleClass($class, true); + if(!wireInstanceOf($nsClass, 'ProcessWire\\Module')) return false; + } + if($now) { + $requires = $this->getRequiresForInstall($class); + if(count($requires)) return false; + } + return true; + } + + /** + * 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->modules->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); + } + } + } + + $database = $this->wire()->database; + $languages = $this->wire()->languages; + $config = $this->wire()->config; + + if($languages) $languages->setDefault(); + + $pathname = $this->modules->installableFile($class); + $this->modules->files->includeModuleFile($pathname, $class); + $this->modules->files->setConfigPaths($class, dirname($pathname)); + + $module = $this->modules->newModule($class); + if(!$module) return null; + + $flags = 0; + $moduleID = 0; + + if($this->modules->isSingular($module)) $flags = $flags | Modules::flagsSingular; + if($this->modules->isAutoload($module)) $flags = $flags | Modules::flagsAutoload; + + $sql = "INSERT INTO modules SET class=:class, flags=:flags, data=''"; + if($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->modules->moduleID($class, $moduleID); + $this->modules->add($module); + $this->modules->installableFile($class, false); // unset + + // note: the module's install is called here because it may need to know its 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->modules->info->getModuleInfoVerbose($class, array('noCache' => true)); + $sanitizer = $this->wire()->sanitizer; + $permissions = $this->wire()->permissions; + + // if this module has custom permissions defined in its getModuleInfo()['permissions'] array, install them + foreach($info['permissions'] as $name => $title) { + $name = $sanitizer->pageName($name); + if(ctype_digit("$name") || empty($name)) continue; // permission name not valid + $permission = $permissions->get($name); + if($permission->id) continue; // permision already there + try { + $permission = $permissions->add($name); + $permission->title = $title; + $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->modules->isInstalled($name)) { + try { + $this->modules->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->modules->info->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->modules->info->getModuleNamespace($class); + $class = $this->modules->getModuleClass($class); + + if(!$this->modules->isInstalled($class)) { + $reason = $reason1 . ' (a)'; + + } else { + $this->modules->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->modules->info->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->modules->getModuleClass($class); + $filename = $this->modules->installableFile($class); + $dirname = dirname($filename); + + if(empty($filename) || $this->modules->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->modules->coreModulesPath) === 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 + * @throws WireException If module can't be deleted, exception will be thrown containing reason. + * + */ + public function delete($class) { + + $config = $this->wire()->config; + $fileTools = $this->wire()->files; + + $class = $this->modules->getModuleClass($class); + $success = true; + $reason = $this->isDeleteable($class, true); + if($reason !== true) throw new WireException($reason); + $siteModulesPath = $config->paths->siteModules; + + $filename = $this->modules->installableFile($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->modules->getPaths() 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[] = $fileTools->unixDirName($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 = (bool) $fileTools->rmdir($path, true); + if($success) { + $this->message("Removed directory: $path", Notice::debug); + if(is_dir($backupPath)) { + if($fileTools->rmdir($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($fileTools->unlink($file, $siteModulesPath)) { + $this->message("Removed file: $file", Notice::debug); + } else { + $this->error("Unable to remove file: $file", Notice::debug); + } + } + + $this->log("Deleted 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->modules->getModuleClass($class); + $reason = $this->modules->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->modules->uninstall($name); + $this->message("$label: $name"); + + } catch(\Exception $e) { + $error = "$label: $name - " . $e->getMessage(); + $this->trackException($e, false, $error); + } + } + + $info = $this->modules->info->getModuleInfoVerbose($class); + $module = $this->modules->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->modules->getModuleClass($module, true)); + $this->modules->installableFile($class, $reflector->getFileName()); + } + + $this->modules->moduleID($class, false); + $this->modules->remove($module); + + $sanitizer = $this->wire()->sanitizer; + $permissions = $this->wire()->permissions; + + // delete permissions installed by this module + if(isset($info['permissions']) && is_array($info['permissions'])) { + foreach($info['permissions'] as $name => $title) { + $name = $sanitizer->pageName($name); + if(ctype_digit("$name") || empty($name)) continue; + $permission = $permissions->get($name); + if(!$permission->id) continue; + try { + $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->modules->refresh(); + + 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->modules->getModuleClass($class); + if(!$class) return $uninstalls; + $info = $this->modules->info->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->modules->isInstalled($name)) continue; + + // if an 'installs' module doesn't indicate that it requires this one, then leave it installed + $i = $this->modules->info->getModuleInfo($name); + if(!in_array($class, $i['requires'])) continue; + + // add it to the uninstalls array + $uninstalls[] = $name; + } + + return $uninstalls; + } + + /** + * Return an array of module class names that require the given one + * + * #pw-internal + * + * @param string $class + * @param bool $uninstalled Set to true to include modules dependent upon this one, even if they aren't installed. + * @param bool $installs Set to true to exclude modules that indicate their install/uninstall is controlled by $class. + * @return array() + * + */ + public function getRequiredBy($class, $uninstalled = false, $installs = false) { + + $class = $this->modules->getModuleClass($class); + $info = $this->modules->info->getModuleInfo($class); + $dependents = array(); + + foreach($this as $module) { + $c = $this->modules->getModuleClass($module); + if(!$uninstalled && !$this->modules->isInstalled($c)) continue; + $i = $this->modules->info->getModuleInfo($c); + if(!count($i['requires'])) continue; + if($installs && in_array($c, $info['installs'])) continue; + if(in_array($class, $i['requires'])) $dependents[] = $c; + } + + return $dependents; + } + + /** + * Return an array of module class names required by the given one + * + * Default behavior is to return all listed requirements, whether they are currently met by + * the environment or not. Specify TRUE for the 2nd argument to return only requirements + * that are not currently met. + * + * #pw-internal + * + * @param string $class + * @param bool $onlyMissing Set to true to return only required modules/versions that aren't + * yet installed or don't have the right version. It excludes those that the class says it + * will install (via 'installs' property of getModuleInfo) + * @param null|bool $versions Set to true to always include versions in the returned requirements list. + * Set to null to always exclude versions in requirements list (so only module class names will be there). + * Set to false (which is the default) to include versions only when version is the dependency issue. + * Note versions are already included when the installed version is not adequate. + * @return array of strings each with ModuleName Operator Version, i.e. "ModuleName>=1.0.0" + * + */ + public function getRequires($class, $onlyMissing = false, $versions = false) { + + $class = $this->modules->getModuleClass($class); + $info = $this->modules->getModuleInfo($class); + $requires = $info['requires']; + $currentVersion = 0; + + // quick exit if arguments permit it + if(!$onlyMissing) { + if($versions) foreach($requires as $key => $value) { + list($operator, $version) = $info['requiresVersions'][$value]; + if(empty($version)) continue; + if(ctype_digit("$version")) $version = $this->modules->formatVersion($version); + if(!empty($version)) $requires[$key] .= "$operator$version"; + } + return $requires; + } + + foreach($requires as $key => $requiresClass) { + + if(in_array($requiresClass, $info['installs'])) { + // if this module installs the required class, then we can stop now + // and we assume it's installing the version it wants + unset($requires[$key]); + } + + list($operator, $requiresVersion) = $info['requiresVersions'][$requiresClass]; + $installed = true; + + if($requiresClass == 'PHP') { + $currentVersion = PHP_VERSION; + + } else if($requiresClass == 'ProcessWire') { + $currentVersion = $this->wire()->config->version; + + } else if($this->modules->isInstalled($requiresClass)) { + if(!$requiresVersion) { + // if no version is specified then requirement is already met + unset($requires[$key]); + continue; + } + $i = $this->modules->getModuleInfo($requiresClass, array('noCache' => true)); + $currentVersion = $i['version']; + } else { + // module is not installed + $installed = false; + } + + if($installed && $this->versionCompare($currentVersion, $requiresVersion, $operator)) { + // required version is installed + unset($requires[$key]); + + } else if(empty($requiresVersion)) { + // just the class name is fine + continue; + + } else if(is_null($versions)) { + // request is for no versions to be included (just class names) + $requires[$key] = $requiresClass; + + } else { + // update the requires string to clarify what version it requires + if(ctype_digit("$requiresVersion")) $requiresVersion = $this->modules->formatVersion($requiresVersion); + $requires[$key] = "$requiresClass$operator$requiresVersion"; + } + } + + return $requires; + } + + + /** + * Compare one module version to another, returning TRUE if they match the $operator or FALSE otherwise + * + * #pw-internal + * + * @param int|string $currentVersion May be a number like 123 or a formatted version like 1.2.3 + * @param int|string $requiredVersion May be a number like 123 or a formatted version like 1.2.3 + * @param string $operator + * @return bool + * + */ + public function versionCompare($currentVersion, $requiredVersion, $operator) { + + if(ctype_digit("$currentVersion") && ctype_digit("$requiredVersion")) { + // integer comparison is ok + $currentVersion = (int) $currentVersion; + $requiredVersion = (int) $requiredVersion; + $result = false; + + switch($operator) { + case '=': $result = ($currentVersion == $requiredVersion); break; + case '>': $result = ($currentVersion > $requiredVersion); break; + case '<': $result = ($currentVersion < $requiredVersion); break; + case '>=': $result = ($currentVersion >= $requiredVersion); break; + case '<=': $result = ($currentVersion <= $requiredVersion); break; + case '!=': $result = ($currentVersion != $requiredVersion); break; + } + return $result; + } + + // if either version has no periods or only one, like "1.2" then format it to stanard: "1.2.0" + if(substr_count($currentVersion, '.') < 2) $currentVersion = $this->modules->formatVersion($currentVersion); + if(substr_count($requiredVersion, '.') < 2) $requiredVersion = $this->modules->formatVersion($requiredVersion); + + return version_compare($currentVersion, $requiredVersion, $operator); + } + + /** + * Return an array of module class names required by the given one to be installed before this one. + * + * Excludes modules that are required but already installed. + * Excludes uninstalled modules that $class indicates it handles via it's 'installs' getModuleInfo property. + * + * #pw-internal + * + * @param string $class + * @return array() + * + */ + public function getRequiresForInstall($class) { + return $this->getRequires($class, true); + } + + /** + * Return an array of module class names required by the given one to be uninstalled before this one. + * + * Excludes modules that the given one says it handles via it's 'installs' getModuleInfo property. + * Module class names in returned array include operator and version in the string. + * + * #pw-internal + * + * @param string $class + * @return array() + * + */ + public function getRequiresForUninstall($class) { + return $this->getRequiredBy($class, false, true); + } + + /** + * Return array of dependency errors for given module name + * + * #pw-internal + * + * @param $moduleName + * @return array If no errors, array will be blank. If errors, array will be of strings (error messages) + * + */ + public function getDependencyErrors($moduleName) { + + $moduleName = $this->modules->getModuleClass($moduleName); + $info = $this->modules->getModuleInfo($moduleName); + $errors = array(); + + if(empty($info['requires'])) return $errors; + + foreach($info['requires'] as $requiresName) { + $error = ''; + + if(!$this->modules->isInstalled($requiresName)) { + $error = $requiresName; + + } else if(!empty($info['requiresVersions'][$requiresName])) { + list($operator, $version) = $info['requiresVersions'][$requiresName]; + $info2 = $this->modules->getModuleInfo($requiresName); + $requiresVersion = $info2['version']; + if(!empty($version) && !$this->versionCompare($requiresVersion, $version, $operator)) { + $error = "$requiresName $operator $version"; + } + } + + if($error) $errors[] = sprintf($this->_('Failed module dependency: %s requires %s'), $moduleName, $error); + } + + return $errors; + } + + /** + * Get URL where an administrator can install given module name + * + * If module is already installed, it returns the URL to edit the module. + * + * @param string $className + * @return string + * + */ + public function getModuleInstallUrl($className) { + if(!is_string($className)) $className = $this->modules->getModuleClass($className); + $className = $this->wire()->sanitizer->fieldName($className); + if($this->modules->isInstalled($className)) return $this->modules->getModuleEditUrl($className); + return $this->wire()->config->urls->admin . "module/installConfirm?name=$className"; + } + +} diff --git a/wire/core/ModulesLoader.php b/wire/core/ModulesLoader.php new file mode 100644 index 00000000..641522fa --- /dev/null +++ b/wire/core/ModulesLoader.php @@ -0,0 +1,909 @@ + order to indicate autoload order when necessary + * + * @var array + * + */ + protected $autoloadOrders = 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 from DB used across multiple calls temporarily by loadPath() method + * + */ + protected $modulesTableCache = array(); + + /** + * Module created dates indexed by module ID + * + */ + protected $createdDates = array(); + + /** + * 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->modules->debugTimerStart("triggerInit$level"); + $this->message("triggerInit(level=$level)"); + } + + $queue = array(); + + if($modules === null) $modules = $this->modules; + + 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->modules->debugTimerStart("triggerInit$level($class)"); + + $info = $this->modules->getModuleInfo($module); + $skip = false; + + // module requires other modules + foreach($info['requires'] as $requiresClass) { + if(in_array($requiresClass, $completed)) continue; + $dependencyInfo = $this->modules->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->modules->isAutoload($module)) { + $this->initModule($module); + } + } + $completed[] = $class; + } + + if($this->debug) $this->modules->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->modules->isInitialized(true); + + if($this->debug) if($debugKey) $this->modules->debugTimerStop($debugKey); + + if(!$level && $this->modules->info->moduleInfoCacheEmpty()) { + if($this->debug) $this->message("saveModuleInfoCache from triggerInit"); + $this->modules->info->saveModuleInfoCache(); + } + } + + /** + * 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) + * - `configOnly` (bool): When true, module init() method NOT called, but config data still set (default=false) 3.0.169+ + * - `configData` (array): Extra config data merge with module’s config data (default=[]) 3.0.169+ + * - `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. + * + */ + public 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 + $extraConfigData = isset($options['configData']) ? $options['configData'] : null; + $this->modules->configs->setModuleConfigData($module, null, $extraConfigData); + + $moduleName = wireClassName($module, false); + $moduleID = $this->modules->moduleID($moduleName); + + if($moduleID && $this->modules->info->modulesLastVersions($moduleID)) { + $this->modules->info->checkModuleVersion($module); + } + + if(method_exists($module, 'init') && empty($options['configOnly'])) { + + if($this->debug) { + $debugKey = $this->modules->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->modules->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->modules->isSingular($module)) { + if(!$moduleID) $moduleID = $this->modules->getModuleID($module); + if($moduleID && $this->modules->configs->configData($moduleID) !== null) { + $this->modules->configs->configData($moduleID, 1); + } + } + + return $result; + } + + /** + * Call ready for a single module + * + * @param Module $module + * @return bool + * + */ + public function readyModule(Module $module) { + $result = true; + if(method_exists($module, 'ready')) { + $debugKey = $this->debug ? $this->modules->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->modules->debugTimerStop($debugKey); + static $n = 0; + $this->message("readyModule (" . (++$n) . "): " . wireClassName($module)); + } + } + return $result; + } + + /** + * 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->modules->debugTimerStart("triggerReady") : null; + $skipped = $this->triggerConditionalAutoload(); + + // trigger ready method on all applicable modules + foreach($this->modules as $module) { + /** @var Module $module */ + if($module instanceof ModulePlaceholder) continue; + + $class = $this->modules->getModuleClass($module); + + if(isset($skipped[$class])) continue; + + $id = $this->modules->moduleID($class); + $flags = $this->modules->flags->moduleFlags($id); + + if($flags & Modules::flagsAutoload) $this->readyModule($module); + } + + if($this->debug) $this->modules->debugTimerStop($debugKey); + } + + + /** + * Init conditional autoload modules, if conditions allow + * + * @return array of skipped module names + * + */ + public 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->modules->getModuleID($className); + $flags = $this->modules->flags->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->modules->newModule($className); + if($module) { + $this->modules->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; + } + + /** + * Retrieve the installed module info as stored in the database + * + */ + public function loadModulesTable() { + + $this->autoloadOrders = array(); + $database = $this->wire()->database; + + // skip loading dymanic caches at this stage + $skipCaches = array( + ModulesInfo::moduleInfoCacheUninstalledName, + ModulesInfo::moduleInfoCacheVerboseName + ); + + $query = $database->query( + // Currently: id, class, flags, data, with created added at sysupdate 7 + "SELECT * FROM modules " . + "WHERE class NOT IN('" . implode("','", $skipCaches) . "') " . + "ORDER BY class", + "modules.loadModulesTable()" + ); + + /** @noinspection PhpAssignmentInConditionInspection */ + while($row = $query->fetch(\PDO::FETCH_ASSOC)) { + + $moduleID = (int) $row['id']; + $flags = (int) $row['flags']; + $class = $row['class']; + + if($flags & Modules::flagsSystemCache) { + // system cache names are prefixed with a '.' so they load first + $this->modules->memcache(ltrim($class, '.'), $row['data']); + continue; + } + + $this->modules->moduleID($class, $moduleID); + $this->modules->moduleName($moduleID, $class); + $this->modules->flags->moduleFlags($moduleID, $flags); + + $autoload = $flags & Modules::flagsAutoload; + $loadSettings = $autoload || ($flags & Modules::flagsDuplicate) || ($class === 'SystemUpdater'); + + if($loadSettings) { + // preload config data for autoload modules since we'll need it again very soon + $data = $row['data'] ? json_decode($row['data'], true) : array(); + $this->modules->configs->configData($moduleID, $data); + // populate information about duplicates, if applicable + if($flags & Modules::flagsDuplicate) $this->modules->duplicates()->addFromConfigData($class, $data); + + } else if(!empty($row['data'])) { + // indicate that it has config data, but not yet loaded + $this->modules->configs->configData($moduleID, 1); + } + + if(isset($row['created']) && $row['created'] != '0000-00-00 00:00:00') { + $this->createdDates[$moduleID] = $row['created']; + } + + if($autoload) { + $value = $this->modules->info->moduleInfoCache($moduleID, 'autoload'); + if(!empty($value)) { + $autoload = $value; + $disabled = $flags & Modules::flagsDisabled; + if(is_int($autoload) && $autoload > 1 && !$disabled) { + // autoload specifies an order > 1, indicating it should load before others + $this->autoloadOrders[$class] = $autoload; + } + } + } + + unset($row['data'], $row['created']); // 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 + * + */ + public function loadPath($path) { + + $config = $this->wire()->config; + $debugKey = $this->debug ? $this->modules->debugTimerStart("loadPath($path)") : null; + $installed =& $this->modulesTableCache; + $modulesLoaded = array(); + $modulesDelayed = array(); + $modulesRequired = array(); + $modulesFiles = $this->modules->files; + $rootPath = $config->paths->root; + $basePath = substr($path, strlen($rootPath)); + + foreach($modulesFiles->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" + + $modulesFiles->moduleFileExt($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(!$config->paths->get($name)) $modulesFiles->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->modules->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) + * + */ + public 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->modules->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 = $this->modules->offsetGet($basename); + $dir = rtrim((string) $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') === false) return false; + list(, $ext) = explode('.module', $filename, 2); + if(!empty($ext) && $ext !== '.php') return false; + + // if the filename doesn't start with the requested path, then skip + 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(!isset($installed[$basename])) { + // array_key_exists is used as secondary to check the null case + $this->modules->installableFile($basename, $pathname); + return ''; + } + + $info = $installed[$basename]; + $this->modules->files->setConfigPaths($basename, $dirname); + $module = null; + $autoload = false; + + if($info['flags'] & Modules::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() + + // determine if module has dependencies that are not yet met + $requiresClasses = $this->modules->info->getModuleInfoProperty($basename, 'requires'); + if(!empty($requiresClasses)) { + foreach($requiresClasses as $requiresClass) { + $nsRequiresClass = $this->modules->getModuleClass($requiresClass, true); + if(!wireClassExists($nsRequiresClass, false)) { + $requiresInfo = $this->modules->getModuleInfo($requiresClass); + if(!empty($requiresInfo['error']) + || $requiresInfo['autoload'] === true + || !$this->modules->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 + /** @var bool|string|callable $autoload */ + $autoload = $this->modules->info->moduleInfoCache($basename, 'autoload'); + if(empty($autoload)) $autoload = true; + 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->modules->info->getModuleInfoExternal($basename); + if(empty($i)) { + $this->modules->files->includeModuleFile($pathname, $basename); + $namespace = $this->modules->info->getModuleNamespace($basename); + $className = $namespace . $basename; + if(method_exists($className, 'getModuleInfo')) { + $i = $className::getModuleInfo(); + } else { + $i = $this->modules->getModuleInfo($className); + } + } + $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'] & Modules::flagsDisabled)) { + // anonymous function or selector string + $this->conditionalAutoloadModules[$basename] = $autoload; + $this->modules->moduleID($basename, (int) $info['id']); + $this->modules->moduleName((int) $info['id'], $basename); + $autoload = true; + } else if($autoload) { + $this->modules->files->includeModuleFile($pathname, $basename); + if(!($info['flags'] & Modules::flagsDisabled)) { + if($this->modules->refreshing) { + $module = $this->modules->offsetGet($basename); + } else if(isset($this->autoloadOrders[$basename]) && $this->autoloadOrders[$basename] >= 10000) { + $module = $this->modules->offsetGet($basename); // preloaded module + } + if(!$module) $module = $this->modules->newModule($basename); + } + } + } + + if($module === null) { + // placeholder for a module, which is not yet included and instantiated + // if(!$moduleInfo) $moduleInfo = $this->getModuleInfo($basename); + $ns = $moduleInfo ? $moduleInfo['namespace'] : $this->modules->info->moduleInfoCache($basename, 'namespace'); + if(empty($namespace)) $ns = __NAMESPACE__ . "\\"; + $singular = $info['flags'] & Modules::flagsSingular; + $module = $this->newModulePlaceholder($basename, $ns, $pathname, $singular, $autoload); + } + + $this->modules->moduleID($basename, (int) $info['id']); + $this->modules->moduleName((int) $info['id'], $basename); + $this->modules->set($basename, $module); + + return $basename; + } + + /** + * 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 = ''; + $moduleName = ''; + + if(is_string($module)) { + $moduleName = ctype_alnum($module) ? $module : wireClassName($module); + $className = wireClassName($module, true); + } else if(is_object($module)) { + if($module instanceof ModulePlaceholder) { + $moduleName = $module->className(); + $className = $module->className(true); + } else if($module instanceof Module) { + return true; // already included + } + } else { + $moduleName = $this->modules->getModuleClass($module, false); + $className = $this->modules->getModuleClass($module, true); + } + + if(!$className) return false; + + // already included + if(class_exists($className, false)) return true; + + // attempt to retrieve module + $module = $this->modules->offsetGet($moduleName); + + if($module) { + // module found, check to make sure it actually points to a module + if(!$module instanceof Module) $module = false; + + } else if($moduleName) { + // This is reached for any of the following: + // 1. an uninstalled module + // 2. an installed module that has changed locations + // 3. a module outside the \ProcessWire\ namespace + // 4. a module that does not exist + $fast = true; + if(!$file) { + // determine module file, if not already provided to the method + $file = $this->modules->getModuleFile($moduleName, array('fast' => true)); + if(!$file) { + $fast = false; + $file = $this->modules->getModuleFile($moduleName, array('fast' => false)); + } + // still can't figure out what file is? fail + if(!$file) return false; + } + + if(!$this->modules->files->includeModuleFile($file, $moduleName)) { + // module file failed to include(), try to identify and include file again + if($fast) { + $filePrev = $file; + $file = $this->modules->getModuleFile($moduleName, array('fast' => false)); + if($file && $file !== $filePrev) { + if($this->modules->files->includeModuleFile($file, $moduleName)) { + // module is missing a module file + return false; + } + } + } else { + // we already tried this earlier, no point in doing it again + } + } + + // now check to see if included file resulted in presence of module class + if(class_exists($className)) { + // module in ProcessWire namespace + $module = true; + } else { + // module in root namespace or some other namespace + $namespace = (string) $this->modules->info->getModuleNamespace($moduleName, array('file' => $file)); + $className = trim($namespace, "\\") . "\\$moduleName"; + if(class_exists($className, false)) { + // successful include module + $module = true; + } + } + } + + if($module === true) { + // great + return true; + + } else if(!$module) { + // darn + return false; + + } else if($module instanceof ModulePlaceholder) { + $this->modules->files->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; + } + } + + /** + * 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 = $this->modules->getModuleClass($moduleName, true); // ??? + $moduleName = wireClassName($moduleName, false); + } + + $info = $this->modules->getModuleInfo($module ? $module : $moduleName); + + if(empty($info['permission']) && empty($info['permissionMethod'])) { + return ($strict ? false : true); + } + + if(!$user instanceof 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 method_exists($className, $method) ? $className::$method($data) : false; + } + + return true; + } + + /** + * Include site preload modules + * + * Preload modules load before all other modules, including core modules. In order + * for a module to be a preload module, it must meet the following conditions: + * + * - Module info `autoload` value is integer of 10000 or greater, i.e. `[ 'autoload' => 10000 ]` + * - Module info `singular` value must be non-empty, i.e. `[ 'singular' => true ]` + * - Module file is located in: /site/modules/ModuleName/ModuleName.module.php + * - Module cannot load any other modules at least until ready() method called. + * - Module cannot have any `requires` dependencies to any other modules. + * + * Please note the above is specifically stating that the module must be in its + * own “site/ModuleName/” directory and have the “.module.php” extension. Using + * just the “.module” extension is not supported for preload modules. + * + * @param string $path + * @since 3.0.173 + * + */ + public function preloadModules($path) { + + if(empty($this->autoloadOrders)) return; + + arsort($this->autoloadOrders); + + foreach($this->autoloadOrders as $moduleName => $order) { + if($order < 10000) break; + $info = $this->modules->info->moduleInfoCache($moduleName); + if(empty($info)) continue; + if(empty($info['singular'])) continue; + $file = $path . "$moduleName/$moduleName.module.php"; + if(!file_exists($file) || !$this->modules->files->includeModuleFile($file, $moduleName)) continue; + if(!isset($info['namespace'])) $info['namespace'] = ''; + $className = $info['namespace'] . $moduleName; + $module = $this->modules->newModule($className, $moduleName); + if($module) { + $this->modules->offsetSet($moduleName, $module); + } + } + } + + + /** + * Get or set created date for given module ID + * + * #pw-internal + * + * @param int $moduleID Module ID or omit to get all + * @param string $setValue Set created date value + * @return string|array|null + * @since 3.0.219 + * + */ + public function createdDate($moduleID = null, $setValue = null) { + if($moduleID === null) return $this->createdDates; + if($setValue) { + $this->createdDates[$moduleID] = $setValue; + return $setValue; + } + return isset($this->createdDates[$moduleID]) ? $this->createdDates[$moduleID] : null; + } + + /** + * Return a new ModulePlaceholder for the given className + * + * #pw-internal + * + * @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 + * + */ + public function newModulePlaceholder($className, $ns, $file, $singular, $autoload) { + /** @var ModulePlaceholder $module */ + $module = $this->wire(new ModulePlaceholder()); + $module->setClass($className); + $module->setNamespace($ns); + $module->singular = $singular; + $module->autoload = $autoload; + $module->file = $file; + return $module; + } + + /** + * Called by Modules class when init has finished + * + */ + public function loaded() { + $this->modulesTableCache = array(); + } + + /** + * Get the autoload orders + * + * @return array Array of [ moduleName (string => order (int) ] + * + */ + public function getAutoloadOrders() { + return $this->autoloadOrders; + } + + public function getDebugData() { + return array( + 'autoloadOrders' => $this->autoloadOrders, + 'conditionalAutoloadModules' => $this->conditionalAutoloadModules, + 'modulesTableCache' => $this->modulesTableCache, + 'createdDates' => $this->createdDates, + ); + } + +} diff --git a/wire/modules/Process/ProcessModule/ProcessModule.module b/wire/modules/Process/ProcessModule/ProcessModule.module index 47b3ee2c..64a3c8a7 100644 --- a/wire/modules/Process/ProcessModule/ProcessModule.module +++ b/wire/modules/Process/ProcessModule/ProcessModule.module @@ -87,6 +87,7 @@ class ProcessModule extends Process { protected $newCoreModules = array( 'SystemNotifications', 'InputfieldCKEditor', + 'InputfieldTinyMCE', 'FieldtypeOptions', 'InputfieldIcon', 'ProcessLogger', @@ -173,6 +174,9 @@ class ProcessModule extends Process { if($this->wire()->input->get('update')) { $this->labels['download_install'] = $this->_('Download and Update'); } + /** @var JqueryUI $jQueryUI */ + $jQueryUI = $this->wire()->modules->getModule('JqueryUI'); + if($jQueryUI) $jQueryUI->use('modal'); } /** @@ -248,7 +252,7 @@ class ProcessModule extends Process { if($configurable) { if(!$info['configurable'] || !$info['installed']) continue; - $flags = $modules->getFlags($moduleName); + $flags = $modules->flags->getFlags($moduleName); if($flags & Modules::flagsNoUserConfig) continue; } @@ -443,7 +447,7 @@ class ProcessModule extends Process { } if($info['configurable'] && $info['installed']) { - $flags = $modules->getFlags($name); + $flags = $modules->flags->getFlags($name); if(!($flags & Modules::flagsNoUserConfig)) { $configurableArray[$name] = $installed; } @@ -958,7 +962,7 @@ class ProcessModule extends Process { $editUrl = '#'; } else if($configurable) { - $flags = $modules->getFlags($name); + $flags = $modules->flags->getFlags($name); if(!($flags & Modules::flagsNoUserConfig)) { $buttons .= "