artabro/wire/core/ModulesInfo.php

1394 lines
45 KiB
PHP
Raw Normal View History

2024-08-27 11:35:37 +02:00
<?php namespace ProcessWire;
/**
* ProcessWire Modules: Info
*
* ProcessWire 3.x, Copyright 2023 by Ryan Cramer
* https://processwire.com
*
* @property-read array $moduleInfoCache
* @property-read array $moduleInfoCacheVerbose
* @property-read array $moduleInfoCacheUninstalled
* @property-read array $moduleInfoVerboseKeys
* @property-read array $modulesLastVersions
*
*/
class ModulesInfo extends ModulesClass {
/**
* 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';
/**
* Default namespace
*
*/
const defaultNamespace = "\\ProcessWire\\";
protected $debug = false;
/**
* @var Modules
*
*/
protected $modules;
/**
* Cache of module information
*
*/
public $moduleInfoCache = array();
/**
* Cache of module information (verbose text) including: summary, author, href, file, core
*
*/
public $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)
*
*/
public $moduleInfoCacheUninstalled = array();
/**
* Last known versions of modules, for version change tracking
*
* @var array of ModuleName (string) => 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 versionsmodule 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 = @filemtime($pathname);
if($filemtime === false) {
$info['created'] = 0;
} else {
$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 whats 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, 'requiresVersions'); // must be 'requiredVersions' here
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;
$config = $this->wire()->config;
$versionChanges = array();
$editLinks = 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) . '&upgrade=1';
$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 {
$editLinks[$moduleName] = "<a class='pw-modal' target='_blank' href='$editUrl'>" .
$sanitizer->entities1($this->_('Apply')) . "</a>";
}
}
}
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] = "$moduleName => $file";
$editUrl = $this->modules->configs->getModuleEditUrl($moduleName, false) . '&missing=1';
$editLinks[$moduleName] = "<a class='pw-modal' target='_blank' href='$editUrl'>" .
$sanitizer->entities1($this->_('Edit')) . "</a>";
$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($editLinks[$moduleName])) $item .= " - " . $editLinks[$moduleName];
$items[] = $item;
}
$itemsStr = implode("\n", $items);
$itemsStr = str_replace($config->paths->root, $config->urls->root, $itemsStr);
$this->message(
$sanitizer->entities1(sprintf($report['label'], count($items))) .
"<pre>$itemsStr</pre>",
'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
);
}
}