Merge pull request #2845 from elcapo/activity-contact-api
[civicrm-core.git] / CRM / Core / Resources.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | CiviCRM version 4.5 |
5 +--------------------------------------------------------------------+
6 | Copyright CiviCRM LLC (c) 2004-2014 |
7 +--------------------------------------------------------------------+
8 | This file is a part of CiviCRM. |
9 | |
10 | CiviCRM is free software; you can copy, modify, and distribute it |
11 | under the terms of the GNU Affero General Public License |
12 | Version 3, 19 November 2007 and the CiviCRM Licensing Exception. |
13 | |
14 | CiviCRM is distributed in the hope that it will be useful, but |
15 | WITHOUT ANY WARRANTY; without even the implied warranty of |
16 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. |
17 | See the GNU Affero General Public License for more details. |
18 | |
19 | You should have received a copy of the GNU Affero General Public |
20 | License and the CiviCRM Licensing Exception along |
21 | with this program; if not, contact CiviCRM LLC |
22 | at info[AT]civicrm[DOT]org. If you have questions about the |
23 | GNU Affero General Public License or the licensing of CiviCRM, |
24 | see the CiviCRM license FAQ at http://civicrm.org/licensing |
25 +--------------------------------------------------------------------+
26 */
27
28 /**
29 * This class facilitates the loading of resources
30 * such as JavaScript files and CSS files.
31 *
32 * Any URLs generated for resources may include a 'cache-code'. By resetting the
33 * cache-code, one may force clients to re-download resource files (regardless of
34 * any HTTP caching rules).
35 *
36 * TODO: This is currently a thin wrapper over CRM_Core_Region. We
37 * should incorporte services for aggregation, minimization, etc.
38 *
39 * @package CRM
40 * @copyright CiviCRM LLC (c) 2004-2014
41 * $Id$
42 *
43 */
44 class CRM_Core_Resources {
45 const DEFAULT_WEIGHT = 0;
46 const DEFAULT_REGION = 'page-footer';
47
48 /**
49 * We don't have a container or dependency-injection, so use singleton instead
50 *
51 * @var object
52 * @static
53 */
54 private static $_singleton = NULL;
55
56 /**
57 * @var CRM_Extension_Mapper
58 */
59 private $extMapper = NULL;
60
61 /**
62 * @var CRM_Utils_Cache_Interface
63 */
64 private $cache = NULL;
65
66 /**
67 * @var array free-form data tree
68 */
69 protected $settings = array();
70 protected $addedSettings = FALSE;
71
72 /**
73 * @var array of callables
74 */
75 protected $settingsFactories = array();
76
77 /**
78 * @var array ($regionName => bool)
79 */
80 protected $addedCoreResources = array();
81
82 /**
83 * @var array ($regionName => bool)
84 */
85 protected $addedCoreStyles = array();
86
87 /**
88 * @var string a value to append to JS/CSS URLs to coerce cache resets
89 */
90 protected $cacheCode = NULL;
91
92 /**
93 * @var string the name of a setting which persistently stores the cacheCode
94 */
95 protected $cacheCodeKey = NULL;
96
97 /**
98 * @var bool
99 */
100 public $ajaxPopupsEnabled;
101
102 /**
103 * Get or set the single instance of CRM_Core_Resources
104 *
105 * @param $instance CRM_Core_Resources, new copy of the manager
106 * @return CRM_Core_Resources
107 */
108 static public function singleton(CRM_Core_Resources $instance = NULL) {
109 if ($instance !== NULL) {
110 self::$_singleton = $instance;
111 }
112 if (self::$_singleton === NULL) {
113 $sys = CRM_Extension_System::singleton();
114 $cache = new CRM_Utils_Cache_SqlGroup(array(
115 'group' => 'js-strings',
116 'prefetch' => FALSE,
117 ));
118 self::$_singleton = new CRM_Core_Resources(
119 $sys->getMapper(),
120 $cache,
121 CRM_Core_Config::isUpgradeMode() ? NULL : 'resCacheCode'
122 );
123 }
124 return self::$_singleton;
125 }
126
127 /**
128 * Construct a resource manager
129 *
130 * @param CRM_Extension_Mapper $extMapper Map extension names to their base path or URLs.
131 */
132 public function __construct($extMapper, $cache, $cacheCodeKey = NULL) {
133 $this->extMapper = $extMapper;
134 $this->cache = $cache;
135 $this->cacheCodeKey = $cacheCodeKey;
136 if ($cacheCodeKey !== NULL) {
137 $this->cacheCode = CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, $cacheCodeKey);
138 }
139 if (!$this->cacheCode) {
140 $this->resetCacheCode();
141 }
142 $this->ajaxPopupsEnabled = (bool) CRM_Core_BAO_Setting::getItem(
143 CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'ajaxPopupsEnabled', NULL, TRUE
144 );
145 }
146
147 /**
148 * Add a JavaScript file to the current page using <SCRIPT SRC>.
149 *
150 * @param $ext string, extension name; use 'civicrm' for core
151 * @param $file string, file path -- relative to the extension base dir
152 * @param $weight int, relative weight within a given region
153 * @param $region string, location within the file; 'html-header', 'page-header', 'page-footer'
154 * @param $translate, whether to parse this file for strings enclosed in ts()
155 *
156 * @return CRM_Core_Resources
157 */
158 public function addScriptFile($ext, $file, $weight = self::DEFAULT_WEIGHT, $region = self::DEFAULT_REGION, $translate = TRUE) {
159 if ($translate) {
160 $this->translateScript($ext, $file);
161 }
162 // Look for non-minified version if we are in debug mode
163 if (CRM_Core_Config::singleton()->debug && strpos($file, '.min.js') !== FALSE) {
164 $nonMiniFile = str_replace('.min.js', '.js', $file);
165 if ($this->getPath($ext, $nonMiniFile)) {
166 $file = $nonMiniFile;
167 }
168 }
169 return $this->addScriptUrl($this->getUrl($ext, $file, TRUE), $weight, $region);
170 }
171
172 /**
173 * Add a JavaScript file to the current page using <SCRIPT SRC>.
174 *
175 * @param $url string
176 * @param $weight int, relative weight within a given region
177 * @param $region string, location within the file; 'html-header', 'page-header', 'page-footer'
178 * @return CRM_Core_Resources
179 */
180 public function addScriptUrl($url, $weight = self::DEFAULT_WEIGHT, $region = self::DEFAULT_REGION) {
181 CRM_Core_Region::instance($region)->add(array(
182 'name' => $url,
183 'type' => 'scriptUrl',
184 'scriptUrl' => $url,
185 'weight' => $weight,
186 'region' => $region,
187 ));
188 return $this;
189 }
190
191 /**
192 * Add a JavaScript file to the current page using <SCRIPT SRC>.
193 *
194 * @param $code string, JavaScript source code
195 * @param $weight int, relative weight within a given region
196 * @param $region string, location within the file; 'html-header', 'page-header', 'page-footer'
197 * @return CRM_Core_Resources
198 */
199 public function addScript($code, $weight = self::DEFAULT_WEIGHT, $region = self::DEFAULT_REGION) {
200 CRM_Core_Region::instance($region)->add(array(
201 // 'name' => automatic
202 'type' => 'script',
203 'script' => $code,
204 'weight' => $weight,
205 'region' => $region,
206 ));
207 return $this;
208 }
209
210 /**
211 * Add JavaScript variables to the global CRM object.
212 *
213 * Example:
214 * From the server:
215 * CRM_Core_Resources::singleton()->addSetting(array('myNamespace' => array('foo' => 'bar')));
216 * From javascript:
217 * CRM.myNamespace.foo // "bar"
218 *
219 * @see http://wiki.civicrm.org/confluence/display/CRMDOC/Javascript+Reference
220 *
221 * @param $settings array
222 * @return CRM_Core_Resources
223 */
224 public function addSetting($settings) {
225 $this->settings = $this->mergeSettings($this->settings, $settings);
226 if (!$this->addedSettings) {
227 $resources = $this;
228 CRM_Core_Region::instance('html-header')->add(array(
229 'callback' => function(&$snippet, &$html) use ($resources) {
230 $html .= "\n" . $resources->renderSetting();
231 },
232 'weight' => -100000,
233 ));
234 $this->addedSettings = TRUE;
235 }
236 return $this;
237 }
238
239 /**
240 * Add JavaScript variables to the global CRM object via a callback function.
241 *
242 * @param $callable function
243 * @return CRM_Core_Resources
244 */
245 public function addSettingsFactory($callable) {
246 // Make sure our callback has been registered
247 $this->addSetting(array());
248 $this->settingsFactories[] = $callable;
249 return $this;
250 }
251
252 /**
253 * Helper fn for addSettingsFactory
254 */
255 public function getSettings() {
256 $result = $this->settings;
257 foreach ($this->settingsFactories as $callable) {
258 $result = $this->mergeSettings($result, $callable());
259 }
260 return $result;
261 }
262
263 /**
264 * @param array $settings
265 * @param array $additions
266 * @return array combination of $settings and $additions
267 */
268 protected function mergeSettings($settings, $additions) {
269 foreach ($additions as $k => $v) {
270 if (isset($settings[$k]) && is_array($settings[$k]) && is_array($v)) {
271 $v += $settings[$k];
272 }
273 $settings[$k] = $v;
274 }
275 return $settings;
276 }
277
278 /**
279 * Helper fn for addSetting
280 * Render JavaScript variables for the global CRM object.
281 *
282 * @return string
283 */
284 public function renderSetting() {
285 $js = 'var CRM = ' . json_encode($this->getSettings()) . ';';
286 return sprintf("<script type=\"text/javascript\">\n%s\n</script>\n", $js);
287 }
288
289 /**
290 * Add translated string to the js CRM object.
291 * It can then be retrived from the client-side ts() function
292 * Variable substitutions can happen from client-side
293 *
294 * Note: this function rarely needs to be called directly and is mostly for internal use.
295 * @see CRM_Core_Resources::addScriptFile which automatically adds translated strings from js files
296 *
297 * Simple example:
298 * // From php:
299 * CRM_Core_Resources::singleton()->addString('Hello');
300 * // The string is now available to javascript code i.e.
301 * ts('Hello');
302 *
303 * Example with client-side substitutions:
304 * // From php:
305 * CRM_Core_Resources::singleton()->addString('Your %1 has been %2');
306 * // ts() in javascript works the same as in php, for example:
307 * ts('Your %1 has been %2', {1: objectName, 2: actionTaken});
308 *
309 * NOTE: This function does not work with server-side substitutions
310 * (as this might result in collisions and unwanted variable injections)
311 * Instead, use code like:
312 * CRM_Core_Resources::singleton()->addSetting(array('myNamespace' => array('myString' => ts('Your %1 has been %2', array(subs)))));
313 * And from javascript access it at CRM.myNamespace.myString
314 *
315 * @param $text string|array
316 * @return CRM_Core_Resources
317 */
318 public function addString($text) {
319 foreach ((array) $text as $str) {
320 $translated = ts($str);
321 // We only need to push this string to client if the translation
322 // is actually different from the original
323 if ($translated != $str) {
324 $this->addSetting(array('strings' => array($str => $translated)));
325 }
326 }
327 return $this;
328 }
329
330 /**
331 * Add a CSS file to the current page using <LINK HREF>.
332 *
333 * @param $ext string, extension name; use 'civicrm' for core
334 * @param $file string, file path -- relative to the extension base dir
335 * @param $weight int, relative weight within a given region
336 * @param $region string, location within the file; 'html-header', 'page-header', 'page-footer'
337 * @return CRM_Core_Resources
338 */
339 public function addStyleFile($ext, $file, $weight = self::DEFAULT_WEIGHT, $region = self::DEFAULT_REGION) {
340 return $this->addStyleUrl($this->getUrl($ext, $file, TRUE), $weight, $region);
341 }
342
343 /**
344 * Add a CSS file to the current page using <LINK HREF>.
345 *
346 * @param $url string
347 * @param $weight int, relative weight within a given region
348 * @param $region string, location within the file; 'html-header', 'page-header', 'page-footer'
349 * @return CRM_Core_Resources
350 */
351 public function addStyleUrl($url, $weight = self::DEFAULT_WEIGHT, $region = self::DEFAULT_REGION) {
352 CRM_Core_Region::instance($region)->add(array(
353 'name' => $url,
354 'type' => 'styleUrl',
355 'styleUrl' => $url,
356 'weight' => $weight,
357 'region' => $region,
358 ));
359 return $this;
360 }
361
362 /**
363 * Add a CSS content to the current page using <STYLE>.
364 *
365 * @param $code string, CSS source code
366 * @param $weight int, relative weight within a given region
367 * @param $region string, location within the file; 'html-header', 'page-header', 'page-footer'
368 * @return CRM_Core_Resources
369 */
370 public function addStyle($code, $weight = self::DEFAULT_WEIGHT, $region = self::DEFAULT_REGION) {
371 CRM_Core_Region::instance($region)->add(array(
372 // 'name' => automatic
373 'type' => 'style',
374 'style' => $code,
375 'weight' => $weight,
376 'region' => $region,
377 ));
378 return $this;
379 }
380
381 /**
382 * Determine file path of a resource provided by an extension
383 *
384 * @param $ext string, extension name; use 'civicrm' for core
385 * @param $file string, file path -- relative to the extension base dir
386 *
387 * @return (string|bool), full file path or FALSE if not found
388 */
389 public function getPath($ext, $file) {
390 // TODO consider caching results
391 $path = $this->extMapper->keyToBasePath($ext) . '/' . $file;
392 if (is_file($path)) {
393 return $path;
394 }
395 return FALSE;
396 }
397
398 /**
399 * Determine public URL of a resource provided by an extension
400 *
401 * @param $ext string, extension name; use 'civicrm' for core
402 * @param $file string, file path -- relative to the extension base dir
403 * @return string, URL
404 */
405 public function getUrl($ext, $file = NULL, $addCacheCode = FALSE) {
406 if ($file === NULL) {
407 $file = '';
408 }
409 if ($addCacheCode) {
410 $file .= '?r=' . $this->getCacheCode();
411 }
412 // TODO consider caching results
413 return $this->extMapper->keyToUrl($ext) . '/' . $file;
414 }
415
416 public function getCacheCode() {
417 return $this->cacheCode;
418 }
419
420 public function setCacheCode($value) {
421 $this->cacheCode = $value;
422 if ($this->cacheCodeKey) {
423 CRM_Core_BAO_Setting::setItem($value, CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, $this->cacheCodeKey);
424 }
425 }
426
427 public function resetCacheCode() {
428 $this->setCacheCode(CRM_Utils_String::createRandom(5, CRM_Utils_String::ALPHANUMERIC));
429 }
430
431 /**
432 * This adds CiviCRM's standard css and js to the specified region of the document.
433 * It will only run once.
434 *
435 * TODO: Separate the functional code (like addStyle/addScript) from the policy code
436 * (like addCoreResources/addCoreStyles).
437 *
438 * @return CRM_Core_Resources
439 * @access public
440 */
441 public function addCoreResources($region = 'html-header') {
442 if (!isset($this->addedCoreResources[$region])) {
443 $this->addedCoreResources[$region] = TRUE;
444 $config = CRM_Core_Config::singleton();
445
446 // Add resources from coreResourceList
447 $jsWeight = -9999;
448 foreach ($this->coreResourceList() as $file) {
449 if (substr($file, -2) == 'js') {
450 // Don't bother looking for ts() calls in packages, there aren't any
451 $translate = (substr($file, 0, 9) != 'packages/');
452 $this->addScriptFile('civicrm', $file, $jsWeight++, $region, $translate);
453 }
454 else {
455 $this->addStyleFile('civicrm', $file, -100, $region);
456 }
457 }
458
459 // Initialize CRM.url and CRM.formatMoney
460 $url = CRM_Utils_System::url('civicrm/example', 'placeholder', FALSE, NULL, FALSE);
461 $js = "CRM.url('init', '$url');\n";
462 $js .= "CRM.formatMoney('init', " . json_encode(CRM_Utils_Money::format(1234.56)) . ");";
463
464 $this->addLocalization($js);
465 $this->addScript($js, $jsWeight++, $region);
466
467 // Add global settings
468 $settings = array(
469 'userFramework' => $config->userFramework,
470 'resourceBase' => $config->resourceBase,
471 'lcMessages' => $config->lcMessages,
472 'ajaxPopupsEnabled' => $this->ajaxPopupsEnabled,
473 );
474 $this->addSetting(array('config' => $settings));
475
476 // Give control of jQuery back to the CMS - this loads last
477 $this->addScriptFile('civicrm', 'js/noconflict.js', 9999, $region, FALSE);
478
479 $this->addCoreStyles($region);
480 }
481 return $this;
482 }
483
484 /**
485 * This will add CiviCRM's standard CSS
486 *
487 * TODO: Separate the functional code (like addStyle/addScript) from the policy code
488 * (like addCoreResources/addCoreStyles).
489 *
490 * @param string $region
491 * @return CRM_Core_Resources
492 */
493 public function addCoreStyles($region = 'html-header') {
494 if (!isset($this->addedCoreStyles[$region])) {
495 $this->addedCoreStyles[$region] = TRUE;
496
497 // Load custom or core css
498 $config = CRM_Core_Config::singleton();
499 if (!empty($config->customCSSURL)) {
500 $this->addStyleUrl($config->customCSSURL, 99, $region);
501 }
502 if (!CRM_Core_BAO_Setting::getItem(CRM_Core_BAO_Setting::SYSTEM_PREFERENCES_NAME, 'disable_core_css')) {
503 $this->addStyleFile('civicrm', 'css/civicrm.css', -99, $region);
504 }
505 }
506 return $this;
507 }
508
509 /**
510 * Flushes cached translated strings
511 */
512 public function flushStrings() {
513 $this->cache->flush();
514 }
515
516 /**
517 * Translate strings in a javascript file
518 *
519 * @param $ext string, extension name
520 * @param $file string, file path
521 * @return void
522 */
523 private function translateScript($ext, $file) {
524 // For each extension, maintain one cache record which
525 // includes parsed (translatable) strings for all its JS files.
526 $stringsByFile = $this->cache->get($ext); // array($file => array(...strings...))
527 if (!$stringsByFile) {
528 $stringsByFile = array();
529 }
530 if (!isset($stringsByFile[$file])) {
531 $filePath = $this->getPath($ext, $file);
532 if ($filePath && is_readable($filePath)) {
533 $stringsByFile[$file] = CRM_Utils_JS::parseStrings(file_get_contents($filePath));
534 } else {
535 $stringsByFile[$file] = array();
536 }
537 $this->cache->set($ext, $stringsByFile);
538 }
539 $this->addString($stringsByFile[$file]);
540 }
541
542 /**
543 * Add inline scripts needed to localize js widgets
544 * @param string $js
545 */
546 function addLocalization(&$js) {
547 $config = CRM_Core_Config::singleton();
548
549 // Localize select2 strings
550 $contactSearch = json_encode($config->includeEmailInName ? ts('Start typing a name or email...') : ts('Start typing a name...'));
551 $otherSearch = json_encode(ts('Enter search term...'));
552 $js .= "
553 $.fn.select2.defaults.formatNoMatches = " . json_encode(ts("None found.")) . ";
554 $.fn.select2.defaults.formatLoadMore = " . json_encode(ts("Loading...")) . ";
555 $.fn.select2.defaults.formatSearching = " . json_encode(ts("Searching...")) . ";
556 $.fn.select2.defaults.formatInputTooShort = function(){return CRM.$(this).data('api-entity') == 'contact' ? $contactSearch : $otherSearch};
557 ";
558
559 // Contact create profiles with localized names
560 if (CRM_Core_Permission::check('edit all contacts') || CRM_Core_Permission::check('add contacts')) {
561 $this->addSetting(array('profile' => array('contactCreate' => CRM_Core_BAO_UFGroup::getCreateLinks())));
562 }
563 }
564
565 /**
566 * List of core resources we add to every CiviCRM page
567 *
568 * @return array
569 */
570 public function coreResourceList() {
571 $config = CRM_Core_Config::singleton();
572 // Use minified files for production, uncompressed in debug mode
573 // Note, $this->addScriptFile would automatically search for the non-minified file in debug mode but this is probably faster
574 $min = $config->debug ? '' : '.min';
575
576 // Scripts needed by everyone, everywhere
577 // FIXME: This is too long; list needs finer-grained segmentation
578 $items = array(
579 "packages/jquery/jquery-1.11.0$min.js",
580 "packages/jquery/jquery-migrate-1.2.1.js", // TODO: Remove before 4.5 release
581 "packages/jquery/jquery-ui/js/jquery-ui-1.10.4.custom$min.js",
582 "packages/jquery/jquery-ui/css/theme/jquery-ui-1.10.4.custom$min.css",
583
584 "packages/backbone/lodash.compat$min.js",
585
586 "packages/jquery/plugins/jquery.mousewheel$min.js",
587
588 "packages/jquery/plugins/select2/select2.js", // No mini until release of select2 3.4.6
589 "packages/jquery/plugins/select2/select2.css",
590
591 // TODO: Remove before 4.5 release
592 "packages/jquery/plugins/jquery.autocomplete.js",
593 "packages/jquery/css/jquery.autocomplete.css",
594
595 "packages/jquery/plugins/jquery.tableHeader.js",
596
597 "packages/jquery/plugins/jquery.textarearesizer.js",
598
599 "packages/jquery/plugins/jquery.form$min.js",
600
601 "packages/jquery/plugins/jquery.timeentry$min.js",
602
603 "packages/jquery/plugins/DataTables/media/js/jquery.dataTables$min.js",
604
605 "packages/jquery/plugins/jquery.FormNavigate$min.js",
606
607 "packages/jquery/plugins/jquery.validate$min.js",
608 "packages/jquery/plugins/jquery.ui.datepicker.validation.pack.js",
609
610 "js/Common.js",
611 "js/crm.ajax.js",
612 );
613
614 // These scripts are only needed by back-office users
615 if (CRM_Core_Permission::check('access CiviCRM')) {
616 $items[] = "packages/jquery/plugins/jquery.menu$min.js";
617 $items[] = "packages/jquery/css/menu.css";
618 $items[] = "packages/jquery/plugins/jquery.jeditable$min.js";
619 $items[] = "packages/jquery/plugins/jquery.blockUI$min.js";
620 $items[] = "packages/jquery/plugins/jquery.notify$min.js";
621 $items[] = "js/jquery/jquery.crmeditable.js";
622
623 // TODO: tokeninput is deprecated in favor of select2 and will be removed soon
624 $items[] = "packages/jquery/plugins/jquery.tokeninput$min.js";
625 $items[] = "packages/jquery/css/token-input-facebook.css";
626 }
627
628 // Enable administrators to edit option lists in a dialog
629 if (CRM_Core_Permission::check('administer CiviCRM') && $this->ajaxPopupsEnabled) {
630 $items[] = "js/crm.optionEdit.js";
631 }
632
633 // Add localized jQuery UI files
634 if ($config->lcMessages && $config->lcMessages != 'en_US') {
635 // Search for i18n file in order of specificity (try fr-CA, then fr)
636 list($lang) = explode('_', $config->lcMessages);
637 $path = "packages/jquery/jquery-ui/development-bundle/ui/" . ($min ? 'minified/' : '') . "i18n";
638 foreach (array(str_replace('_', '-', $config->lcMessages), $lang) as $language) {
639 $localizationFile = "$path/jquery.ui.datepicker-{$language}{$min}.js";
640 if ($this->getPath('civicrm', $localizationFile)) {
641 $items[] = $localizationFile;
642 break;
643 }
644 }
645 }
646 return $items;
647 }
648 }