(NFC) CRM_Core_Resources::addBundle() - More explanatory comments.
[civicrm-core.git] / CRM / Core / Resources.php
CommitLineData
6a488035
TO
1<?php
2/*
bc77d7c0
TO
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 +--------------------------------------------------------------------+
e70a7fc0 10 */
1889d803 11use Civi\Core\Event\GenericHookEvent;
6a488035
TO
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
ca5cec67 25 * @copyright CiviCRM LLC https://civicrm.org/licensing
6a488035 26 */
e9d08c6b 27class CRM_Core_Resources implements CRM_Core_Resources_CollectionAdderInterface {
6a488035
TO
28 const DEFAULT_WEIGHT = 0;
29 const DEFAULT_REGION = 'page-footer';
30
e9d08c6b
TO
31 use CRM_Core_Resources_CollectionAdderTrait;
32
6a488035
TO
33 /**
34 * We don't have a container or dependency-injection, so use singleton instead
35 *
36 * @var object
6a488035
TO
37 */
38 private static $_singleton = NULL;
39
40 /**
41 * @var CRM_Extension_Mapper
42 */
43 private $extMapper = NULL;
44
45 /**
fd7dc3f3 46 * @var CRM_Core_Resources_Strings
6a488035 47 */
fd7dc3f3 48 private $strings = NULL;
6a488035 49
fcf926ad
TO
50 /**
51 * Any bundles that have been added.
52 *
53 * Format is ($bundleName => bool).
54 *
55 * @var array
56 */
57 protected $addedBundles = [];
58
6a488035 59 /**
e97c66ff 60 * Added core resources.
61 *
62 * Format is ($regionName => bool).
63 *
64 * @var array
6a488035 65 */
be2fb01f 66 protected $addedCoreResources = [];
6a488035 67
2b2878a9
MW
68 /**
69 * Added settings.
70 *
71 * Format is ($regionName => bool).
72 *
73 * @var array
74 */
75 protected $addedSettings = [];
76
6a488035 77 /**
e97c66ff 78 * A value to append to JS/CSS URLs to coerce cache resets.
79 *
80 * @var string
6a488035
TO
81 */
82 protected $cacheCode = NULL;
83
84 /**
e97c66ff 85 * The name of a setting which persistently stores the cacheCode.
86 *
87 * @var string
6a488035
TO
88 */
89 protected $cacheCodeKey = NULL;
90
53f2643c 91 /**
e97c66ff 92 * Are ajax popup screens enabled.
93 *
53f2643c
CW
94 * @var bool
95 */
96 public $ajaxPopupsEnabled;
97
b698e2d5
TO
98 /**
99 * @var \Civi\Core\Paths
100 */
101 protected $paths;
102
6a488035 103 /**
fe482240 104 * Get or set the single instance of CRM_Core_Resources.
6a488035 105 *
5a4f6742
CW
106 * @param CRM_Core_Resources $instance
107 * New copy of the manager.
e97c66ff 108 *
6a488035
TO
109 * @return CRM_Core_Resources
110 */
518fa0ee 111 public static function singleton(CRM_Core_Resources $instance = NULL) {
6a488035
TO
112 if ($instance !== NULL) {
113 self::$_singleton = $instance;
114 }
115 if (self::$_singleton === NULL) {
223ba025 116 self::$_singleton = Civi::service('resources');
6a488035
TO
117 }
118 return self::$_singleton;
119 }
120
121 /**
d09edf64 122 * Construct a resource manager.
6a488035 123 *
6a0b768e
TO
124 * @param CRM_Extension_Mapper $extMapper
125 * Map extension names to their base path or URLs.
bbcf0f46 126 * @param CRM_Core_Resources_Strings $strings
6a0b768e 127 * JS-localization cache.
3be754d6 128 * @param string|null $cacheCodeKey Random code to append to resource URLs; changing the code forces clients to reload resources
6a488035 129 */
bbcf0f46 130 public function __construct($extMapper, $strings, $cacheCodeKey = NULL) {
6a488035 131 $this->extMapper = $extMapper;
bbcf0f46 132 $this->strings = $strings;
6a488035
TO
133 $this->cacheCodeKey = $cacheCodeKey;
134 if ($cacheCodeKey !== NULL) {
aaffa79f 135 $this->cacheCode = Civi::settings()->get($cacheCodeKey);
6a488035 136 }
150f50c1 137 if (!$this->cacheCode) {
6a488035
TO
138 $this->resetCacheCode();
139 }
84fb7424 140 $this->ajaxPopupsEnabled = (bool) Civi::settings()->get('ajaxPopupsEnabled');
b698e2d5 141 $this->paths = Civi::paths();
6a488035
TO
142 }
143
e9d08c6b
TO
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
fcf926ad
TO
176 /**
177 * Assimilate all the resources listed in a bundle.
178 *
179 * @param iterable|string|\CRM_Core_Resources_Bundle $bundle
19d42be6 180 * Either bundle object, or the symbolic name of a bundle, or a list of bundles.
fcf926ad
TO
181 * Note: For symbolic names, the bundle must be a container service ('bundle.FOO').
182 * @return static
183 */
184 public function addBundle($bundle) {
19d42be6
TO
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
fcf926ad
TO
206 if (is_iterable($bundle)) {
207 foreach ($bundle as $b) {
208 $this->addBundle($b);
209 return $this;
210 }
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 // If an item is already assigned to a region, we'll respect that.
223 // Otherwise, we'll use defaults.
224 $pickRegion = function ($snippet) {
225 if (isset($snippet['settings'])) {
226 return $this->getSettingRegion($snippet['region'] ?? NULL)->_name;
227 }
228 else {
229 return $snippet['region'] ?? self::DEFAULT_REGION;
230 }
231 };
232
233 $byRegion = [];
234 foreach ($bundle->getAll() as $snippet) {
235 $snippet['region'] = $pickRegion($snippet);
236 $byRegion[$snippet['region']][$snippet['name']] = $snippet;
237 }
238
239 foreach ($byRegion as $regionName => $snippets) {
240 CRM_Core_Region::instance($regionName)->merge($snippets);
241 }
242 return $this;
243 }
244
69847402 245 /**
d09edf64 246 * Helper fn for addSettingsFactory.
6a488035 247 */
63918d37
TO
248 public function getSettings($region = NULL) {
249 return $this->getSettingRegion($region)->getSettings();
6a488035
TO
250 }
251
6a488035 252 /**
d09edf64 253 * Determine file path of a resource provided by an extension.
6a488035 254 *
5a4f6742
CW
255 * @param string $ext
256 * extension name; use 'civicrm' for core.
e97c66ff 257 * @param string|null $file
5a4f6742 258 * file path -- relative to the extension base dir.
6a488035 259 *
72b3a70c
CW
260 * @return bool|string
261 * full file path or FALSE if not found
6a488035 262 */
16cd1eca 263 public function getPath($ext, $file = NULL) {
6a488035 264 // TODO consider caching results
b698e2d5
TO
265 $base = $this->paths->hasVariable($ext)
266 ? rtrim($this->paths->getVariable($ext, 'path'), '/')
267 : $this->extMapper->keyToBasePath($ext);
16cd1eca 268 if ($file === NULL) {
b698e2d5 269 return $base;
16cd1eca 270 }
b698e2d5 271 $path = $base . '/' . $file;
6a488035
TO
272 if (is_file($path)) {
273 return $path;
274 }
275 return FALSE;
276 }
277
278 /**
d09edf64 279 * Determine public URL of a resource provided by an extension.
6a488035 280 *
5a4f6742
CW
281 * @param string $ext
282 * extension name; use 'civicrm' for core.
283 * @param string $file
284 * file path -- relative to the extension base dir.
2a6da8d7
EM
285 * @param bool $addCacheCode
286 *
6a488035
TO
287 * @return string, URL
288 */
289 public function getUrl($ext, $file = NULL, $addCacheCode = FALSE) {
290 if ($file === NULL) {
291 $file = '';
292 }
293 if ($addCacheCode) {
6f12c6eb 294 $file = $this->addCacheCode($file);
6a488035
TO
295 }
296 // TODO consider caching results
b698e2d5
TO
297 $base = $this->paths->hasVariable($ext)
298 ? $this->paths->getVariable($ext, 'url')
299 : ($this->extMapper->keyToUrl($ext) . '/');
300 return $base . $file;
6a488035
TO
301 }
302
16cd1eca
TO
303 /**
304 * Evaluate a glob pattern in the context of a particular extension.
305 *
306 * @param string $ext
307 * Extension name; use 'civicrm' for core.
308 * @param string|array $patterns
309 * Glob pattern; e.g. "*.html".
310 * @param null|int $flags
311 * See glob().
312 * @return array
313 * List of matching files, relative to the extension base dir.
314 * @see glob()
315 */
316 public function glob($ext, $patterns, $flags = NULL) {
317 $path = $this->getPath($ext);
318 $patterns = (array) $patterns;
be2fb01f 319 $files = [];
16cd1eca 320 foreach ($patterns as $pattern) {
e5c376e7
TO
321 if (preg_match(';^(assetBuilder|ext)://;', $pattern)) {
322 $files[] = $pattern;
323 }
9f87b14b 324 if (CRM_Utils_File::isAbsolute($pattern)) {
16cd1eca
TO
325 // Absolute path.
326 $files = array_merge($files, (array) glob($pattern, $flags));
327 }
328 else {
329 // Relative path.
330 $files = array_merge($files, (array) glob("$path/$pattern", $flags));
331 }
332 }
518fa0ee
SL
333 // Deterministic order.
334 sort($files);
16cd1eca
TO
335 $files = array_unique($files);
336 return array_map(function ($file) use ($path) {
337 return CRM_Utils_File::relativize($file, "$path/");
338 }, $files);
339 }
340
a0ee3941
EM
341 /**
342 * @return string
343 */
6a488035
TO
344 public function getCacheCode() {
345 return $this->cacheCode;
346 }
347
a0ee3941
EM
348 /**
349 * @param $value
5badddc3 350 * @return CRM_Core_Resources
a0ee3941 351 */
6a488035
TO
352 public function setCacheCode($value) {
353 $this->cacheCode = $value;
354 if ($this->cacheCodeKey) {
08ef4ddd 355 Civi::settings()->set($this->cacheCodeKey, $value);
6a488035 356 }
9762f6ff 357 return $this;
6a488035
TO
358 }
359
5badddc3
CW
360 /**
361 * @return CRM_Core_Resources
362 */
6a488035
TO
363 public function resetCacheCode() {
364 $this->setCacheCode(CRM_Utils_String::createRandom(5, CRM_Utils_String::ALPHANUMERIC));
f091327b
CW
365 // Also flush cms resource cache if needed
366 CRM_Core_Config::singleton()->userSystem->clearResourceCache();
9762f6ff 367 return $this;
6a488035
TO
368 }
369
370 /**
371 * This adds CiviCRM's standard css and js to the specified region of the document.
372 * It will only run once.
373 *
2a6da8d7 374 * @param string $region
6a488035 375 * @return CRM_Core_Resources
6a488035
TO
376 */
377 public function addCoreResources($region = 'html-header') {
8d2c99cf
TO
378 if ($region !== 'html-header') {
379 // The signature of this method allowed different regions. However, this
380 // doesn't appear to be used - based on grepping `universe` generally
381 // and `civicrm-{core,backdrop,drupal,packages,wordpress,joomla}` specifically,
382 // it appears that all callers use 'html-header' (either implicitly or explicitly).
383 throw new \CRM_Core_Exception("Error: addCoreResources only supports html-header");
384 }
8d469336
TO
385 if (!self::isAjaxMode()) {
386 $this->addBundle('coreResources');
6a488035
TO
387 $this->addCoreStyles($region);
388 }
389 return $this;
390 }
391
392 /**
393 * This will add CiviCRM's standard CSS
394 *
6a488035
TO
395 * @param string $region
396 * @return CRM_Core_Resources
397 */
398 public function addCoreStyles($region = 'html-header') {
8d2c99cf
TO
399 if ($region !== 'html-header') {
400 // The signature of this method allowed different regions. However, this
401 // doesn't appear to be used - based on grepping `universe` generally
402 // and `civicrm-{core,backdrop,drupal,packages,wordpress,joomla}` specifically,
403 // it appears that all callers use 'html-header' (either implicitly or explicitly).
404 throw new \CRM_Core_Exception("Error: addCoreResources only supports html-header");
405 }
5526ab4d 406 $this->addBundle('coreStyles');
6a488035
TO
407 return $this;
408 }
409
627668e8 410 /**
d09edf64 411 * Flushes cached translated strings.
5badddc3 412 * @return CRM_Core_Resources
627668e8
CW
413 */
414 public function flushStrings() {
fd7dc3f3 415 $this->strings->flush();
9762f6ff
CW
416 return $this;
417 }
418
6a488035 419 /**
fd7dc3f3
TO
420 * @return CRM_Core_Resources_Strings
421 */
422 public function getStrings() {
423 return $this->strings;
6a488035
TO
424 }
425
19f7e35e 426 /**
8d7a9d07 427 * Create dynamic script for localizing js widgets.
19f7e35e 428 */
00be9182 429 public static function outputLocalizationJS() {
4cc9b813 430 CRM_Core_Page_AJAX::setJsHeaders();
a88cf11a 431 $config = CRM_Core_Config::singleton();
be2fb01f 432 $vars = [
3d527838
CW
433 'moneyFormat' => json_encode(CRM_Utils_Money::format(1234.56)),
434 'contactSearch' => json_encode($config->includeEmailInName ? ts('Start typing a name or email...') : ts('Start typing a name...')),
435 'otherSearch' => json_encode(ts('Enter search term...')),
e695ee7c 436 'entityRef' => self::getEntityRefMetadata(),
7b83e312 437 'ajaxPopupsEnabled' => self::singleton()->ajaxPopupsEnabled,
a9fb6123 438 'allowAlertAutodismissal' => (bool) Civi::settings()->get('allow_alert_autodismissal'),
c7e39a79 439 'resourceCacheCode' => self::singleton()->getCacheCode(),
b30809e4
CW
440 'locale' => CRM_Core_I18n::getLocale(),
441 'cid' => (int) CRM_Core_Session::getLoggedInContactID(),
be2fb01f 442 ];
3d4fb0ed 443 print CRM_Core_Smarty::singleton()->fetchWith('CRM/common/l10n.js.tpl', $vars);
4cc9b813 444 CRM_Utils_System::civiExit();
c66581f5
CW
445 }
446
156fd9b9 447 /**
a6c01b45
CW
448 * @return bool
449 * is this page request an ajax snippet?
156fd9b9 450 */
00be9182 451 public static function isAjaxMode() {
be2fb01f 452 if (in_array(CRM_Utils_Array::value('snippet', $_REQUEST), [
518fa0ee
SL
453 CRM_Core_Smarty::PRINT_SNIPPET,
454 CRM_Core_Smarty::PRINT_NOFORM,
455 CRM_Core_Smarty::PRINT_JSON,
456 ])
42a40a1c 457 ) {
458 return TRUE;
459 }
f31f885e 460 list($arg0, $arg1) = array_pad(explode('/', CRM_Utils_System::currentPath()), 2, '');
60c3b6e9 461 return ($arg0 === 'civicrm' && in_array($arg1, ['ajax', 'angularprofiles', 'asset']));
156fd9b9 462 }
b7ceb253 463
1889d803 464 /**
518fa0ee 465 * @param \Civi\Core\Event\GenericHookEvent $e
1889d803
CW
466 * @see \CRM_Utils_Hook::buildAsset()
467 */
468 public static function renderMenubarStylesheet(GenericHookEvent $e) {
469 if ($e->asset !== 'crm-menubar.css') {
470 return;
471 }
472 $e->mimeType = 'text/css';
dcaf410f 473 $content = '';
1889d803
CW
474 $config = CRM_Core_Config::singleton();
475 $cms = strtolower($config->userFramework);
476 $cms = $cms === 'drupal' ? 'drupal7' : $cms;
477 $items = [
478 'bower_components/smartmenus/dist/css/sm-core-css.css',
479 'css/crm-menubar.css',
480 "css/menubar-$cms.css",
481 ];
482 foreach ($items as $item) {
dcaf410f 483 $content .= file_get_contents(self::singleton()->getPath('civicrm', $item));
8a52ae34 484 }
dcaf410f
CW
485 $params = $e->params;
486 // "color" is deprecated in favor of the more specific "menubarColor"
487 $menubarColor = $params['color'] ?? $params['menubarColor'];
1889d803 488 $vars = [
dcaf410f
CW
489 '$resourceBase' => rtrim($config->resourceBase, '/'),
490 '$menubarHeight' => $params['height'] . 'px',
491 '$breakMin' => $params['breakpoint'] . 'px',
492 '$breakMax' => ($params['breakpoint'] - 1) . 'px',
493 '$menubarColor' => $menubarColor,
63c2508b 494 '$menuItemColor' => $params['menuItemColor'] ?? $menubarColor,
dcaf410f
CW
495 '$highlightColor' => $params['highlightColor'] ?? CRM_Utils_Color::getHighlight($menubarColor),
496 '$textColor' => $params['textColor'] ?? CRM_Utils_Color::getContrast($menubarColor, '#333', '#ddd'),
1889d803 497 ];
dcaf410f
CW
498 $vars['$highlightTextColor'] = $params['highlightTextColor'] ?? CRM_Utils_Color::getContrast($vars['$highlightColor'], '#333', '#ddd');
499 $e->content = str_replace(array_keys($vars), array_values($vars), $content);
1889d803
CW
500 }
501
b7ceb253 502 /**
f9e31d7f 503 * Provide a list of available entityRef filters.
fd7c068f 504 *
b7ceb253
CW
505 * @return array
506 */
e695ee7c
CW
507 public static function getEntityRefMetadata() {
508 $data = [
509 'filters' => [],
510 'links' => [],
511 ];
06606cd1 512 $config = CRM_Core_Config::singleton();
b7ceb253 513
1d6f94ab
CW
514 $disabledComponents = [];
515 $dao = CRM_Core_DAO::executeQuery("SELECT name, namespace FROM civicrm_component");
516 while ($dao->fetch()) {
517 if (!in_array($dao->name, $config->enableComponents)) {
518 $disabledComponents[$dao->name] = $dao->namespace;
06606cd1
CW
519 }
520 }
521
1d6f94ab
CW
522 foreach (CRM_Core_DAO_AllCoreTables::daoToClass() as $entity => $daoName) {
523 // Skip DAOs of disabled components
524 foreach ($disabledComponents as $nameSpace) {
525 if (strpos($daoName, $nameSpace) === 0) {
526 continue 2;
527 }
528 }
529 $baoName = str_replace('_DAO_', '_BAO_', $daoName);
530 if (class_exists($baoName)) {
e695ee7c
CW
531 $filters = $baoName::getEntityRefFilters();
532 if ($filters) {
2229cf4f 533 $data['filters'][$entity] = $filters;
e695ee7c
CW
534 }
535 if (is_callable([$baoName, 'getEntityRefCreateLinks'])) {
536 $createLinks = $baoName::getEntityRefCreateLinks();
537 if ($createLinks) {
2229cf4f 538 $data['links'][$entity] = $createLinks;
e695ee7c 539 }
1d6f94ab
CW
540 }
541 }
b7b528bc
CW
542 }
543
77d0bf4e 544 CRM_Utils_Hook::entityRefFilters($data['filters'], $data['links']);
fd7c068f 545
e695ee7c 546 return $data;
b7ceb253 547 }
96025800 548
09a4dcd5 549 /**
d89d2545 550 * Determine the minified file name.
09a4dcd5 551 *
d89d2545
TO
552 * @param string $ext
553 * @param string $file
554 * @return string
555 * An updated $fileName. If a minified version exists and is supported by
556 * system policy, the minified version will be returned. Otherwise, the original.
557 */
558 public function filterMinify($ext, $file) {
559 if (CRM_Core_Config::singleton()->debug && strpos($file, '.min.') !== FALSE) {
560 $nonMiniFile = str_replace('.min.', '.', $file);
561 if ($this->getPath($ext, $nonMiniFile)) {
562 $file = $nonMiniFile;
09a4dcd5
CW
563 }
564 }
d89d2545 565 return $file;
09a4dcd5
CW
566 }
567
6f12c6eb 568 /**
569 * @param string $url
570 * @return string
571 */
572 public function addCacheCode($url) {
33603e1d 573 $hasQuery = strpos($url, '?') !== FALSE;
03449a5b 574 $operator = $hasQuery ? '&' : '?';
6f12c6eb 575
03449a5b 576 return $url . $operator . 'r=' . $this->cacheCode;
6f12c6eb 577 }
33603e1d 578
adcd4bf7
CW
579 /**
580 * Checks if the given URL is fully-formed
581 *
582 * @param string $url
583 *
584 * @return bool
585 */
586 public static function isFullyFormedUrl($url) {
587 return (substr($url, 0, 4) === 'http') || (substr($url, 0, 1) === '/');
588 }
589
f55f8f17
TO
590 /**
591 * @param string|NULL $region
592 * Optional request for a specific region. If NULL/omitted, use global default.
593 * @return \CRM_Core_Region
594 */
595 private function getSettingRegion($region = NULL) {
596 $region = $region ?: (self::isAjaxMode() ? 'ajax-snippet' : 'html-header');
597 return CRM_Core_Region::instance($region);
598 }
599
6a488035 600}