Merge pull request #23824 from totten/mixin-refresh
[civicrm-core.git] / Civi / Angular / Manager.php
1 <?php
2 namespace Civi\Angular;
3
4 /**
5 * Manage Angular resources.
6 *
7 * @package Civi\Angular
8 */
9 class Manager {
10
11 /**
12 * @var \CRM_Core_Resources
13 */
14 protected $res = NULL;
15
16 /**
17 * Modules.
18 *
19 * @var array|null
20 * Each item has some combination of these keys:
21 * - ext: string
22 * The Civi extension which defines the Angular module.
23 * - js: array(string $relativeFilePath)
24 * List of JS files (relative to the extension).
25 * - css: array(string $relativeFilePath)
26 * List of CSS files (relative to the extension).
27 * - partials: array(string $relativeFilePath)
28 * A list of partial-HTML folders (relative to the extension).
29 * This will be mapped to "~/moduleName" by crmResource.
30 * - settings: array(string $key => mixed $value)
31 * List of settings to preload.
32 * - settingsFactory: callable
33 * Callback function to fetch settings.
34 * - permissions: array
35 * List of permissions to make available client-side
36 * - requires: array
37 * List of other modules required
38 */
39 protected $modules = NULL;
40
41 /**
42 * @var \CRM_Utils_Cache_Interface
43 */
44 protected $cache;
45
46 /**
47 * @var array
48 * Array(string $name => ChangeSet $change).
49 */
50 protected $changeSets = NULL;
51
52 /**
53 * @param \CRM_Core_Resources $res
54 * The resource manager.
55 * @param $cache
56 */
57 public function __construct($res, \CRM_Utils_Cache_Interface $cache = NULL) {
58 $this->res = $res;
59 $this->cache = $cache ? $cache : new \CRM_Utils_Cache_ArrayCache([]);
60 }
61
62 /**
63 * Clear out any runtime-cached metadata.
64 *
65 * This is useful if, eg, you have recently added or destroyed Angular modules.
66 *
67 * @return static
68 */
69 public function clear() {
70 $this->cache->clear();
71 $this->modules = NULL;
72 $this->changeSets = NULL;
73 // Force-refresh assetBuilder files
74 \Civi::container()->get('asset_builder')->clear(FALSE);
75 return $this;
76 }
77
78 /**
79 * Get a list of AngularJS modules which should be autoloaded.
80 *
81 * @return array
82 * Each item has some combination of these keys:
83 * - ext: string
84 * The Civi extension which defines the Angular module.
85 * - js: array(string $relativeFilePath)
86 * List of JS files (relative to the extension).
87 * - css: array(string $relativeFilePath)
88 * List of CSS files (relative to the extension).
89 * - partials: array(string $relativeFilePath)
90 * A list of partial-HTML folders (relative to the extension).
91 * This will be mapped to "~/moduleName" by crmResource.
92 * - settings: array(string $key => mixed $value)
93 * List of settings to preload.
94 */
95 public function getModules() {
96 if ($this->modules === NULL) {
97 $config = \CRM_Core_Config::singleton();
98 global $civicrm_root;
99
100 // Note: It would be nice to just glob("$civicrm_root/ang/*.ang.php"), but at time
101 // of writing CiviMail and CiviCase have special conditionals.
102
103 $angularModules = [];
104 $angularModules['angularFileUpload'] = include "$civicrm_root/ang/angularFileUpload.ang.php";
105 $angularModules['checklist-model'] = include "$civicrm_root/ang/checklist-model.ang.php";
106 $angularModules['crmApp'] = include "$civicrm_root/ang/crmApp.ang.php";
107 $angularModules['crmAttachment'] = include "$civicrm_root/ang/crmAttachment.ang.php";
108 $angularModules['crmAutosave'] = include "$civicrm_root/ang/crmAutosave.ang.php";
109 $angularModules['crmCxn'] = include "$civicrm_root/ang/crmCxn.ang.php";
110 $angularModules['crmDialog'] = include "$civicrm_root/ang/crmDialog.ang.php";
111 $angularModules['crmMonaco'] = include "$civicrm_root/ang/crmMonaco.ang.php";
112 $angularModules['crmResource'] = include "$civicrm_root/ang/crmResource.ang.php";
113 $angularModules['crmRouteBinder'] = include "$civicrm_root/ang/crmRouteBinder.ang.php";
114 $angularModules['crmUi'] = include "$civicrm_root/ang/crmUi.ang.php";
115 $angularModules['crmUtil'] = include "$civicrm_root/ang/crmUtil.ang.php";
116 $angularModules['dialogService'] = include "$civicrm_root/ang/dialogService.ang.php";
117 $angularModules['jsonFormatter'] = include "$civicrm_root/ang/jsonFormatter.ang.php";
118 $angularModules['ngRoute'] = include "$civicrm_root/ang/ngRoute.ang.php";
119 $angularModules['ngSanitize'] = include "$civicrm_root/ang/ngSanitize.ang.php";
120 $angularModules['ui.utils'] = include "$civicrm_root/ang/ui.utils.ang.php";
121 $angularModules['ui.bootstrap'] = include "$civicrm_root/ang/ui.bootstrap.ang.php";
122 $angularModules['ui.sortable'] = include "$civicrm_root/ang/ui.sortable.ang.php";
123 $angularModules['unsavedChanges'] = include "$civicrm_root/ang/unsavedChanges.ang.php";
124 $angularModules['crmQueueMonitor'] = include "$civicrm_root/ang/crmQueueMonitor.ang.php";
125 $angularModules['crmStatusPage'] = include "$civicrm_root/ang/crmStatusPage.ang.php";
126 $angularModules['exportui'] = include "$civicrm_root/ang/exportui.ang.php";
127 $angularModules['api4Explorer'] = include "$civicrm_root/ang/api4Explorer.ang.php";
128 $angularModules['api4'] = include "$civicrm_root/ang/api4.ang.php";
129 $angularModules['crmDashboard'] = include "$civicrm_root/ang/crmDashboard.ang.php";
130 $angularModules['crmD3'] = include "$civicrm_root/ang/crmD3.ang.php";
131
132 foreach (\CRM_Core_Component::getEnabledComponents() as $component) {
133 $angularModules = array_merge($angularModules, $component->getAngularModules());
134 }
135 \CRM_Utils_Hook::angularModules($angularModules);
136 foreach ($angularModules as $module => $info) {
137 // Merge in defaults
138 $angularModules[$module] += ['basePages' => ['civicrm/a']];
139 // Validate settingsFactory callables
140 if (isset($info['settingsFactory'])) {
141 // To keep the cache small, we want `settingsFactory` to contain the string names of class & function, not an object
142 if (!is_array($info['settingsFactory']) && !is_string($info['settingsFactory'])) {
143 throw new \CRM_Core_Exception($module . ' settingsFactory must be a callable array or string');
144 }
145 // To keep the cache small, convert full object to just the class name
146 if (is_array($info['settingsFactory']) && is_object($info['settingsFactory'][0])) {
147 $angularModules[$module]['settingsFactory'][0] = get_class($info['settingsFactory'][0]);
148 }
149 }
150 }
151 $this->modules = $this->resolvePatterns($angularModules);
152 }
153
154 return $this->modules;
155 }
156
157 /**
158 * Get the descriptor for an Angular module.
159 *
160 * @param string $name
161 * Module name.
162 * @return array
163 * Details about the module:
164 * - ext: string, the name of the Civi extension which defines the module
165 * - js: array(string $relativeFilePath).
166 * - css: array(string $relativeFilePath).
167 * - partials: array(string $relativeFilePath).
168 * @throws \Exception
169 */
170 public function getModule($name) {
171 $modules = $this->getModules();
172 if (!isset($modules[$name])) {
173 throw new \Exception("Unrecognized Angular module");
174 }
175 return $modules[$name];
176 }
177
178 /**
179 * Resolve a full list of Angular dependencies.
180 *
181 * @param array $names
182 * List of Angular modules.
183 * Ex: array('crmMailing').
184 * @return array
185 * List of Angular modules, include all dependencies.
186 * Ex: array('crmMailing', 'crmUi', 'crmUtil', 'ngRoute').
187 * @throws \CRM_Core_Exception
188 */
189 public function resolveDependencies($names) {
190 $allModules = $this->getModules();
191 $visited = [];
192 $result = $names;
193 while (($missingModules = array_diff($result, array_keys($visited))) && !empty($missingModules)) {
194 foreach ($missingModules as $module) {
195 $visited[$module] = 1;
196 if (!isset($allModules[$module])) {
197 throw new \CRM_Core_Exception("Unrecognized Angular module {$module}. Please ensure that all Angular modules are declared.");
198 }
199 elseif (isset($allModules[$module]['requires'])) {
200 $result = array_unique(array_merge($result, $allModules[$module]['requires']));
201 }
202 }
203 }
204 sort($result);
205 return $result;
206 }
207
208 /**
209 * Get a list of Angular modules that should be loaded on the given
210 * base-page.
211 *
212 * @param string $basePage
213 * The name of the base-page for which we want a list of moudles.
214 * @return array
215 * List of Angular modules.
216 * Ex: array('crmMailing', 'crmUi', 'crmUtil', 'ngRoute').
217 */
218 public function resolveDefaultModules($basePage) {
219 $modules = $this->getModules();
220 $result = [];
221 foreach ($modules as $moduleName => $module) {
222 if (in_array($basePage, $module['basePages']) || in_array('*', $module['basePages'])) {
223 $result[] = $moduleName;
224 }
225 }
226 return $result;
227 }
228
229 /**
230 * Convert any globs in an Angular module to file names.
231 *
232 * @param array $modules
233 * List of Angular modules.
234 * @return array
235 * Updated list of Angular modules
236 */
237 protected function resolvePatterns($modules) {
238 $newModules = [];
239
240 foreach ($modules as $moduleKey => $module) {
241 foreach (['js', 'css', 'partials'] as $fileset) {
242 if (!isset($module[$fileset])) {
243 continue;
244 }
245 $module[$fileset] = $this->res->glob($module['ext'], $module[$fileset]);
246 }
247 $newModules[$moduleKey] = $module;
248 }
249
250 return $newModules;
251 }
252
253 /**
254 * Get the partial HTML documents for a module (unfiltered).
255 *
256 * @param string $name
257 * Angular module name.
258 * @return array
259 * Array(string $extFilePath => string $html)
260 * @throws \Exception
261 * Invalid partials configuration.
262 */
263 public function getRawPartials($name) {
264 $module = $this->getModule($name);
265 $result = !empty($module['partialsCallback'])
266 ? \Civi\Core\Resolver::singleton()->call($module['partialsCallback'], [$name, $module])
267 : [];
268 if (isset($module['partials'])) {
269 foreach ($module['partials'] as $partialDir) {
270 $partialDir = $this->res->getPath($module['ext']) . '/' . $partialDir;
271 $files = \CRM_Utils_File::findFiles($partialDir, '*.html', TRUE);
272 foreach ($files as $file) {
273 $filename = '~/' . $name . '/' . $file;
274 $result[$filename] = file_get_contents($partialDir . '/' . $file);
275 }
276 }
277 return $result;
278 }
279 return $result;
280 }
281
282 /**
283 * Get the partial HTML documents for a module.
284 *
285 * @param string $name
286 * Angular module name.
287 * @return array
288 * Array(string $extFilePath => string $html)
289 * @throws \Exception
290 * Invalid partials configuration.
291 */
292 public function getPartials($name) {
293 $cacheKey = "angular-partials_$name";
294 $cacheValue = $this->cache->get($cacheKey);
295 if ($cacheValue === NULL) {
296 $cacheValue = ChangeSet::applyResourceFilters($this->getChangeSets(), 'partials', $this->getRawPartials($name));
297 $this->cache->set($cacheKey, $cacheValue);
298 }
299 return $cacheValue;
300 }
301
302 /**
303 * Get list of translated strings for a module.
304 *
305 * @param string $name
306 * Angular module name.
307 * @return array
308 * Translated strings: array(string $orig => string $translated).
309 */
310 public function getTranslatedStrings($name) {
311 $module = $this->getModule($name);
312 $result = [];
313 $strings = $this->getStrings($name);
314 foreach ($strings as $string) {
315 // TODO: should we pass translation domain based on $module[ext] or $module[tsDomain]?
316 // It doesn't look like client side really supports the domain right now...
317 $translated = ts($string, [
318 'domain' => [$module['ext'], NULL],
319 ]);
320 if ($translated != $string) {
321 $result[$string] = $translated;
322 }
323 }
324 return $result;
325 }
326
327 /**
328 * Get list of translatable strings for a module.
329 *
330 * @param string $name
331 * Angular module name.
332 * @return array
333 * Translatable strings.
334 */
335 public function getStrings($name) {
336 $module = $this->getModule($name);
337 $result = [];
338 if (isset($module['js'])) {
339 foreach ($module['js'] as $file) {
340 $strings = $this->res->getStrings()->get(
341 $module['ext'],
342 $this->res->getPath($module['ext'], $file),
343 'text/javascript'
344 );
345 $result = array_unique(array_merge($result, $strings));
346 }
347 }
348 $partials = $this->getPartials($name);
349 foreach ($partials as $partial) {
350 $result = array_unique(array_merge($result, \CRM_Utils_JS::parseStrings($partial)));
351 }
352 return $result;
353 }
354
355 /**
356 * Get resources for one or more modules.
357 *
358 * @param string|array $moduleNames
359 * List of module names.
360 * @param string $resType
361 * Type of resource ('js', 'css', 'settings').
362 * @param string $refType
363 * Type of reference to the resource ('cacheUrl', 'rawUrl', 'path', 'settings').
364 * @return array
365 * List of URLs or paths.
366 * @throws \CRM_Core_Exception
367 */
368 public function getResources($moduleNames, $resType, $refType) {
369 $result = [];
370 $moduleNames = (array) $moduleNames;
371 foreach ($moduleNames as $moduleName) {
372 $module = $this->getModule($moduleName);
373 if (isset($module[$resType])) {
374 foreach ($module[$resType] as $file) {
375 $refTypeSuffix = '';
376 if (is_string($file) && preg_match(';^(assetBuilder|ext)://;', $file)) {
377 $refTypeSuffix = '-' . parse_url($file, PHP_URL_SCHEME);
378 }
379
380 switch ($refType . $refTypeSuffix) {
381 case 'path':
382 $result[] = $this->res->getPath($module['ext'], $file);
383 break;
384
385 case 'rawUrl':
386 $result[] = $this->res->getUrl($module['ext'], $file);
387 break;
388
389 case 'cacheUrl':
390 $result[] = $this->res->getUrl($module['ext'], $file, TRUE);
391 break;
392
393 case 'path-assetBuilder':
394 $assetName = parse_url($file, PHP_URL_HOST) . parse_url($file, PHP_URL_PATH);
395 $assetParams = [];
396 parse_str('' . parse_url($file, PHP_URL_QUERY), $assetParams);
397 $result[] = \Civi::service('asset_builder')->getPath($assetName, $assetParams);
398 break;
399
400 case 'rawUrl-assetBuilder':
401 case 'cacheUrl-assetBuilder':
402 $assetName = parse_url($file, PHP_URL_HOST) . parse_url($file, PHP_URL_PATH);
403 $assetParams = [];
404 parse_str('' . parse_url($file, PHP_URL_QUERY), $assetParams);
405 $result[] = \Civi::service('asset_builder')->getUrl($assetName, $assetParams);
406 break;
407
408 case 'path-ext':
409 $result[] = $this->res->getPath(parse_url($file, PHP_URL_HOST), ltrim(parse_url($file, PHP_URL_PATH), '/'));
410 break;
411
412 case 'rawUrl-ext':
413 $result[] = $this->res->getUrl(parse_url($file, PHP_URL_HOST), ltrim(parse_url($file, PHP_URL_PATH), '/'));
414 break;
415
416 case 'cacheUrl-ext':
417 $result[] = $this->res->getUrl(parse_url($file, PHP_URL_HOST), ltrim(parse_url($file, PHP_URL_PATH), '/'), TRUE);
418 break;
419
420 case 'settings':
421 case 'settingsFactory':
422 case 'requires':
423 case 'permissions':
424 case 'bundles':
425 if (!empty($module[$resType])) {
426 $result[$moduleName] = $module[$resType];
427 }
428 break;
429
430 default:
431 throw new \CRM_Core_Exception("Unrecognized resource format");
432 }
433 }
434 }
435 }
436
437 return ChangeSet::applyResourceFilters($this->getChangeSets(), $resType, $result);
438 }
439
440 /**
441 * @return array
442 * Array(string $name => ChangeSet $changeSet).
443 */
444 public function getChangeSets() {
445 if ($this->changeSets === NULL) {
446 $this->changeSets = [];
447 \CRM_Utils_Hook::alterAngular($this);
448 }
449 return $this->changeSets;
450 }
451
452 /**
453 * @param ChangeSet $changeSet
454 * @return \Civi\Angular\Manager
455 */
456 public function add($changeSet) {
457 $this->changeSets[$changeSet->getName()] = $changeSet;
458 return $this;
459 }
460
461 }