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