diff --git a/CRM/Emailamender/Upgrader.php b/CRM/Emailamender/Upgrader.php index 8255348..8874d7b 100644 --- a/CRM/Emailamender/Upgrader.php +++ b/CRM/Emailamender/Upgrader.php @@ -3,7 +3,7 @@ /** * Collection of upgrade steps */ -class CRM_Emailamender_Upgrader extends CRM_Emailamender_Upgrader_Base { +class CRM_Emailamender_Upgrader extends CRM_Extension_Upgrader_Base { // By convention, functions that look like "function upgrade_NNNN()" are // upgrade tasks. They are executed in order (like Drupal's hook_update_N). diff --git a/CRM/Emailamender/Upgrader/Base.php b/CRM/Emailamender/Upgrader/Base.php deleted file mode 100644 index aeb6dd3..0000000 --- a/CRM/Emailamender/Upgrader/Base.php +++ /dev/null @@ -1,298 +0,0 @@ -ctx = array_shift($args); - $instance->queue = $instance->ctx->queue; - $method = array_shift($args); - return call_user_func_array(array($instance, $method), $args); - } - - public function __construct($extensionName, $extensionDir) { - $this->extensionName = $extensionName; - $this->extensionDir = $extensionDir; - } - - // ******** Task helpers ******** - - /** - * Run a CustomData file - * - * @param string $relativePath the CustomData XML file path (relative to this extension's dir) - * @return bool - */ - public function executeCustomDataFile($relativePath) { - $xml_file = $this->extensionDir . '/' . $relativePath; - return $this->executeCustomDataFileByAbsPath($xml_file); - } - - /** - * Run a CustomData file - * - * @param string $xml_file the CustomData XML file path (absolute path) - * @return bool - */ - protected static function executeCustomDataFileByAbsPath($xml_file) { - require_once 'CRM/Utils/Migrate/Import.php'; - $import = new CRM_Utils_Migrate_Import(); - $import->run($xml_file); - return TRUE; - } - - /** - * Run a SQL file - * - * @param string $relativePath the SQL file path (relative to this extension's dir) - * @return bool - */ - public function executeSqlFile($relativePath) { - CRM_Utils_File::sourceSQLFile( - CIVICRM_DSN, - $this->extensionDir . '/' . $relativePath - ); - return TRUE; - } - - /** - * Run one SQL query - * - * This is just a wrapper for CRM_Core_DAO::executeSql, but it - * provides syntatic sugar for queueing several tasks that - * run different queries - */ - public function executeSql($query, $params = array()) { - // FIXME verify that we raise an exception on error - CRM_Core_DAO::executeSql($query, $params); - return TRUE; - } - - /** - * Syntatic sugar for enqueuing a task which calls a function - * in this class. The task is weighted so that it is processed - * as part of the currently-pending revision. - * - * After passing the $funcName, you can also pass parameters that will go to - * the function. Note that all params must be serializable. - */ - public function addTask($title) { - $args = func_get_args(); - $title = array_shift($args); - $task = new CRM_Queue_Task( - array(get_class($this), '_queueAdapter'), - $args, - $title - ); - return $this->queue->createItem($task, array('weight' => -1)); - } - - // ******** Revision-tracking helpers ******** - - /** - * Determine if there are any pending revisions - * - * @return bool - */ - public function hasPendingRevisions() { - $revisions = $this->getRevisions(); - $currentRevision = $this->getCurrentRevision(); - - if (empty($revisions)) { - return FALSE; - } - if (empty($currentRevision)) { - return TRUE; - } - - return ($currentRevision < max($revisions)); - } - - /** - * Add any pending revisions to the queue - */ - public function enqueuePendingRevisions(CRM_Queue_Queue $queue) { - $this->queue = $queue; - - $currentRevision = $this->getCurrentRevision(); - foreach ($this->getRevisions() as $revision) { - if ($revision > $currentRevision) { - $title = ts('Upgrade %1 to revision %2', array( - 1 => $this->extensionName, - 2 => $revision, - )); - - // note: don't use addTask() because it sets weight=-1 - - $task = new CRM_Queue_Task( - array(get_class($this), '_queueAdapter'), - array('upgrade_' . $revision), - $title - ); - $this->queue->createItem($task); - - $task = new CRM_Queue_Task( - array(get_class($this), '_queueAdapter'), - array('setCurrentRevision', $revision), - $title - ); - $this->queue->createItem($task); - } - } - } - - /** - * Get a list of revisions - * - * @return array(revisionNumbers) sorted numerically - */ - public function getRevisions() { - if (! is_array($this->revisions)) { - $this->revisions = array(); - - $clazz = new ReflectionClass(get_class($this)); - $methods = $clazz->getMethods(); - foreach ($methods as $method) { - if (preg_match('/^upgrade_(.*)/', $method->name, $matches)) { - $this->revisions[] = $matches[1]; - } - } - sort($this->revisions, SORT_NUMERIC); - } - - return $this->revisions; - } - - public function getCurrentRevision() { - // return CRM_Core_BAO_Extension::getSchemaVersion($this->extensionName); - $key = $this->extensionName . ':version'; - return Civi::settings()->get($key); - } - - public function setCurrentRevision($revision) { - // We call this during hook_civicrm_install, but the underlying SQL - // UPDATE fails because the extension record hasn't been INSERTed yet. - // Instead, track revisions in our own namespace. - // CRM_Core_BAO_Extension::setSchemaVersion($this->extensionName, $revision); - - $key = $this->extensionName . ':version'; - Civi::settings()->set($key, $revision); - return TRUE; - } - - // ******** Hook delegates ******** - - public function onInstall() { - $files = glob($this->extensionDir . '/sql/*_install.sql'); - if (is_array($files)) { - foreach ($files as $file) { - CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file); - } - } - $files = glob($this->extensionDir . '/xml/*_install.xml'); - if (is_array($files)) { - foreach ($files as $file) { - $this->executeCustomDataFileByAbsPath($file); - } - } - if (is_callable(array($this, 'install'))) { - $this->install(); - } - $revisions = $this->getRevisions(); - if (!empty($revisions)) { - $this->setCurrentRevision(max($revisions)); - } - } - - public function onUninstall() { - if (is_callable(array($this, 'uninstall'))) { - $this->uninstall(); - } - $files = glob($this->extensionDir . '/sql/*_uninstall.sql'); - if (is_array($files)) { - foreach ($files as $file) { - CRM_Utils_File::sourceSQLFile(CIVICRM_DSN, $file); - } - } - $this->setCurrentRevision(NULL); - } - - public function onEnable() { - // stub for possible future use - if (is_callable(array($this, 'enable'))) { - $this->enable(); - } - } - - public function onDisable() { - // stub for possible future use - if (is_callable(array($this, 'disable'))) { - $this->disable(); - } - } - - public function onUpgrade($op, CRM_Queue_Queue $queue = NULL) { - switch($op) { - case 'check': - return array($this->hasPendingRevisions()); - case 'enqueue': - return $this->enqueuePendingRevisions($queue); - default: - } - } -} diff --git a/emailamender.civix.php b/emailamender.civix.php index fa004e6..c0b66cb 100755 --- a/emailamender.civix.php +++ b/emailamender.civix.php @@ -24,7 +24,7 @@ class CRM_Emailamender_ExtensionUtil { * Translated text. * @see ts */ - public static function ts($text, $params = []) { + public static function ts($text, $params = []): string { if (!array_key_exists('domain', $params)) { $params['domain'] = [self::LONG_NAME, NULL]; } @@ -41,7 +41,7 @@ public static function ts($text, $params = []) { * Ex: 'http://example.org/sites/default/ext/org.example.foo'. * Ex: 'http://example.org/sites/default/ext/org.example.foo/css/foo.css'. */ - public static function url($file = NULL) { + public static function url($file = NULL): string { if ($file === NULL) { return rtrim(CRM_Core_Resources::singleton()->getUrl(self::LONG_NAME), '/'); } @@ -75,49 +75,70 @@ public static function findClass($suffix) { return self::CLASS_PREFIX . '_' . str_replace('\\', '_', $suffix); } + /** + * @return \CiviMix\Schema\SchemaHelperInterface + */ + public static function schema() { + if (!isset($GLOBALS['CiviMixSchema'])) { + pathload()->loadPackage('civimix-schema@5', TRUE); + } + return $GLOBALS['CiviMixSchema']->getHelper(static::LONG_NAME); + } + } use CRM_Emailamender_ExtensionUtil as E; +($GLOBALS['_PathLoad'][0] ?? require __DIR__ . '/mixin/lib/pathload-0.php'); +pathload()->addSearchDir(__DIR__ . '/mixin/lib'); +spl_autoload_register('_emailamender_civix_class_loader', TRUE, TRUE); + +function _emailamender_civix_class_loader($class) { + if ($class === 'CRM_Emailamender_DAO_Base') { + if (version_compare(CRM_Utils_System::version(), '5.74.beta', '>=')) { + class_alias('CRM_Core_DAO_Base', 'CRM_Emailamender_DAO_Base'); + // ^^ Materialize concrete names -- encourage IDE's to pick up on this association. + } + else { + $realClass = 'CiviMix\\Schema\\Emailamender\\DAO'; + class_alias($realClass, $class); + // ^^ Abstract names -- discourage IDE's from picking up on this association. + } + return; + } + + // This allows us to tap-in to the installation process (without incurring real file-reads on typical requests). + if (strpos($class, 'CiviMix\\Schema\\Emailamender\\') === 0) { + // civimix-schema@5 is designed for backported use in download/activation workflows, + // where new revisions may become dynamically available. + pathload()->loadPackage('civimix-schema@5', TRUE); + CiviMix\Schema\loadClass($class); + } +} + +function _emailamender_civix_mixin_polyfill() { + if (!class_exists('CRM_Extension_MixInfo')) { + $polyfill = __DIR__ . '/mixin/polyfill.php'; + (require $polyfill)(E::LONG_NAME, E::SHORT_NAME, E::path()); + } +} + /** * (Delegated) Implements hook_civicrm_config(). * * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_config */ -function _emailamender_civix_civicrm_config(&$config = NULL) { +function _emailamender_civix_civicrm_config($config = NULL) { static $configured = FALSE; if ($configured) { return; } $configured = TRUE; - $template =& CRM_Core_Smarty::singleton(); - - $extRoot = dirname(__FILE__) . DIRECTORY_SEPARATOR; - $extDir = $extRoot . 'templates'; - - if (is_array($template->template_dir)) { - array_unshift($template->template_dir, $extDir); - } - else { - $template->template_dir = [$extDir, $template->template_dir]; - } - + $extRoot = __DIR__ . DIRECTORY_SEPARATOR; $include_path = $extRoot . PATH_SEPARATOR . get_include_path(); set_include_path($include_path); -} - -/** - * (Delegated) Implements hook_civicrm_xmlMenu(). - * - * @param $files array(string) - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_xmlMenu - */ -function _emailamender_civix_civicrm_xmlMenu(&$files) { - foreach (_emailamender_civix_glob(__DIR__ . '/xml/Menu/*.xml') as $file) { - $files[] = $file; - } + _emailamender_civix_mixin_polyfill(); } /** @@ -127,35 +148,7 @@ function _emailamender_civix_civicrm_xmlMenu(&$files) { */ function _emailamender_civix_civicrm_install() { _emailamender_civix_civicrm_config(); - if ($upgrader = _emailamender_civix_upgrader()) { - $upgrader->onInstall(); - } -} - -/** - * Implements hook_civicrm_postInstall(). - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_postInstall - */ -function _emailamender_civix_civicrm_postInstall() { - _emailamender_civix_civicrm_config(); - if ($upgrader = _emailamender_civix_upgrader()) { - if (is_callable([$upgrader, 'onPostInstall'])) { - $upgrader->onPostInstall(); - } - } -} - -/** - * Implements hook_civicrm_uninstall(). - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_uninstall - */ -function _emailamender_civix_civicrm_uninstall() { - _emailamender_civix_civicrm_config(); - if ($upgrader = _emailamender_civix_upgrader()) { - $upgrader->onUninstall(); - } + _emailamender_civix_mixin_polyfill(); } /** @@ -163,212 +156,9 @@ function _emailamender_civix_civicrm_uninstall() { * * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_enable */ -function _emailamender_civix_civicrm_enable() { +function _emailamender_civix_civicrm_enable(): void { _emailamender_civix_civicrm_config(); - if ($upgrader = _emailamender_civix_upgrader()) { - if (is_callable([$upgrader, 'onEnable'])) { - $upgrader->onEnable(); - } - } -} - -/** - * (Delegated) Implements hook_civicrm_disable(). - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_disable - * @return mixed - */ -function _emailamender_civix_civicrm_disable() { - _emailamender_civix_civicrm_config(); - if ($upgrader = _emailamender_civix_upgrader()) { - if (is_callable([$upgrader, 'onDisable'])) { - $upgrader->onDisable(); - } - } -} - -/** - * (Delegated) Implements hook_civicrm_upgrade(). - * - * @param $op string, the type of operation being performed; 'check' or 'enqueue' - * @param $queue CRM_Queue_Queue, (for 'enqueue') the modifiable list of pending up upgrade tasks - * - * @return mixed - * based on op. for 'check', returns array(boolean) (TRUE if upgrades are pending) - * for 'enqueue', returns void - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_upgrade - */ -function _emailamender_civix_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) { - if ($upgrader = _emailamender_civix_upgrader()) { - return $upgrader->onUpgrade($op, $queue); - } -} - -/** - * @return CRM_Emailamender_Upgrader - */ -function _emailamender_civix_upgrader() { - if (!file_exists(__DIR__ . '/CRM/Emailamender/Upgrader.php')) { - return NULL; - } - else { - return CRM_Emailamender_Upgrader_Base::instance(); - } -} - -/** - * Search directory tree for files which match a glob pattern. - * - * Note: Dot-directories (like "..", ".git", or ".svn") will be ignored. - * Note: In Civi 4.3+, delegate to CRM_Utils_File::findFiles() - * - * @param string $dir base dir - * @param string $pattern , glob pattern, eg "*.txt" - * - * @return array - */ -function _emailamender_civix_find_files($dir, $pattern) { - if (is_callable(['CRM_Utils_File', 'findFiles'])) { - return CRM_Utils_File::findFiles($dir, $pattern); - } - - $todos = [$dir]; - $result = []; - while (!empty($todos)) { - $subdir = array_shift($todos); - foreach (_emailamender_civix_glob("$subdir/$pattern") as $match) { - if (!is_dir($match)) { - $result[] = $match; - } - } - if ($dh = opendir($subdir)) { - while (FALSE !== ($entry = readdir($dh))) { - $path = $subdir . DIRECTORY_SEPARATOR . $entry; - if ($entry[0] == '.') { - } - elseif (is_dir($path)) { - $todos[] = $path; - } - } - closedir($dh); - } - } - return $result; -} - -/** - * (Delegated) Implements hook_civicrm_managed(). - * - * Find any *.mgd.php files, merge their content, and return. - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_managed - */ -function _emailamender_civix_civicrm_managed(&$entities) { - $mgdFiles = _emailamender_civix_find_files(__DIR__, '*.mgd.php'); - sort($mgdFiles); - foreach ($mgdFiles as $file) { - $es = include $file; - foreach ($es as $e) { - if (empty($e['module'])) { - $e['module'] = E::LONG_NAME; - } - if (empty($e['params']['version'])) { - $e['params']['version'] = '3'; - } - $entities[] = $e; - } - } -} - -/** - * (Delegated) Implements hook_civicrm_caseTypes(). - * - * Find any and return any files matching "xml/case/*.xml" - * - * Note: This hook only runs in CiviCRM 4.4+. - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_caseTypes - */ -function _emailamender_civix_civicrm_caseTypes(&$caseTypes) { - if (!is_dir(__DIR__ . '/xml/case')) { - return; - } - - foreach (_emailamender_civix_glob(__DIR__ . '/xml/case/*.xml') as $file) { - $name = preg_replace('/\.xml$/', '', basename($file)); - if ($name != CRM_Case_XMLProcessor::mungeCaseType($name)) { - $errorMessage = sprintf("Case-type file name is malformed (%s vs %s)", $name, CRM_Case_XMLProcessor::mungeCaseType($name)); - throw new CRM_Core_Exception($errorMessage); - } - $caseTypes[$name] = [ - 'module' => E::LONG_NAME, - 'name' => $name, - 'file' => $file, - ]; - } -} - -/** - * (Delegated) Implements hook_civicrm_angularModules(). - * - * Find any and return any files matching "ang/*.ang.php" - * - * Note: This hook only runs in CiviCRM 4.5+. - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_angularModules - */ -function _emailamender_civix_civicrm_angularModules(&$angularModules) { - if (!is_dir(__DIR__ . '/ang')) { - return; - } - - $files = _emailamender_civix_glob(__DIR__ . '/ang/*.ang.php'); - foreach ($files as $file) { - $name = preg_replace(':\.ang\.php$:', '', basename($file)); - $module = include $file; - if (empty($module['ext'])) { - $module['ext'] = E::LONG_NAME; - } - $angularModules[$name] = $module; - } -} - -/** - * (Delegated) Implements hook_civicrm_themes(). - * - * Find any and return any files matching "*.theme.php" - */ -function _emailamender_civix_civicrm_themes(&$themes) { - $files = _emailamender_civix_glob(__DIR__ . '/*.theme.php'); - foreach ($files as $file) { - $themeMeta = include $file; - if (empty($themeMeta['name'])) { - $themeMeta['name'] = preg_replace(':\.theme\.php$:', '', basename($file)); - } - if (empty($themeMeta['ext'])) { - $themeMeta['ext'] = E::LONG_NAME; - } - $themes[$themeMeta['name']] = $themeMeta; - } -} - -/** - * Glob wrapper which is guaranteed to return an array. - * - * The documentation for glob() says, "On some systems it is impossible to - * distinguish between empty match and an error." Anecdotally, the return - * result for an empty match is sometimes array() and sometimes FALSE. - * This wrapper provides consistency. - * - * @link http://php.net/glob - * @param string $pattern - * - * @return array - */ -function _emailamender_civix_glob($pattern) { - $result = glob($pattern); - return is_array($result) ? $result : []; + _emailamender_civix_mixin_polyfill(); } /** @@ -387,8 +177,8 @@ function _emailamender_civix_insert_navigation_menu(&$menu, $path, $item) { if (empty($path)) { $menu[] = [ 'attributes' => array_merge([ - 'label' => CRM_Utils_Array::value('name', $item), - 'active' => 1, + 'label' => $item['name'] ?? NULL, + 'active' => 1, ], $item), ]; return TRUE; @@ -452,26 +242,3 @@ function _emailamender_civix_fixNavigationMenuItems(&$nodes, &$maxNavID, $parent } } } - -/** - * (Delegated) Implements hook_civicrm_alterSettingsFolders(). - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_alterSettingsFolders - */ -function _emailamender_civix_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) { - $settingsDir = __DIR__ . DIRECTORY_SEPARATOR . 'settings'; - if (!in_array($settingsDir, $metaDataFolders) && is_dir($settingsDir)) { - $metaDataFolders[] = $settingsDir; - } -} - -/** - * (Delegated) Implements hook_civicrm_entityTypes(). - * - * Find any *.entityType.php files, merge their content, and return. - * - * @link https://docs.civicrm.org/dev/en/latest/hooks/hook_civicrm_entityTypes - */ -function _emailamender_civix_civicrm_entityTypes(&$entityTypes) { - $entityTypes = array_merge($entityTypes, []); -} diff --git a/emailamender.php b/emailamender.php index 0dd200c..d732bcf 100755 --- a/emailamender.php +++ b/emailamender.php @@ -9,13 +9,6 @@ function emailamender_civicrm_config(&$config) { _emailamender_civix_civicrm_config($config); } -/** - * Implements hook_civicrm_xmlMenu(). - */ -function emailamender_civicrm_xmlMenu(&$files) { - _emailamender_civix_civicrm_xmlMenu($files); -} - /** * Implements hook_civicrm_install(). */ @@ -37,7 +30,7 @@ function emailamender_civicrm_uninstall() { CRM_Core_DAO::executeQuery("DELETE FROM civicrm_setting WHERE name LIKE 'emailamender%'"); - return _emailamender_civix_civicrm_uninstall(); + return; } /** @@ -47,30 +40,6 @@ function emailamender_civicrm_enable() { return _emailamender_civix_civicrm_enable(); } -/** - * Implements hook_civicrm_disable(). - */ -function emailamender_civicrm_disable() { - return _emailamender_civix_civicrm_disable(); -} - -/** - * Implements hook_civicrm_upgrade(). - */ -function emailamender_civicrm_upgrade($op, CRM_Queue_Queue $queue = NULL) { - return _emailamender_civix_civicrm_upgrade($op, $queue); -} - -/** - * Implements hook_civicrm_managed(). - * - * Generate a list of entities to create/deactivate/delete when this module - * is installed, disabled, uninstalled. - */ -function emailamender_civicrm_managed(&$entities) { - return _emailamender_civix_civicrm_managed($entities); -} - /** * Implements hook_civicrm_post(). * @@ -93,15 +62,6 @@ function emailamender_civicrm_emailProcessorContact($email, $contactID, &$result CRM_Emailamender_Equivalentmatcher::processHook($email, $contactID, $result); } -/** - * Implements hook_civicrm_alterSettingsFolders(). - * - * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_alterSettingsFolders - */ -function emailamender_civicrm_alterSettingsFolders(&$metaDataFolders = NULL) { - _emailamender_civix_civicrm_alterSettingsFolders($metaDataFolders); -} - /** * civicrm_civicrm_navigationMenu * @@ -142,18 +102,3 @@ function emailamender_civicrm_searchTasks($objectType, &$tasks) { ); } } - -/** - * Implements hook_civicrm_angularModules(). - * - * Generate a list of Angular modules. - * Generate a list of Angular modules. - * - * Note: This hook only runs in CiviCRM 4.5+. It may - * use features only available in v4.6+. - * - * @link http://wiki.civicrm.org/confluence/display/CRMDOC/hook_civicrm_angularModules - */ -function emailamender_civicrm_angularModules(&$angularModules) { - _emailamender_civix_civicrm_angularModules($angularModules); -} diff --git a/info.xml b/info.xml index 38755d6..e961363 100755 --- a/info.xml +++ b/info.xml @@ -4,7 +4,7 @@ Email Address Corrector This extension allows automatic and manual correction of email addresses that are added to your database. i.e. john@hotmai.cpm is corrected to john@hotmail.com. - Settings under Administrator -> System Settings -> Email Address Corrector. Automatic correction is disabled by default. Also supports domain equivalents to reduce duplicates. See comments. + Settings under Administrator -> System Settings -> Email Address Corrector. Automatic correction is disabled by default. Also supports domain equivalents to reduce duplicates. See comments. AGPL 3.0 @@ -15,9 +15,7 @@ 3.0.1 stable - 4.7 - 5.18 - 5.22 + 5.38 Data Cleaning @@ -28,6 +26,17 @@ CRM/Emailamender + 25.01.1 https://github.com/JohnFF/Email-Amender/wiki + + menu-xml@1.0.0 + setting-php@1.0.0 + smarty-v2@1.0.3 + + + + + + CiviMix\Schema\Emailamender\AutomaticUpgrader diff --git a/mixin/lib/civimix-schema@5.85.beta1/pathload.main.php b/mixin/lib/civimix-schema@5.85.beta1/pathload.main.php new file mode 100644 index 0000000..f0442e8 --- /dev/null +++ b/mixin/lib/civimix-schema@5.85.beta1/pathload.main.php @@ -0,0 +1,28 @@ +activatePackage('civimix-schema@5', __DIR__, [ + 'reloadable' => TRUE, + // The civimix-schema library specifically supports installation processes. From a + // bootstrap/service-availability POV, this is a rough environment which leads to + // the "Multi-Activation Issue" and "Multi-Download Issue". To adapt to them, + // civimix-schema follows "Reloadable Library" patterns. + // More information: https://github.com/totten/pathload-poc/blob/master/doc/issues.md +]); + +// When reloading, we make newer instance of the Facade object. +$GLOBALS['CiviMixSchema'] = require __DIR__ . '/src/CiviMixSchema.php'; + +if (!interface_exists(__NAMESPACE__ . '\SchemaHelperInterface')) { + require __DIR__ . '/src/SchemaHelperInterface.php'; +} + +// \CiviMix\Schema\loadClass() is a facade. The facade should remain identical across versions. +if (!function_exists(__NAMESPACE__ . '\loadClass')) { + + function loadClass(string $class) { + return $GLOBALS['CiviMixSchema']->loadClass($class); + } + + spl_autoload_register(__NAMESPACE__ . '\loadClass'); +} diff --git a/mixin/lib/civimix-schema@5.85.beta1/src/AutomaticUpgrader.php b/mixin/lib/civimix-schema@5.85.beta1/src/AutomaticUpgrader.php new file mode 100644 index 0000000..db6140b --- /dev/null +++ b/mixin/lib/civimix-schema@5.85.beta1/src/AutomaticUpgrader.php @@ -0,0 +1,181 @@ +initIdentity($params); + if ($info = $this->getInfo()) { + if ($class = $this->getDelegateUpgraderClass($info)) { + $this->customUpgrader = new $class(); + $this->customUpgrader->init($params); + if ($errors = $this->checkDelegateCompatibility($this->customUpgrader)) { + throw new \CRM_Core_Exception("AutomaticUpgrader is not compatible with $class:\n" . implode("\n", $errors)); + } + } + } + } + + public function notify(string $event, array $params = []) { + $info = $this->getInfo(); + if (!$info) { + return; + } + + if ($event === 'install') { + $GLOBALS['CiviMixSchema']->getHelper($this->getExtensionKey())->install(); + } + + if ($this->customUpgrader) { + $result = $this->customUpgrader->notify($event, $params); + // for upgrade checks, we need to pass check results up to the caller + // (for now - could definitely be more elegant!) + if ($event === 'upgrade') { + return $result; + } + } + + if ($event === 'uninstall') { + $GLOBALS['CiviMixSchema']->getHelper($this->getExtensionKey())->uninstall(); + } + } + + /** + * Civix-based extensions have a conventional name for their upgrader class ("CRM_Myext_Upgrader" + * or "Civi\Myext\Upgrader"). Figure out if this class exists. + * + * @param \CRM_Extension_Info $info + * @return string|null + * Ex: 'CRM_Myext_Upgrader' or 'Civi\Myext\Upgrader' + */ + public function getDelegateUpgraderClass(\CRM_Extension_Info $info): ?string { + $candidates = []; + + if (!empty($info->civix['namespace'])) { + $namespace = $info->civix['namespace']; + $candidates[] = sprintf('%s_Upgrader', str_replace('/', '_', $namespace)); + $candidates[] = sprintf('%s\\Upgrader', str_replace('/', '\\', $namespace)); + } + + foreach ($candidates as $candidate) { + if (class_exists($candidate)) { + return $candidate; + } + } + + return NULL; + } + + public function getInfo(): ?\CRM_Extension_Info { + try { + return \CRM_Extension_System::singleton()->getMapper()->keyToInfo($this->extensionName); + } + catch (\CRM_Extension_Exception_ParseException $e) { + \Civi::log()->error("Parse error in extension " . $this->extensionName . ": " . $e->getMessage()); + return NULL; + } + } + + /** + * @param \CRM_Extension_Upgrader_Interface $upgrader + * @return array + * List of error messages. + */ + public function checkDelegateCompatibility($upgrader): array { + $class = get_class($upgrader); + + $errors = []; + + if (!($upgrader instanceof \CRM_Extension_Upgrader_Base)) { + $errors[] = "$class is not based on CRM_Extension_Upgrader_Base."; + return $errors; + } + + // In the future, we will probably modify AutomaticUpgrader to build its own + // sequence of revisions (based on other sources of data). AutomaticUpgrader + // is only regarded as compatible with classes that strictly follow the standard revision-model. + $methodNames = [ + 'appendTask', + 'onUpgrade', + 'getRevisions', + 'getCurrentRevision', + 'setCurrentRevision', + 'enqueuePendingRevisions', + 'hasPendingRevisions', + ]; + foreach ($methodNames as $methodName) { + $method = new \ReflectionMethod($upgrader, $methodName); + if ($method->getDeclaringClass()->getName() !== 'CRM_Extension_Upgrader_Base') { + $errors[] = "To ensure future interoperability, AutomaticUpgrader only supports {$class}::{$methodName}() if it's inherited from CRM_Extension_Upgrader_Base"; + } + } + + return $errors; + } + + public function __set($property, $value) { + switch ($property) { + // _queueAdapter() needs these properties. + case 'ctx': + case 'queue': + if (!$this->customUpgrader) { + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot assign delegated property: $property (No custom-upgrader found)"); + } + // "Invasive": unlike QueueTrait, we are not in the same class as the recipient. And we can't replace previously-published QueueTraits. + Invasive::set([$this->customUpgrader, $property], $value); + return; + } + + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot assign unknown property: $property"); + } + + public function __get($property) { + switch ($property) { + // _queueAdapter() needs these properties. + case 'ctx': + case 'queue': + if (!$this->customUpgrader) { + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot read delegated property: $property (No custom-upgrader found)"); + } + // "Invasive": Unlike QueueTrait, we are not in the same class as the recipient. And we can't replace previously-published QueueTraits. + return Invasive::get([$this->customUpgrader, $property]); + } + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot read unknown property: $property"); + } + + public function __call($name, $arguments) { + if ($this->customUpgrader) { + return call_user_func_array([$this->customUpgrader, $name], $arguments); + } + else { + throw new \RuntimeException("AutomaticUpgrader($this->extensionName): Cannot delegate method $name (No custom-upgrader found)"); + } + } + +}; diff --git a/mixin/lib/civimix-schema@5.85.beta1/src/CiviMixSchema.php b/mixin/lib/civimix-schema@5.85.beta1/src/CiviMixSchema.php new file mode 100644 index 0000000..6d0a350 --- /dev/null +++ b/mixin/lib/civimix-schema@5.85.beta1/src/CiviMixSchema.php @@ -0,0 +1,46 @@ +regex, $class, $m)) { + $absPath = __DIR__ . DIRECTORY_SEPARATOR . $m[2] . '.php'; + class_alias(get_class(require $absPath), $class); + } + } + + /** + * @param string $extensionKey + * Ex: 'org.civicrm.flexmailer' + * @return \CiviMix\Schema\SchemaHelperInterface + */ + public function getHelper(string $extensionKey) { + $store = &\Civi::$statics['CiviMixSchema-helpers']; + if (!isset($store[$extensionKey])) { + $class = get_class(require __DIR__ . '/SchemaHelper.php'); + $store[$extensionKey] = new $class($extensionKey); + } + return $store[$extensionKey]; + } + +}; diff --git a/mixin/lib/civimix-schema@5.85.beta1/src/DAO.php b/mixin/lib/civimix-schema@5.85.beta1/src/DAO.php new file mode 100644 index 0000000..3b82b27 --- /dev/null +++ b/mixin/lib/civimix-schema@5.85.beta1/src/DAO.php @@ -0,0 +1,350 @@ + $field) { + $this->$name = NULL; + } + } + + /** + * @inheritDoc + */ + public function keys(): array { + $keys = []; + foreach (static::getEntityDefinition()['getFields']() as $name => $field) { + if (!empty($field['primary_key'])) { + $keys[] = $name; + } + } + return $keys; + } + + public static function getEntityTitle($plural = FALSE) { + $info = static::getEntityInfo(); + return ($plural && isset($info['title_plural'])) ? $info['title_plural'] : $info['title']; + } + + /** + * @inheritDoc + */ + public static function getEntityPaths(): array { + $definition = static::getEntityDefinition(); + if (isset($definition['getPaths'])) { + return $definition['getPaths'](); + } + return []; + } + + public static function getLabelField(): ?string { + return static::getEntityInfo()['label_field'] ?? NULL; + } + + /** + * @inheritDoc + */ + public static function getEntityDescription(): ?string { + return static::getEntityInfo()['description'] ?? NULL; + } + + /** + * @inheritDoc + */ + public static function getTableName() { + return static::getEntityDefinition()['table']; + } + + /** + * @inheritDoc + */ + public function getLog(): bool { + return static::getEntityInfo()['log'] ?? FALSE; + } + + /** + * @inheritDoc + */ + public static function getEntityIcon(string $entityName, ?int $entityId = NULL): ?string { + return static::getEntityInfo()['icon'] ?? NULL; + } + + /** + * @inheritDoc + */ + protected static function getTableAddVersion(): string { + return static::getEntityInfo()['add'] ?? '1.0'; + } + + /** + * @inheritDoc + */ + public static function getExtensionName(): ?string { + return static::getEntityDefinition()['module']; + } + + /** + * @inheritDoc + */ + public static function &fields() { + $fields = []; + foreach (static::getSchemaFields() as $field) { + $key = $field['uniqueName'] ?? $field['name']; + unset($field['uniqueName']); + $fields[$key] = $field; + } + return $fields; + } + + private static function getSchemaFields(): array { + if (!isset(\Civi::$statics[static::class]['fields'])) { + \Civi::$statics[static::class]['fields'] = static::loadSchemaFields(); + } + return \Civi::$statics[static::class]['fields']; + } + + private static function loadSchemaFields(): array { + $fields = []; + $entityDef = static::getEntityDefinition(); + $baoName = \CRM_Core_DAO_AllCoreTables::getBAOClassName(static::class); + + foreach ($entityDef['getFields']() as $fieldName => $fieldSpec) { + $field = [ + 'name' => $fieldName, + 'type' => !empty($fieldSpec['data_type']) ? \CRM_Utils_Type::getValidTypes()[$fieldSpec['data_type']] : static::getCrmTypeFromSqlType($fieldSpec['sql_type']), + 'title' => $fieldSpec['title'], + 'description' => $fieldSpec['description'] ?? NULL, + ]; + if (!empty($fieldSpec['required'])) { + $field['required'] = TRUE; + } + if (strpos($fieldSpec['sql_type'], 'decimal(') === 0) { + $precision = self::getFieldLength($fieldSpec['sql_type']); + $field['precision'] = array_map('intval', explode(',', $precision)); + } + foreach (['maxlength', 'size', 'rows', 'cols'] as $attr) { + if (isset($fieldSpec['input_attrs'][$attr])) { + $field[$attr] = $fieldSpec['input_attrs'][$attr]; + unset($fieldSpec['input_attrs'][$attr]); + } + } + if (strpos($fieldSpec['sql_type'], 'char(') !== FALSE) { + $length = self::getFieldLength($fieldSpec['sql_type']); + if (!isset($field['size'])) { + $field['size'] = constant(static::getDefaultSize($length)); + } + if (!isset($field['maxlength'])) { + $field['maxlength'] = $length; + } + } + $usage = $fieldSpec['usage'] ?? []; + $field['usage'] = [ + 'import' => in_array('import', $usage), + 'export' => in_array('export', $usage), + 'duplicate_matching' => in_array('duplicate_matching', $usage), + 'token' => in_array('token', $usage), + ]; + if ($field['usage']['import']) { + $field['import'] = TRUE; + } + $field['where'] = $entityDef['table'] . '.' . $field['name']; + if ($field['usage']['export'] || (!$field['usage']['export'] && $field['usage']['import'])) { + $field['export'] = $field['usage']['export']; + } + if (!empty($fieldSpec['contact_type'])) { + $field['contactType'] = $fieldSpec['contact_type']; + } + if (!empty($fieldSpec['permission'])) { + $field['permission'] = $fieldSpec['permission']; + } + if (array_key_exists('default', $fieldSpec)) { + $field['default'] = isset($fieldSpec['default']) ? (string) $fieldSpec['default'] : NULL; + if (is_bool($fieldSpec['default'])) { + $field['default'] = $fieldSpec['default'] ? '1' : '0'; + } + } + $field['table_name'] = $entityDef['table']; + $field['entity'] = $entityDef['name']; + $field['bao'] = $baoName; + $field['localizable'] = intval($fieldSpec['localizable'] ?? 0); + if (!empty($fieldSpec['localize_context'])) { + $field['localize_context'] = (string) $fieldSpec['localize_context']; + } + if (!empty($fieldSpec['entity_reference'])) { + if (!empty($fieldSpec['entity_reference']['entity'])) { + $field['FKClassName'] = static::getDAONameForEntity($fieldSpec['entity_reference']['entity']); + } + if (!empty($fieldSpec['entity_reference']['dynamic_entity'])) { + $field['DFKEntityColumn'] = $fieldSpec['entity_reference']['dynamic_entity']; + } + $field['FKColumnName'] = $fieldSpec['entity_reference']['key'] ?? 'id'; + } + if (!empty($fieldSpec['component'])) { + $field['component'] = $fieldSpec['component']; + } + if (!empty($fieldSpec['serialize'])) { + $field['serialize'] = $fieldSpec['serialize']; + } + if (!empty($fieldSpec['unique_name'])) { + $field['uniqueName'] = $fieldSpec['unique_name']; + } + if (!empty($fieldSpec['unique_title'])) { + $field['unique_title'] = $fieldSpec['unique_title']; + } + if (!empty($fieldSpec['deprecated'])) { + $field['deprecated'] = TRUE; + } + if (!empty($fieldSpec['input_attrs'])) { + $field['html'] = \CRM_Utils_Array::rekey($fieldSpec['input_attrs'], function($str) { + return \CRM_Utils_String::convertStringToCamel($str, FALSE); + }); + } + if (!empty($fieldSpec['input_type'])) { + $field['html']['type'] = $fieldSpec['input_type']; + } + if (!empty($fieldSpec['pseudoconstant'])) { + $field['pseudoconstant'] = \CRM_Utils_Array::rekey($fieldSpec['pseudoconstant'], function($str) { + return \CRM_Utils_String::convertStringToCamel($str, FALSE); + }); + if (!isset($field['pseudoconstant']['optionEditPath']) && !empty($field['pseudoconstant']['optionGroupName'])) { + $field['pseudoconstant']['optionEditPath'] = 'civicrm/admin/options/' . $field['pseudoconstant']['optionGroupName']; + } + } + if (!empty($fieldSpec['primary_key']) || !empty($fieldSpec['readonly'])) { + $field['readonly'] = TRUE; + } + $field['add'] = $fieldSpec['add'] ?? NULL; + $fields[$fieldName] = $field; + } + \CRM_Core_DAO_AllCoreTables::invoke(static::class, 'fields_callback', $fields); + return $fields; + } + + private static function getFieldLength($sqlType): ?string { + $open = strpos($sqlType, '('); + if ($open) { + return substr($sqlType, $open + 1, -1); + } + return NULL; + } + + /** + * @inheritDoc + */ + public static function indices(bool $localize = TRUE): array { + $definition = static::getEntityDefinition(); + $indices = []; + if (isset($definition['getIndices'])) { + $fields = $definition['getFields'](); + foreach ($definition['getIndices']() as $name => $info) { + $index = [ + 'name' => $name, + 'field' => [], + 'localizable' => FALSE, + ]; + foreach ($info['fields'] as $fieldName => $length) { + if (!empty($fields[$fieldName]['localizable'])) { + $index['localizable'] = TRUE; + } + if (is_int($length)) { + $fieldName .= "($length)"; + } + $index['field'][] = $fieldName; + } + if (!empty($info['unique'])) { + $index['unique'] = TRUE; + } + $index['sig'] = ($definition['table']) . '::' . intval($info['unique'] ?? 0) . '::' . implode('::', $index['field']); + $indices[$name] = $index; + } + } + return ($localize && $indices) ? \CRM_Core_DAO_AllCoreTables::multilingualize(static::class, $indices) : $indices; + } + + public static function getEntityDefinition(): array { + if (!isset(\Civi::$statics[static::class]['definition'])) { + $class = new \ReflectionClass(static::class); + $file = substr(basename($class->getFileName()), 0, -4) . '.entityType.php'; + $folder = dirname($class->getFileName(), 4) . '/schema/'; + $path = $folder . $file; + \Civi::$statics[static::class]['definition'] = include $path; + } + return \Civi::$statics[static::class]['definition']; + } + + private static function getEntityInfo(): array { + return static::getEntityDefinition()['getInfo'](); + } + + private static function getDefaultSize($length) { + // Infer from tag if was not explicitly set or was invalid + // This map is slightly different from CRM_Core_Form_Renderer::$_sizeMapper + // Because we usually want fields to render as smaller than their maxlength + $sizes = [ + 2 => 'TWO', + 4 => 'FOUR', + 6 => 'SIX', + 8 => 'EIGHT', + 16 => 'TWELVE', + 32 => 'MEDIUM', + 64 => 'BIG', + ]; + foreach ($sizes as $size => $name) { + if ($length <= $size) { + return "CRM_Utils_Type::$name"; + } + } + return 'CRM_Utils_Type::HUGE'; + } + + private static function getCrmTypeFromSqlType(string $sqlType): int { + [$type] = explode('(', $sqlType); + switch ($type) { + case 'varchar': + case 'char': + return \CRM_Utils_Type::T_STRING; + + case 'datetime': + return \CRM_Utils_Type::T_DATE + \CRM_Utils_Type::T_TIME; + + case 'decimal': + return \CRM_Utils_Type::T_MONEY; + + case 'double': + return \CRM_Utils_Type::T_FLOAT; + + case 'int unsigned': + case 'tinyint': + return \CRM_Utils_Type::T_INT; + + default: + return constant('CRM_Utils_Type::T_' . strtoupper($type)); + } + } + + private static function getDAONameForEntity($entity) { + if (is_callable(['CRM_Core_DAO_AllCoreTables', 'getDAONameForEntity'])) { + return \CRM_Core_DAO_AllCoreTables::getDAONameForEntity($entity); + } + else { + return \CRM_Core_DAO_AllCoreTables::getFullName($entity); + } + } + +}; diff --git a/mixin/lib/civimix-schema@5.85.beta1/src/SchemaHelper.php b/mixin/lib/civimix-schema@5.85.beta1/src/SchemaHelper.php new file mode 100644 index 0000000..46dc311 --- /dev/null +++ b/mixin/lib/civimix-schema@5.85.beta1/src/SchemaHelper.php @@ -0,0 +1,138 @@ +key = $key; + } + + public function install(): void { + $this->runSqls([$this->generateInstallSql()]); + } + + public function uninstall(): void { + $this->runSqls([$this->generateUninstallSql()]); + } + + public function generateInstallSql(): ?string { + return $this->getSqlGenerator()->getCreateTablesSql(); + } + + public function generateUninstallSql(): string { + return $this->getSqlGenerator()->getDropTablesSql(); + } + + public function hasSchema(): bool { + return file_exists($this->getExtensionDir() . '/schema'); + } + + /** + * Converts an entity or field definition to SQL statement. + * + * @param array $defn + * The definition array, which can either represent + * an entity with fields or a single database column. + * @return string + * The generated SQL statement, which is either an SQL command + * for creating a table with constraints or for defining a single column. + */ + public function arrayToSql(array $defn): string { + $generator = $this->getSqlGenerator(); + // Entity array: generate entire table + if (isset($defn['getFields'])) { + return $generator->generateCreateTableWithConstraintSql($defn); + } + // Field array: generate single column + else { + return $generator->generateFieldSql($defn); + } + } + + /** + * Create table (if not exists) from a given php schema file. + * + * The original entityType.php file should be copied to a directory (e.g. `my_extension/upgrade/schema`) + * and prefixed with the version-added. + * + * @param string $filePath + * Relative path to copied schema file (relative to extension directory). + * @return bool + * @throws \CRM_Core_Exception + */ + public function createEntityTable(string $filePath): bool { + $absolutePath = $this->getExtensionDir() . DIRECTORY_SEPARATOR . $filePath; + $entityDefn = include $absolutePath; + $sql = $this->arrayToSql($entityDefn); + \CRM_Core_DAO::executeQuery($sql, [], TRUE, NULL, FALSE, FALSE); + return TRUE; + } + + /** + * Task to add or change a column definition, based on the php schema spec. + * + * @param string $entityName + * @param string $fieldName + * @param array $fieldSpec + * As definied in the .entityType.php file for $entityName + * @return bool + * @throws \CRM_Core_Exception + */ + public function alterSchemaField(string $entityName, string $fieldName, array $fieldSpec): bool { + $tableName = method_exists('Civi', 'entity') + ? \Civi::entity($entityName)->getMeta('table') + : \CRM_Core_DAO_AllCoreTables::getTableForEntityName($entityName); + $fieldSql = $this->arrayToSql($fieldSpec); + if (\CRM_Core_BAO_SchemaHandler::checkIfFieldExists($tableName, $fieldName, FALSE)) { + $query = "ALTER TABLE `$tableName` CHANGE `$fieldName` `$fieldName` $fieldSql"; + } + else { + $query = "ALTER TABLE `$tableName` ADD COLUMN `$fieldName` $fieldSql"; + } + \CRM_Core_DAO::executeQuery($query, [], TRUE, NULL, FALSE, FALSE); + return TRUE; + } + + /** + * @param array $sqls + * List of SQL scripts. + */ + private function runSqls(array $sqls): void { + foreach ($sqls as $sql) { + \CRM_Utils_File::runSqlQuery(CIVICRM_DSN, $sql); + } + } + + protected function getExtensionDir(): string { + if ($this->key === 'civicrm') { + $r = new \ReflectionClass('CRM_Core_ClassLoader'); + return dirname($r->getFileName(), 3); + } + $system = \CRM_Extension_System::singleton(); + return $system->getMapper()->keyToBasePath($this->key); + } + + private function getSqlGenerator() { + if ($this->sqlGenerator === NULL) { + $gen = require __DIR__ . '/SqlGenerator.php'; + $this->sqlGenerator = $gen::createFromFolder($this->key, $this->getExtensionDir() . '/schema', $this->key === 'civicrm'); + } + return $this->sqlGenerator; + } + +}; diff --git a/mixin/lib/civimix-schema@5.85.beta1/src/SchemaHelperInterface.php b/mixin/lib/civimix-schema@5.85.beta1/src/SchemaHelperInterface.php new file mode 100644 index 0000000..d14b91c --- /dev/null +++ b/mixin/lib/civimix-schema@5.85.beta1/src/SchemaHelperInterface.php @@ -0,0 +1,36 @@ +entities = array_filter($entities, function($entity) { + return !empty($entity['table']); + }); + $this->findExternalTable = $findExternalTable ?: function() { + return NULL; + }; + } + + public function getEntities(): array { + return $this->entities; + } + + public function getCreateTablesSql(): string { + $sql = ''; + foreach ($this->entities as $entity) { + $sql .= $this->generateCreateTableSql($entity); + } + foreach ($this->entities as $entity) { + $sql .= $this->generateConstraintsSql($entity); + } + return $sql; + } + + public function getCreateTableSql(string $entityName): string { + $sql = $this->generateCreateTableSql($this->entities[$entityName]); + $sql .= $this->generateConstraintsSql($this->entities[$entityName]); + return $sql; + } + + public function getDropTablesSql(): string { + $sql = "SET FOREIGN_KEY_CHECKS=0;\n"; + foreach ($this->entities as $entity) { + $sql .= "DROP TABLE IF EXISTS `{$entity['table']}`;\n"; + } + $sql .= "SET FOREIGN_KEY_CHECKS=1;\n"; + return $sql; + } + + public function generateCreateTableWithConstraintSql(array $entity): string { + $definition = $this->getTableDefinition($entity); + $constraints = $this->getTableConstraints($entity); + $sql = "CREATE TABLE IF NOT EXISTS `{$entity['table']}` (\n " . + implode(",\n ", $definition); + if ($constraints) { + $sql .= ",\n " . implode(",\n ", $constraints); + } + $sql .= "\n)\n" . $this->getTableOptions() . ";\n"; + return $sql; + } + + private function generateCreateTableSql(array $entity): string { + $definition = $this->getTableDefinition($entity); + $sql = "CREATE TABLE `{$entity['table']}` (\n " . + implode(",\n ", $definition) . + "\n)\n" . + $this->getTableOptions() . ";\n"; + return $sql; + } + + private function getTableDefinition(array $entity): array { + $definition = []; + $primaryKeys = []; + foreach ($entity['getFields']() as $fieldName => $field) { + if (!empty($field['primary_key'])) { + $primaryKeys[] = "`$fieldName`"; + } + $definition[] = "`$fieldName` " . self::generateFieldSql($field); + } + if ($primaryKeys) { + $definition[] = 'PRIMARY KEY (' . implode(', ', $primaryKeys) . ')'; + } + $indices = isset($entity['getIndices']) ? $entity['getIndices']() : []; + foreach ($indices as $indexName => $index) { + $indexFields = []; + foreach ($index['fields'] as $fieldName => $length) { + $indexFields[] = "`$fieldName`" . (is_int($length) ? "($length)" : ''); + } + $definition[] = (!empty($index['unique']) ? 'UNIQUE ' : '') . "INDEX `$indexName`(" . implode(', ', $indexFields) . ')'; + } + return $definition; + } + + private function generateConstraintsSql(array $entity): string { + $constraints = $this->getTableConstraints($entity); + $sql = ''; + if ($constraints) { + $sql .= "ALTER TABLE `{$entity['table']}`\n "; + $sql .= 'ADD ' . implode(",\n ADD ", $constraints) . ";\n"; + } + return $sql; + } + + private function getTableConstraints(array $entity): array { + $constraints = []; + foreach ($entity['getFields']() as $fieldName => $field) { + if (!empty($field['entity_reference']['entity'])) { + $fkName = \CRM_Core_BAO_SchemaHandler::getIndexName($entity['table'], $fieldName); + $constraint = "CONSTRAINT `FK_$fkName` FOREIGN KEY (`$fieldName`)" . + " REFERENCES `" . $this->getTableForEntity($field['entity_reference']['entity']) . "`(`{$field['entity_reference']['key']}`)"; + if (!empty($field['entity_reference']['on_delete'])) { + $constraint .= " ON DELETE {$field['entity_reference']['on_delete']}"; + } + $constraints[] = $constraint; + } + } + return $constraints; + } + + public static function generateFieldSql(array $field): string { + $fieldSql = $field['sql_type']; + if (!empty($field['collate'])) { + $fieldSql .= " COLLATE {$field['collate']}"; + } + // Required fields and booleans cannot be null + // FIXME: For legacy support this doesn't force boolean fields to be NOT NULL... but it really should. + if (!empty($field['required'])) { + $fieldSql .= ' NOT NULL'; + } + else { + $fieldSql .= ' NULL'; + } + if (!empty($field['auto_increment'])) { + $fieldSql .= " AUTO_INCREMENT"; + } + $fieldSql .= self::getDefaultSql($field); + if (!empty($field['description'])) { + $fieldSql .= " COMMENT '" . \CRM_Core_DAO::escapeString($field['description']) . "'"; + } + return $fieldSql; + } + + private static function getDefaultSql(array $field): string { + // Booleans always have a default + if ($field['sql_type'] === 'boolean') { + $field += ['default' => FALSE]; + } + if (!array_key_exists('default', $field)) { + return ''; + } + if (is_null($field['default'])) { + $default = 'NULL'; + } + elseif (is_bool($field['default'])) { + $default = $field['default'] ? 'TRUE' : 'FALSE'; + } + elseif (!is_string($field['default']) || str_starts_with($field['default'], 'CURRENT_TIMESTAMP')) { + $default = $field['default']; + } + else { + $default = "'" . \CRM_Core_DAO::escapeString($field['default']) . "'"; + } + return ' DEFAULT ' . $default; + } + + private function getTableForEntity(string $entityName): string { + return $this->entities[$entityName]['table'] ?? call_user_func($this->findExternalTable, $entityName); + } + + /** + * Get general/default options for use in CREATE TABLE (eg character set, collation). + */ + private function getTableOptions(): string { + if (!Civi\Core\Container::isContainerBooted()) { + // Pre-installation environment ==> aka new install + $collation = CRM_Core_BAO_SchemaHandler::DEFAULT_COLLATION; + } + else { + // What character-set is used for CiviCRM core schema? What collation? + // This depends on when the DB was *initialized*: + // - civicrm-core >= 5.33 has used `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` + // - civicrm-core 4.3-5.32 has used `CHARACTER SET utf8 COLLATE utf8_unicode_ci` + // - civicrm-core <= 4.2 -- I haven't checked, but it's probably the same. + // Some systems have migrated (eg APIv3's `System.utf8conversion`), but (as of Feb 2024) + // we haven't made any effort to push to this change. + $collation = \CRM_Core_BAO_SchemaHandler::getInUseCollation(); + } + + $characterSet = (stripos($collation, 'utf8mb4') !== FALSE) ? 'utf8mb4' : 'utf8'; + return "ENGINE=InnoDB DEFAULT CHARACTER SET {$characterSet} COLLATE {$collation} ROW_FORMAT=DYNAMIC"; + } + +}; diff --git a/mixin/lib/pathload-0.php b/mixin/lib/pathload-0.php new file mode 100644 index 0000000..76d6425 --- /dev/null +++ b/mixin/lib/pathload-0.php @@ -0,0 +1,711 @@ +top = $top; + } + public function offsetExists($version): bool { + return ($version === 'top' || $version <= $this->top->version); + } + public function offsetGet($version): ?\PathLoadInterface { + if ($version === 'top' || $version <= $this->top->version) { + return $this->top; + } + return NULL; + } + public function offsetSet($offset, $value): void { + error_log("Cannot overwrite PathLoad[$offset]"); + } + public function offsetUnset($offset): void { + error_log("Cannot remove PathLoad[$offset]"); + } + } + class Package { + /** + * Split a package identifier into its parts. + * + * @param string $package + * Ex: 'foobar@1.2.3' + * @return array + * Tuple: [$majorName, $name, $version] + * Ex: 'foobar@1', 'foobar', '1.2.3' + */ + public static function parseExpr(string $package): array { + if (strpos($package, '@') === FALSE) { + throw new \RuntimeException("Malformed package name: $package"); + } + [$prefix, $suffix] = explode('@', $package, 2); + $prefix = str_replace('/', '~', $prefix); + [$major] = explode('.', $suffix, 2); + return ["$prefix@$major", $prefix, $suffix]; + } + public static function parseFileType(string $file): array { + if (substr($file, -4) === '.php') { + return ['php', substr(basename($file), 0, -4)]; + } + elseif (substr($file, '-5') === '.phar') { + return ['phar', substr(basename($file), 0, -5)]; + } + elseif (is_dir($file)) { + return ['dir', basename($file)]; + } + else { + return [NULL, NULL]; + } + } + /** + * @param string $file + * Ex: '/var/www/app-1/lib/foobar@.1.2.3.phar' + * @return \PathLoad\Vn\Package|null + */ + public static function create(string $file): ?Package { + [$type, $base] = self::parseFileType($file); + if ($type === NULL) { + return NULL; + } + $self = new Package(); + [$self->majorName, $self->name, $self->version] = static::parseExpr($base); + $self->file = $file; + $self->type = $type; + return $self; + } + /** + * @var string + * Ex: '/var/www/app-1/lib/cloud-file-io@1.2.3.phar' + */ + public $file; + /** + * @var string + * Ex: 'cloud-file-io' + */ + public $name; + /** + * @var string + * Ex: 'cloud-file-io@1' + */ + public $majorName; + /** + * @var string + * Ex: '1.2.3' + */ + public $version; + /** + * @var string + * Ex: 'php' or 'phar' or 'dir' + */ + public $type; + public $reloadable = FALSE; + } + class Scanner { + /** + * @var array + * Array(string $id => [package => string, glob => string]) + * @internal + */ + public $allRules = []; + /** + * @var array + * Array(string $id => [package => string, glob => string]) + * @internal + */ + public $newRules = []; + /** + * @param array $rule + * Ex: ['package' => '*', 'glob' => '/var/www/lib/*@*'] + * Ex: ['package' => 'cloud-file-io@1', 'glob' => '/var/www/lib/cloud-io@1*.phar']) + * @return void + */ + public function addRule(array $rule): void { + $id = static::id($rule); + $this->newRules[$id] = $this->allRules[$id] = $rule; + } + public function reset(): void { + $this->newRules = $this->allRules; + } + /** + * Evaluate any rules that have a chance of finding $packageHint. + * + * @param string $packageHint + * Give a hint about what package we're looking for. + * The scanner will try to target packages based on this hint. + * Ex: '*' or 'cloud-file-io' + * @return \Generator + * A list of packages. These may not be the exact package you're looking for. + * You should assimilate knowledge of all outputs because you may not get them again. + */ + public function scan(string $packageHint): \Generator { + yield from []; + foreach (array_keys($this->newRules) as $id) { + $searchRule = $this->newRules[$id]; + if ($searchRule['package'] === '*' || $searchRule['package'] === $packageHint) { + unset($this->newRules[$id]); + if (isset($searchRule['glob'])) { + foreach ((array) glob($searchRule['glob']) as $file) { + if (($package = Package::create($file)) !== NULL) { + yield $package; + } + } + } + if (isset($searchRule['file'])) { + $package = new Package(); + $package->file = $searchRule['file']; + $package->name = $searchRule['package']; + $package->majorName = $searchRule['package'] . '@' . explode('.', $searchRule['version'])[0]; + $package->version = $searchRule['version']; + $package->type = $searchRule['type'] ?: Package::parseFileType($searchRule['file'])[0]; + yield $package; + } + } + } + } + protected static function id(array $rule): string { + if (isset($rule['glob'])) { + return $rule['glob']; + } + elseif (isset($rule['file'])) { + return md5(implode(' ', [$rule['file'], $rule['package'], $rule['version']])); + } + else { + throw new \RuntimeException("Cannot identify rule: " . json_encode($rule)); + } + } + } + class Psr0Loader { + /** + * @var array + * Ex: $paths['F']['Foo_'][0] = '/var/www/app/lib/foo@1.0.0/src/'; + * @internal + */ + public $paths = []; + /** + * @param string $dir + * @param array $config + * Ex: ['Foo_' => ['src/']] or ['Foo_' => ['Foo_']] + */ + public function addAll(string $dir, array $config) { + $dir = rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + foreach ($config as $prefix => $relPaths) { + $bucket = $prefix[0]; + foreach ((array) $relPaths as $relPath) { + $this->paths[$bucket][$prefix][] = $dir . $relPath; + } + } + } + /** + * Loads the class file for a given class name. + * + * @param string $class The fully-qualified class name. + * @return mixed The mapped file name on success, or boolean false on failure. + */ + public function loadClass(string $class) { + $bucket = $class[0]; + if (!isset($this->paths[$bucket])) { + return FALSE; + } + $file = DIRECTORY_SEPARATOR . str_replace(['_', '\\'], [DIRECTORY_SEPARATOR, DIRECTORY_SEPARATOR], $class) . '.php'; + foreach ($this->paths[$bucket] as $prefix => $paths) { + if ($prefix === substr($class, 0, strlen($prefix))) { + foreach ($paths as $path) { + $fullFile = $path . $file; + if (file_exists($fullFile)) { + doRequire($fullFile); + return $fullFile; + } + } + } + } + return FALSE; + } + } + class Psr4Loader { + /** + * @var array + * Ex: $prefixes['Foo\\'][0] = '/var/www/app/lib/foo@1.0.0/src/'] + * @internal + */ + public $prefixes = []; + public function addAll(string $dir, array $config) { + foreach ($config as $prefix => $relPaths) { + foreach ($relPaths as $relPath) { + $this->addNamespace($prefix, $dir . '/' . $relPath); + } + } + } + /** + * Adds a base directory for a namespace prefix. + * + * @param string $prefix + * The namespace prefix. + * @param string $baseDir + * A base directory for class files in the namespace. + * @return void + */ + private function addNamespace($prefix, $baseDir) { + $prefix = trim($prefix, '\\') . '\\'; + $baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . '/'; + if (isset($this->prefixes[$prefix]) === FALSE) { + $this->prefixes[$prefix] = []; + } + array_push($this->prefixes[$prefix], $baseDir); + } + /** + * Loads the class file for a given class name. + * + * @param string $class The fully-qualified class name. + * @return mixed The mapped file name on success, or boolean false on failure. + */ + public function loadClass(string $class) { + $prefix = $class; + while (FALSE !== $pos = strrpos($prefix, '\\')) { + $prefix = substr($class, 0, $pos + 1); + $relativeClass = substr($class, $pos + 1); + if ($mappedFile = $this->findRelativeClass($prefix, $relativeClass)) { + doRequire($mappedFile); + return $mappedFile; + } + $prefix = rtrim($prefix, '\\'); + } + return FALSE; + } + /** + * Load the mapped file for a namespace prefix and relative class. + * + * @param string $prefix + * The namespace prefix. + * @param string $relativeClass + * The relative class name. + * @return string|FALSE + * Matched file name, or FALSE if none found. + */ + private function findRelativeClass($prefix, $relativeClass) { + if (isset($this->prefixes[$prefix]) === FALSE) { + return FALSE; + } + $relFile = str_replace('\\', DIRECTORY_SEPARATOR, $relativeClass) . '.php'; + foreach ($this->prefixes[$prefix] as $baseDir) { + $file = $baseDir . $relFile; + if (file_exists($file)) { + return $file; + } + } + return FALSE; + } + } + class PathLoad implements \PathLoadInterface { + /** + * @var null|int + */ + public $version; + /** + * @var Scanner + * @internal + */ + public $scanner; + /** + * List of best-known versions for each package. + * + * Packages are loaded lazily. Once loaded, the data is moved to $loadedPackages. + * + * @var Package[] + * Ex: ['cloud-file-io@1' => new Package('/usr/share/php-pathload/cloud-file-io@1.2.3.phar', + * ...)] + * @internal + */ + public $availablePackages = []; + /** + * List of packages that have already been resolved. + * + * @var Package[] + * Ex: ['cloud-file-io@1' => new Package('/usr/share/php-pathload/cloud-file-io@1.2.3.phar', + * ...)] Note: If PathLoad version is super-ceded, then the loadedPackages may be instances of + * an old `Package` class. Be mindful of duck-type compatibility. We don't strictly need to + * retain this data, but it feels it'd be handy for debugging. + * @internal + */ + public $loadedPackages = []; + /** + * Log of package activations. Used to re-initialize class-loader if we upgrade. + * + * @var array + * @internal + */ + public $activatedPackages = []; + /** + * List of hints for class-loading. If someone tries to use a matching class, then + * load the corresponding package. + * + * Namespace-rules are evaluated lazily. Once evaluated, the data is removed. + * + * @var array + * Array(string $prefix => [string $package => string $package]) + * Ex: ['Super\Cloud\IO\' => ['cloud-io@1' => 'cloud-io@1'] + * @internal + */ + public $availableNamespaces; + /** + * @var \PathLoad\Vn\Psr0Loader + * @internal + */ + public $psr0; + /** + * @var \PathLoad\Vn\Psr4Loader + * @internal + */ + public $psr4; + /** + * @param int $version + * Identify the version being instantiated. + * @param \PathLoadInterface|null $old + * If this instance is a replacement for an older instance, then it will be passed in. + * @return \ArrayAccess + * Versioned work-a-like array. + */ + public static function create(int $version, ?\PathLoadInterface $old = NULL) { + if ($old !== NULL) { + $old->unregister(); + } + $new = new static(); + $new->version = $version; + $new->scanner = new Scanner(); + $new->psr0 = new Psr0Loader(); + $new->psr4 = new Psr4Loader(); + $new->register(); + // The exact protocol for assimilating $old instances may need change. + // This seems like a fair guess as long as old properties are forward-compatible. + + if ($old === NULL) { + $baseDirs = getenv('PHP_PATHLOAD') ? explode(PATH_SEPARATOR, getenv('PHP_PATHLOAD')) : []; + foreach ($baseDirs as $baseDir) { + $new->addSearchDir($baseDir); + } + } + else { + // TIP: You might use $old->version to decide what to use. + foreach ($old->scanner->allRules as $rule) { + $new->scanner->addRule($rule); + } + $new->loadedPackages = $old->loadedPackages; + $new->availableNamespaces = $old->availableNamespaces; + foreach ($old->activatedPackages as $activatedPackage) { + $new->activatePackage($activatedPackage['name'], $activatedPackage['dir'], $activatedPackage['config']); + } + } + return new Versions($new); + } + public function register(): \PathLoadInterface { + spl_autoload_register([$this, 'loadClass']); + return $this; + } + public function unregister(): \PathLoadInterface { + spl_autoload_unregister([$this, 'loadClass']); + return $this; + } + public function reset(): \PathLoadInterface { + $this->scanner->reset(); + return $this; + } + /** + * Append a directory (with many packages) to the search-path. + * + * @param string $baseDir + * The path to a base directory (e.g. `/var/www/myapp/lib`) which contains many packages (e.g. + * `foo@1.2.3.phar` or `bar@4.5.6/autoload.php`). + */ + public function addSearchDir(string $baseDir): \PathLoadInterface { + $this->scanner->addRule(['package' => '*', 'glob' => "$baseDir/*@*"]); + return $this; + } + /** + * Append one specific item to the search list. + * + * @param string $name + * Ex: 'cloud-file-io' + * @param string $version + * Ex: '1.2.3' + * @param string $file + * Full path to the file or folder. + * @param string|null $type + * One of: 'php', 'phar', or 'dir'. NULL will auto-detect. + * + * @return \PathLoadInterface + */ + public function addSearchItem(string $name, string $version, string $file, ?string $type = NULL): \PathLoadInterface { + $this->scanner->addRule(['package' => $name, 'version' => $version, 'file' => $file, 'type' => $type]); + return $this; + } + /** + * Add auto-loading hints. If someone requests a class in $namespace, then we load $package. + * + * Consecutive/identical calls to addNamespace() are de-duplicated. + * + * @param string $package + * Ex: 'cloud-io@1' + * @param string|string[] $namespaces + * Ex: 'Super\Cloud\IO\' + */ + public function addNamespace(string $package, $namespaces): \PathLoadInterface { + foreach ((array) $namespaces as $namespace) { + $this->availableNamespaces[$namespace][$package] = $package; + } + return $this; + } + public function loadClass(string $class) { + if (strpos($class, '\\') !== FALSE) { + $this->loadPackagesByNamespace('\\', explode('\\', $class)); + } + elseif (strpos($class, '_') !== FALSE) { + $this->loadPackagesByNamespace('_', explode('_', $class)); + } + return $this->psr4->loadClass($class) || $this->psr0->loadClass($class); + } + /** + * If the application requests class "Foo\Bar\Whiz\Bang", then you should load + * any packages related to "Foo\*", "Foo\Bar\*", or "Foo\Bar\Whiz\*". + * + * @param string $delim + * Ex: '\\' or '_' + * @param string[] $classParts + * Ex: ['Symfony', 'Components', 'Filesystem', 'Filesystem'] + */ + private function loadPackagesByNamespace(string $delim, array $classParts): void { + array_pop($classParts); + do { + $foundPackages = FALSE; + $namespace = ''; + foreach ($classParts as $nsPart) { + $namespace .= $nsPart . $delim; + if (isset($this->availableNamespaces[$namespace])) { + $packages = $this->availableNamespaces[$namespace]; + foreach ($packages as $package) { + unset($this->availableNamespaces[$namespace][$package]); + if ($this->loadPackage($package)) { + $foundPackages = TRUE; + } + else { + trigger_error("PathLoad: Failed to locate package \"$package\" required for namespace \"$namespace\"", E_USER_WARNING); + $this->availableNamespaces[$namespace][$package] = $package; /* Maybe some other time */ + } + } + } + } + } while ($foundPackages); + // Loading a package could produce metadata about other packages. Assimilate those too. + } + /** + * Load the content of a package. + * + * @param string $majorName + * Ex: 'cloud-io@1' + * @param bool $reload + * @return string|NULL + * The version# of the loaded package. Otherwise, NULL + */ + public function loadPackage(string $majorName, bool $reload = FALSE): ?string { + if (isset($this->loadedPackages[$majorName])) { + if ($reload && $this->loadedPackages[$majorName]->reloadable) { + $this->scanner->reset(); + } + else { + if ($reload) { + trigger_error("PathLoad: Declined to reload \"$majorName\". Package is not reloadable.", E_USER_WARNING); + } + return $this->loadedPackages[$majorName]->version; + } + } + $this->scanAvailablePackages(explode('@', $majorName, 2)[0], $this->availablePackages); + if (!isset($this->availablePackages[$majorName])) { + return NULL; + } + $package = $this->loadedPackages[$majorName] = $this->availablePackages[$majorName]; + unset($this->availablePackages[$majorName]); + switch ($package->type ?? NULL) { + case 'php': + doRequire($package->file); + return $package->version; + case 'phar': + doRequire($package->file); + $this->useMetadataFiles($package, 'phar://' . $package->file); + return $package->version; + case 'dir': + $this->useMetadataFiles($package, $package->file); + return $package->version; + default: + \error_log("PathLoad: Package (\"$majorName\") appears malformed."); + return NULL; + } + } + private function scanAvailablePackages(string $hint, array &$avail): void { + foreach ($this->scanner->scan($hint) as $package) { + /** @var Package $package */ + if (!isset($avail[$package->majorName]) || \version_compare($package->version, $avail[$package->majorName]->version, '>')) { + $avail[$package->majorName] = $package; + } + } + } + /** + * When loading a package, execute metadata files like "pathload.main.php" or "pathload.json". + * + * @param Package $package + * @param string $dir + * Ex: '/var/www/lib/cloud-io@1.2.0' + * Ex: 'phar:///var/www/lib/cloud-io@1.2.0.phar' + */ + private function useMetadataFiles(Package $package, string $dir): void { + $phpFile = "$dir/pathload.main.php"; + $jsonFile = "$dir/pathload.json"; + if (file_exists($phpFile)) { + require $phpFile; + } + elseif (file_exists($jsonFile)) { + $jsonData = json_decode(file_get_contents($jsonFile), TRUE); + $id = $package->name . '@' . $package->version; + $this->activatePackage($id, $dir, $jsonData); + } + } + /** + * Given a configuration for the package, activate the correspond autoloader rules. + * + * @param string $majorName + * Ex: 'cloud-io@1' + * @param string|null $dir + * Used for applying the 'autoload' rules. + * Ex: '/var/www/lib/cloud-io@1.2.3' + * @param array $config + * Ex: ['autoload' => ['psr4' => ...], 'require-namespace' => [...], 'require-package' => [...]] + * @return \PathLoadInterface + */ + public function activatePackage(string $majorName, ?string $dir, array $config): \PathLoadInterface { + if (isset($config['reloadable'])) { + $this->loadedPackages[$majorName]->reloadable = $config['reloadable']; + } + if (!isset($config['autoload'])) { + return $this; + } + if ($dir === NULL) { + throw new \RuntimeException("Cannot activate package $majorName. The 'autoload' property requires a base-directory."); + } + $this->activatedPackages[] = ['name' => $majorName, 'dir' => $dir, 'config' => $config]; + if (!empty($config['autoload']['include'])) { + foreach ($config['autoload']['include'] as $file) { + doRequire($dir . DIRECTORY_SEPARATOR . $file); + } + } + if (isset($config['autoload']['psr-0'])) { + $this->psr0->addAll($dir, $config['autoload']['psr-0']); + } + if (isset($config['autoload']['psr-4'])) { + $this->psr4->addAll($dir, $config['autoload']['psr-4']); + } + foreach ($config['require-namespace'] ?? [] as $nsRule) { + foreach ((array) $nsRule['package'] as $package) { + foreach ((array) $nsRule['prefix'] as $prefix) { + $this->availableNamespaces[$prefix][$package] = $package; + } + } + } + foreach ($config['require-package'] ?? [] as $package) { + $this->loadPackage($package); + } + return $this; + } + } + } +} + +namespace { + // New or upgraded instance. + $GLOBALS['_PathLoad'] = \PathLoad\V0\PathLoad::create(0, $GLOBALS['_PathLoad']['top'] ?? NULL); + if (!function_exists('pathload')) { + /** + * Get a reference the PathLoad manager. + * + * @param int|string $version + * @return \PathLoadInterface + */ + function pathload($version = 'top') { + return $GLOBALS['_PathLoad'][$version]; + } + } + return pathload(); +} diff --git a/mixin/menu-xml@1.0.0.mixin.php b/mixin/menu-xml@1.0.0.mixin.php new file mode 100644 index 0000000..4c0b227 --- /dev/null +++ b/mixin/menu-xml@1.0.0.mixin.php @@ -0,0 +1,31 @@ +addListener('hook_civicrm_xmlMenu', function ($e) use ($mixInfo) { + if (!$mixInfo->isActive()) { + return; + } + + $files = (array) glob($mixInfo->getPath('xml/Menu/*.xml')); + foreach ($files as $file) { + $e->files[] = $file; + } + }); + +}; diff --git a/mixin/polyfill.php b/mixin/polyfill.php new file mode 100644 index 0000000..be40364 --- /dev/null +++ b/mixin/polyfill.php @@ -0,0 +1,101 @@ +')) { + $mixinVers[$name] = $ver; + } + } + $mixins = []; + foreach ($mixinVers as $name => $ver) { + $mixins[] = "$name@$ver"; + } + + // Imitate CRM_Extension_MixInfo. + $mixInfo = new class() { + + /** + * @var string + */ + public $longName; + + /** + * @var string + */ + public $shortName; + + public $_basePath; + + public function getPath($file = NULL) { + return $this->_basePath . ($file === NULL ? '' : (DIRECTORY_SEPARATOR . $file)); + } + + public function isActive() { + return \CRM_Extension_System::singleton()->getMapper()->isActiveModule($this->shortName); + } + + }; + $mixInfo->longName = $longName; + $mixInfo->shortName = $shortName; + $mixInfo->_basePath = $basePath; + + // Imitate CRM_Extension_BootCache. + $bootCache = new class() { + + public function define($name, $callback) { + $envId = \CRM_Core_Config_Runtime::getId(); + $oldExtCachePath = \Civi::paths()->getPath("[civicrm.compile]/CachedExtLoader.{$envId}.php"); + $stat = stat($oldExtCachePath); + $file = Civi::paths()->getPath('[civicrm.compile]/CachedMixin.' . md5($name . ($stat['mtime'] ?? 0)) . '.php'); + if (file_exists($file)) { + return include $file; + } + else { + $data = $callback(); + file_put_contents($file, '<' . "?php\nreturn " . var_export($data, TRUE) . ';'); + return $data; + } + } + + }; + + // Imitate CRM_Extension_MixinLoader::run() + // Parse all live mixins before trying to scan any classes. + global $_CIVIX_MIXIN_POLYFILL; + foreach ($mixins as $mixin) { + // If the exact same mixin is defined by multiple exts, just use the first one. + if (!isset($_CIVIX_MIXIN_POLYFILL[$mixin])) { + $_CIVIX_MIXIN_POLYFILL[$mixin] = include_once $basePath . '/mixin/' . $mixin . '.mixin.php'; + } + } + foreach ($mixins as $mixin) { + // If there's trickery about installs/uninstalls/resets, then we may need to register a second time. + if (!isset(\Civi::$statics[$longName][$mixin])) { + \Civi::$statics[$longName][$mixin] = 1; + $func = $_CIVIX_MIXIN_POLYFILL[$mixin]; + $func($mixInfo, $bootCache); + } + } +}; diff --git a/mixin/setting-php@1.0.0.mixin.php b/mixin/setting-php@1.0.0.mixin.php new file mode 100644 index 0000000..7195af4 --- /dev/null +++ b/mixin/setting-php@1.0.0.mixin.php @@ -0,0 +1,32 @@ +addListener('hook_civicrm_alterSettingsFolders', function ($e) use ($mixInfo) { + // When deactivating on a polyfill/pre-mixin system, listeners may not cleanup automatically. + if (!$mixInfo->isActive()) { + return; + } + + $settingsDir = $mixInfo->getPath('settings'); + if (!in_array($settingsDir, $e->settingsFolders) && is_dir($settingsDir)) { + $e->settingsFolders[] = $settingsDir; + } + }); + +}; diff --git a/mixin/smarty-v2@1.0.3.mixin.php b/mixin/smarty-v2@1.0.3.mixin.php new file mode 100644 index 0000000..f8718c8 --- /dev/null +++ b/mixin/smarty-v2@1.0.3.mixin.php @@ -0,0 +1,78 @@ +getPath('templates'); + if (!file_exists($dir)) { + return; + } + + $register = function($newDirs) { + $smarty = CRM_Core_Smarty::singleton(); + $v2 = isset($smarty->_version) && version_compare($smarty->_version, 3, '<'); + $templateDirs = (array) ($v2 ? $smarty->template_dir : $smarty->getTemplateDir()); + $templateDirs = array_merge($newDirs, $templateDirs); + $templateDirs = array_unique(array_map(function($v) { + $v = str_replace(DIRECTORY_SEPARATOR, '/', $v); + $v = rtrim($v, '/') . '/'; + return $v; + }, $templateDirs)); + if ($v2) { + $smarty->template_dir = $templateDirs; + } + else { + $smarty->setTemplateDir($templateDirs); + } + }; + + // Let's figure out what environment we're in -- so that we know the best way to call $register(). + + if (!empty($GLOBALS['_CIVIX_MIXIN_POLYFILL'])) { + // Polyfill Loader (v<=5.45): We're already in the middle of firing `hook_config`. + if ($mixInfo->isActive()) { + $register([$dir]); + } + return; + } + + if (CRM_Extension_System::singleton()->getManager()->extensionIsBeingInstalledOrEnabled($mixInfo->longName)) { + // New Install, Standard Loader: The extension has just been enabled, and we're now setting it up. + // System has already booted. New templates may be needed for upcoming installation steps. + $register([$dir]); + return; + } + + // Typical Pageview, Standard Loader: Defer the actual registration for a moment -- to ensure that Smarty is online. + // We need to bundle-up all dirs -- Smarty 3/4/5 is inefficient with processing repeated calls to `getTemplateDir()`+`setTemplateDir()` + if (!isset(Civi::$statics[__FILE__]['event'])) { + Civi::$statics[__FILE__]['event'] = 'civi.smarty-v2.addPaths.' . md5(__FILE__); + Civi::dispatcher()->addListener('hook_civicrm_config', function() use ($register) { + $dirs = []; + $event = \Civi\Core\Event\GenericHookEvent::create(['dirs' => &$dirs]); + Civi::dispatcher()->dispatch(Civi::$statics[__FILE__]['event'], $event); + $register($dirs); + }); + } + + Civi::dispatcher()->addListener(Civi::$statics[__FILE__]['event'], function($event) use ($mixInfo, $dir) { + if ($mixInfo->isActive()) { + array_unshift($event->dirs, $dir); + } + }); + +}; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0f9f25d..fc8f870 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,5 @@ - + ./tests/phpunit