Merge pull request #19296 from eileenmcnaughton/fbool
[civicrm-core.git] / CRM / Core / Resources.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11 use Civi\Core\Event\GenericHookEvent;
12
13 /**
14 * This class facilitates the loading of resources
15 * such as JavaScript files and CSS files.
16 *
17 * Any URLs generated for resources may include a 'cache-code'. By resetting the
18 * cache-code, one may force clients to re-download resource files (regardless of
19 * any HTTP caching rules).
20 *
21 * TODO: This is currently a thin wrapper over CRM_Core_Region. We
22 * should incorporte services for aggregation, minimization, etc.
23 *
24 * @package CRM
25 * @copyright CiviCRM LLC https://civicrm.org/licensing
26 */
27 class CRM_Core_Resources implements CRM_Core_Resources_CollectionAdderInterface {
28 const DEFAULT_WEIGHT = 0;
29 const DEFAULT_REGION = 'page-footer';
30
31 use CRM_Core_Resources_CollectionAdderTrait;
32
33 /**
34 * We don't have a container or dependency-injection, so use singleton instead
35 *
36 * @var object
37 */
38 private static $_singleton = NULL;
39
40 /**
41 * @var CRM_Extension_Mapper
42 */
43 private $extMapper = NULL;
44
45 /**
46 * @var CRM_Core_Resources_Strings
47 */
48 private $strings = NULL;
49
50 /**
51 * Any bundles that have been added.
52 *
53 * Format is ($bundleName => bool).
54 *
55 * @var array
56 */
57 protected $addedBundles = [];
58
59 /**
60 * Added core resources.
61 *
62 * Format is ($regionName => bool).
63 *
64 * @var array
65 */
66 protected $addedCoreResources = [];
67
68 /**
69 * Added settings.
70 *
71 * Format is ($regionName => bool).
72 *
73 * @var array
74 */
75 protected $addedSettings = [];
76
77 /**
78 * A value to append to JS/CSS URLs to coerce cache resets.
79 *
80 * @var string
81 */
82 protected $cacheCode = NULL;
83
84 /**
85 * The name of a setting which persistently stores the cacheCode.
86 *
87 * @var string
88 */
89 protected $cacheCodeKey = NULL;
90
91 /**
92 * Are ajax popup screens enabled.
93 *
94 * @var bool
95 */
96 public $ajaxPopupsEnabled;
97
98 /**
99 * @var \Civi\Core\Paths
100 */
101 protected $paths;
102
103 /**
104 * Get or set the single instance of CRM_Core_Resources.
105 *
106 * @param CRM_Core_Resources $instance
107 * New copy of the manager.
108 *
109 * @return CRM_Core_Resources
110 */
111 public static function singleton(CRM_Core_Resources $instance = NULL) {
112 if ($instance !== NULL) {
113 self::$_singleton = $instance;
114 }
115 if (self::$_singleton === NULL) {
116 self::$_singleton = Civi::service('resources');
117 }
118 return self::$_singleton;
119 }
120
121 /**
122 * Construct a resource manager.
123 *
124 * @param CRM_Extension_Mapper $extMapper
125 * Map extension names to their base path or URLs.
126 * @param CRM_Core_Resources_Strings $strings
127 * JS-localization cache.
128 * @param string|null $cacheCodeKey Random code to append to resource URLs; changing the code forces clients to reload resources
129 */
130 public function __construct($extMapper, $strings, $cacheCodeKey = NULL) {
131 $this->extMapper = $extMapper;
132 $this->strings = $strings;
133 $this->cacheCodeKey = $cacheCodeKey;
134 if ($cacheCodeKey !== NULL) {
135 $this->cacheCode = Civi::settings()->get($cacheCodeKey);
136 }
137 if (!$this->cacheCode) {
138 $this->resetCacheCode();
139 }
140 $this->ajaxPopupsEnabled = (bool) Civi::settings()->get('ajaxPopupsEnabled');
141 $this->paths = Civi::paths();
142 }
143
144 /**
145 * Add an item to the collection.
146 *
147 * @param array $snippet
148 * @return array
149 * The full/computed snippet (with defaults applied).
150 * @see CRM_Core_Resources_CollectionInterface::add()
151 */
152 public function add($snippet) {
153 if (!isset($snippet['region'])) {
154 $snippet['region'] = self::DEFAULT_REGION;
155 }
156 if (!isset($snippet['weight'])) {
157 $snippet['weight'] = self::DEFAULT_WEIGHT;
158 }
159 return CRM_Core_Region::instance($snippet['region'])->add($snippet);
160 }
161
162 /**
163 * Locate the 'settings' snippet.
164 *
165 * @param array $options
166 * @return array
167 * @see CRM_Core_Resources_CollectionTrait::findCreateSettingSnippet()
168 */
169 public function &findCreateSettingSnippet($options = []): array {
170 $options = CRM_Core_Resources_CollectionAdderTrait::mergeSettingOptions($options, [
171 'region' => NULL,
172 ]);
173 return $this->getSettingRegion($options['region'])->findCreateSettingSnippet($options);
174 }
175
176 /**
177 * Assimilate all the resources listed in a bundle.
178 *
179 * @param iterable|string|\CRM_Core_Resources_Bundle $bundle
180 * Either bundle object, or the symbolic name of a bundle, or a list of bundles.
181 * Note: For symbolic names, the bundle must be a container service ('bundle.FOO').
182 * @return static
183 */
184 public function addBundle($bundle) {
185 // There are two ways you might write this method: (1) immediately merge
186 // resources from the bundle, or (2) store a reference to the bundle and
187 // merge resources later. Both have pros/cons. The implementation does #1.
188 //
189 // The upshot of #1 is *multi-region* support. For example, a bundle might
190 // add some JS to `html-header` and then add some HTML to `page-header`.
191 // Implementing this requires splitting the bundle (ie copying specific
192 // resources to their respective regions). The timing of `addBundle()` is
193 // favorable to splitting.
194 //
195 // The upshot of #2 would be *reduced timing sensitivity for downstream*:
196 // if party A wants to include some bundle, and party B wants to refine
197 // the same bundle, then it wouldn't matter if A or B executed first.
198 // This should make DX generally more forgiving. But we can't split until
199 // everyone has their shot at tweaking the bundle.
200 //
201 // In theory, you could have both characteristics if you figure the right
202 // time at which to perform a split. Or maybe you could have both by tracking
203 // more detailed references+events among the bundles/regions. I haven't
204 // seen a simple way to do get both.
205
206 if (is_iterable($bundle)) {
207 foreach ($bundle as $b) {
208 $this->addBundle($b);
209 }
210 return $this;
211 }
212
213 if (is_string($bundle)) {
214 $bundle = Civi::service('bundle.' . $bundle);
215 }
216
217 if (isset($this->addedBundles[$bundle->name])) {
218 return $this;
219 }
220 $this->addedBundles[$bundle->name] = TRUE;
221
222 // Ensure that every asset has a region.
223 $bundle->filter(function($snippet) {
224 if (empty($snippet['region'])) {
225 $snippet['region'] = isset($snippet['settings'])
226 ? $this->getSettingRegion()->_name
227 : self::DEFAULT_REGION;
228 }
229 return $snippet;
230 });
231
232 $byRegion = CRM_Utils_Array::index(['region', 'name'], $bundle->getAll());
233 foreach ($byRegion as $regionName => $snippets) {
234 CRM_Core_Region::instance($regionName)->merge($snippets);
235 }
236 return $this;
237 }
238
239 /**
240 * Helper fn for addSettingsFactory.
241 */
242 public function getSettings($region = NULL) {
243 return $this->getSettingRegion($region)->getSettings();
244 }
245
246 /**
247 * Determine file path of a resource provided by an extension.
248 *
249 * @param string $ext
250 * extension name; use 'civicrm' for core.
251 * @param string|null $file
252 * file path -- relative to the extension base dir.
253 *
254 * @return bool|string
255 * full file path or FALSE if not found
256 */
257 public function getPath($ext, $file = NULL) {
258 // TODO consider caching results
259 $base = $this->paths->hasVariable($ext)
260 ? rtrim($this->paths->getVariable($ext, 'path'), '/')
261 : $this->extMapper->keyToBasePath($ext);
262 if ($file === NULL) {
263 return $base;
264 }
265 $path = $base . '/' . $file;
266 if (is_file($path)) {
267 return $path;
268 }
269 return FALSE;
270 }
271
272 /**
273 * Determine public URL of a resource provided by an extension.
274 *
275 * @param string $ext
276 * extension name; use 'civicrm' for core.
277 * @param string $file
278 * file path -- relative to the extension base dir.
279 * @param bool $addCacheCode
280 *
281 * @return string, URL
282 */
283 public function getUrl($ext, $file = NULL, $addCacheCode = FALSE) {
284 if ($file === NULL) {
285 $file = '';
286 }
287 if ($addCacheCode) {
288 $file = $this->addCacheCode($file);
289 }
290 // TODO consider caching results
291 $base = $this->paths->hasVariable($ext)
292 ? $this->paths->getVariable($ext, 'url')
293 : ($this->extMapper->keyToUrl($ext) . '/');
294 return $base . $file;
295 }
296
297 /**
298 * Evaluate a glob pattern in the context of a particular extension.
299 *
300 * @param string $ext
301 * Extension name; use 'civicrm' for core.
302 * @param string|array $patterns
303 * Glob pattern; e.g. "*.html".
304 * @param null|int $flags
305 * See glob().
306 * @return array
307 * List of matching files, relative to the extension base dir.
308 * @see glob()
309 */
310 public function glob($ext, $patterns, $flags = NULL) {
311 $path = $this->getPath($ext);
312 $patterns = (array) $patterns;
313 $files = [];
314 foreach ($patterns as $pattern) {
315 if (preg_match(';^(assetBuilder|ext)://;', $pattern)) {
316 $files[] = $pattern;
317 }
318 if (CRM_Utils_File::isAbsolute($pattern)) {
319 // Absolute path.
320 $files = array_merge($files, (array) glob($pattern, $flags));
321 }
322 else {
323 // Relative path.
324 $files = array_merge($files, (array) glob("$path/$pattern", $flags));
325 }
326 }
327 // Deterministic order.
328 sort($files);
329 $files = array_unique($files);
330 return array_map(function ($file) use ($path) {
331 return CRM_Utils_File::relativize($file, "$path/");
332 }, $files);
333 }
334
335 /**
336 * @return string
337 */
338 public function getCacheCode() {
339 return $this->cacheCode;
340 }
341
342 /**
343 * @param $value
344 * @return CRM_Core_Resources
345 */
346 public function setCacheCode($value) {
347 $this->cacheCode = $value;
348 if ($this->cacheCodeKey) {
349 Civi::settings()->set($this->cacheCodeKey, $value);
350 }
351 return $this;
352 }
353
354 /**
355 * @return CRM_Core_Resources
356 */
357 public function resetCacheCode() {
358 $this->setCacheCode(CRM_Utils_String::createRandom(5, CRM_Utils_String::ALPHANUMERIC));
359 // Also flush cms resource cache if needed
360 CRM_Core_Config::singleton()->userSystem->clearResourceCache();
361 return $this;
362 }
363
364 /**
365 * This adds CiviCRM's standard css and js to the specified region of the document.
366 * It will only run once.
367 *
368 * @param string $region
369 * @return CRM_Core_Resources
370 */
371 public function addCoreResources($region = 'html-header') {
372 if ($region !== 'html-header') {
373 // The signature of this method allowed different regions. However, this
374 // doesn't appear to be used - based on grepping `universe` generally
375 // and `civicrm-{core,backdrop,drupal,packages,wordpress,joomla}` specifically,
376 // it appears that all callers use 'html-header' (either implicitly or explicitly).
377 throw new \CRM_Core_Exception("Error: addCoreResources only supports html-header");
378 }
379 if (!self::isAjaxMode()) {
380 $this->addBundle('coreResources');
381 $this->addCoreStyles($region);
382 }
383 return $this;
384 }
385
386 /**
387 * This will add CiviCRM's standard CSS
388 *
389 * @param string $region
390 * @return CRM_Core_Resources
391 */
392 public function addCoreStyles($region = 'html-header') {
393 if ($region !== 'html-header') {
394 // The signature of this method allowed different regions. However, this
395 // doesn't appear to be used - based on grepping `universe` generally
396 // and `civicrm-{core,backdrop,drupal,packages,wordpress,joomla}` specifically,
397 // it appears that all callers use 'html-header' (either implicitly or explicitly).
398 throw new \CRM_Core_Exception("Error: addCoreResources only supports html-header");
399 }
400 $this->addBundle('coreStyles');
401 return $this;
402 }
403
404 /**
405 * Flushes cached translated strings.
406 * @return CRM_Core_Resources
407 */
408 public function flushStrings() {
409 $this->strings->flush();
410 return $this;
411 }
412
413 /**
414 * @return CRM_Core_Resources_Strings
415 */
416 public function getStrings() {
417 return $this->strings;
418 }
419
420 /**
421 * Create dynamic script for localizing js widgets.
422 */
423 public static function outputLocalizationJS() {
424 CRM_Core_Page_AJAX::setJsHeaders();
425 $config = CRM_Core_Config::singleton();
426 $vars = [
427 'moneyFormat' => json_encode(CRM_Utils_Money::format(1234.56)),
428 'contactSearch' => json_encode($config->includeEmailInName ? ts('Start typing a name or email...') : ts('Start typing a name...')),
429 'otherSearch' => json_encode(ts('Enter search term...')),
430 'entityRef' => self::getEntityRefMetadata(),
431 'ajaxPopupsEnabled' => self::singleton()->ajaxPopupsEnabled,
432 'allowAlertAutodismissal' => (bool) Civi::settings()->get('allow_alert_autodismissal'),
433 'resourceCacheCode' => self::singleton()->getCacheCode(),
434 'locale' => CRM_Core_I18n::getLocale(),
435 'cid' => (int) CRM_Core_Session::getLoggedInContactID(),
436 ];
437 print CRM_Core_Smarty::singleton()->fetchWith('CRM/common/l10n.js.tpl', $vars);
438 CRM_Utils_System::civiExit();
439 }
440
441 /**
442 * @return bool
443 * is this page request an ajax snippet?
444 */
445 public static function isAjaxMode() {
446 if (in_array(CRM_Utils_Array::value('snippet', $_REQUEST), [
447 CRM_Core_Smarty::PRINT_SNIPPET,
448 CRM_Core_Smarty::PRINT_NOFORM,
449 CRM_Core_Smarty::PRINT_JSON,
450 ])
451 ) {
452 return TRUE;
453 }
454 list($arg0, $arg1) = array_pad(explode('/', CRM_Utils_System::currentPath()), 2, '');
455 return ($arg0 === 'civicrm' && in_array($arg1, ['ajax', 'angularprofiles', 'asset']));
456 }
457
458 /**
459 * @param \Civi\Core\Event\GenericHookEvent $e
460 * @see \CRM_Utils_Hook::buildAsset()
461 */
462 public static function renderMenubarStylesheet(GenericHookEvent $e) {
463 if ($e->asset !== 'crm-menubar.css') {
464 return;
465 }
466 $e->mimeType = 'text/css';
467 $content = '';
468 $config = CRM_Core_Config::singleton();
469 $cms = strtolower($config->userFramework);
470 $cms = $cms === 'drupal' ? 'drupal7' : $cms;
471 $items = [
472 'bower_components/smartmenus/dist/css/sm-core-css.css',
473 'css/crm-menubar.css',
474 "css/menubar-$cms.css",
475 ];
476 foreach ($items as $item) {
477 $content .= file_get_contents(self::singleton()->getPath('civicrm', $item));
478 }
479 $params = $e->params;
480 // "color" is deprecated in favor of the more specific "menubarColor"
481 $menubarColor = $params['color'] ?? $params['menubarColor'];
482 $vars = [
483 '$resourceBase' => rtrim($config->resourceBase, '/'),
484 '$menubarHeight' => $params['height'] . 'px',
485 '$breakMin' => $params['breakpoint'] . 'px',
486 '$breakMax' => ($params['breakpoint'] - 1) . 'px',
487 '$menubarColor' => $menubarColor,
488 '$menuItemColor' => $params['menuItemColor'] ?? $menubarColor,
489 '$highlightColor' => $params['highlightColor'] ?? CRM_Utils_Color::getHighlight($menubarColor),
490 '$textColor' => $params['textColor'] ?? CRM_Utils_Color::getContrast($menubarColor, '#333', '#ddd'),
491 ];
492 $vars['$highlightTextColor'] = $params['highlightTextColor'] ?? CRM_Utils_Color::getContrast($vars['$highlightColor'], '#333', '#ddd');
493 $e->content = str_replace(array_keys($vars), array_values($vars), $content);
494 }
495
496 /**
497 * Provide a list of available entityRef filters.
498 *
499 * @return array
500 */
501 public static function getEntityRefMetadata() {
502 $data = [
503 'filters' => [],
504 'links' => [],
505 ];
506 $config = CRM_Core_Config::singleton();
507
508 $disabledComponents = [];
509 $dao = CRM_Core_DAO::executeQuery("SELECT name, namespace FROM civicrm_component");
510 while ($dao->fetch()) {
511 if (!in_array($dao->name, $config->enableComponents)) {
512 $disabledComponents[$dao->name] = $dao->namespace;
513 }
514 }
515
516 foreach (CRM_Core_DAO_AllCoreTables::daoToClass() as $entity => $daoName) {
517 // Skip DAOs of disabled components
518 foreach ($disabledComponents as $nameSpace) {
519 if (strpos($daoName, $nameSpace) === 0) {
520 continue 2;
521 }
522 }
523 $baoName = str_replace('_DAO_', '_BAO_', $daoName);
524 if (class_exists($baoName)) {
525 $filters = $baoName::getEntityRefFilters();
526 if ($filters) {
527 $data['filters'][$entity] = $filters;
528 }
529 if (is_callable([$baoName, 'getEntityRefCreateLinks'])) {
530 $createLinks = $baoName::getEntityRefCreateLinks();
531 if ($createLinks) {
532 $data['links'][$entity] = $createLinks;
533 }
534 }
535 }
536 }
537
538 CRM_Utils_Hook::entityRefFilters($data['filters'], $data['links']);
539
540 return $data;
541 }
542
543 /**
544 * Determine the minified file name.
545 *
546 * @param string $ext
547 * @param string $file
548 * @return string
549 * An updated $fileName. If a minified version exists and is supported by
550 * system policy, the minified version will be returned. Otherwise, the original.
551 */
552 public function filterMinify($ext, $file) {
553 if (CRM_Core_Config::singleton()->debug && strpos($file, '.min.') !== FALSE) {
554 $nonMiniFile = str_replace('.min.', '.', $file);
555 if ($this->getPath($ext, $nonMiniFile)) {
556 $file = $nonMiniFile;
557 }
558 }
559 return $file;
560 }
561
562 /**
563 * @param string $url
564 * @return string
565 */
566 public function addCacheCode($url) {
567 $hasQuery = strpos($url, '?') !== FALSE;
568 $operator = $hasQuery ? '&' : '?';
569
570 return $url . $operator . 'r=' . $this->cacheCode;
571 }
572
573 /**
574 * Checks if the given URL is fully-formed
575 *
576 * @param string $url
577 *
578 * @return bool
579 */
580 public static function isFullyFormedUrl($url) {
581 return (substr($url, 0, 4) === 'http') || (substr($url, 0, 1) === '/');
582 }
583
584 /**
585 * @param string|NULL $region
586 * Optional request for a specific region. If NULL/omitted, use global default.
587 * @return \CRM_Core_Region
588 */
589 private function getSettingRegion($region = NULL) {
590 $region = $region ?: (self::isAjaxMode() ? 'ajax-snippet' : 'html-header');
591 return CRM_Core_Region::instance($region);
592 }
593
594 }