Merge pull request #20877 from kurund/patch-1
[civicrm-core.git] / CRM / Core / Resources / CollectionTrait.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
12 /**
13 * Class CRM_Core_Resources_CollectionTrait
14 *
15 * This is a building-block for creating classes which maintain a list of resources.
16 * It implements of the `CollectionInterface`.
17 *
18 * @see CRM_Core_Resources_CollectionInterface
19 */
20 trait CRM_Core_Resources_CollectionTrait {
21
22 use CRM_Core_Resources_CollectionAdderTrait;
23
24 /**
25 * Static defaults - a list of options to apply to any new snippets.
26 *
27 * @var array
28 */
29 protected $defaults = ['weight' => 1, 'disabled' => FALSE];
30
31 /**
32 * List of snippets to inject within region.
33 *
34 * e.g. $this->_snippets[3]['type'] = 'template';
35 *
36 * @var array
37 */
38 protected $snippets = [];
39
40 /**
41 * Whether the snippets array has been sorted
42 *
43 * @var bool
44 */
45 protected $isSorted = TRUE;
46
47 /**
48 * Whitelist of supported types.
49 *
50 * @var array
51 */
52 protected $types = [];
53
54 /**
55 * Add an item to the collection.
56 *
57 * @param array $snippet
58 * Resource options. See CollectionInterface docs.
59 * @return array
60 * The full/computed snippet (with defaults applied).
61 * @see CRM_Core_Resources_CollectionInterface
62 * @see CRM_Core_Resources_CollectionInterface::add()
63 */
64 public function add($snippet) {
65 $snippet = array_merge($this->defaults, $snippet);
66 $snippet['id'] = $this->nextId();
67 if (!isset($snippet['type'])) {
68 foreach ($this->types as $type) {
69 // auto-detect
70 if (isset($snippet[$type])) {
71 $snippet['type'] = $type;
72 break;
73 }
74 }
75 }
76 if (!in_array($snippet['type'] ?? NULL, $this->types)) {
77 $typeExpr = $snippet['type'] ?? '(' . implode(',', array_keys($snippet)) . ')';
78 throw new \RuntimeException("Unsupported snippet type: $typeExpr");
79 }
80 // Traditional behavior: sort by (1) weight and (2) either name or natural position. This second thing is called 'sortId'.
81 if (isset($snippet['name'])) {
82 $snippet['sortId'] = $snippet['name'];
83 }
84 else {
85 switch ($snippet['type']) {
86 case 'scriptUrl':
87 case 'styleUrl':
88 $snippet['sortId'] = $snippet['id'];
89 $snippet['name'] = $snippet[$snippet['type']];
90 break;
91
92 case 'scriptFile':
93 case 'styleFile':
94 $snippet['sortId'] = $snippet['id'];
95 $snippet['name'] = implode(':', $snippet[$snippet['type']]);
96 break;
97
98 default:
99 $snippet['sortId'] = $snippet['id'];
100 $snippet['name'] = $snippet['sortId'];
101 break;
102 }
103 }
104
105 if ($snippet['type'] === 'scriptFile' && !isset($snippet['scriptFileUrls'])) {
106 $res = Civi::resources();
107 list ($ext, $file) = $snippet['scriptFile'];
108
109 $snippet['translate'] = $snippet['translate'] ?? TRUE;
110 if ($snippet['translate']) {
111 $domain = ($snippet['translate'] === TRUE) ? $ext : $snippet['translate'];
112 // Is this too early?
113 $this->addString(Civi::service('resources.js_strings')->get($domain, $res->getPath($ext, $file), 'text/javascript'), $domain);
114 }
115 $snippet['scriptFileUrls'] = [$res->getUrl($ext, $res->filterMinify($ext, $file), TRUE)];
116 }
117 if ($snippet['type'] === 'scriptFile' && !isset($snippet['aliases'])) {
118 $snippet['aliases'] = $snippet['scriptFileUrls'];
119 }
120
121 if ($snippet['type'] === 'styleFile' && !isset($snippet['styleFileUrls'])) {
122 /** @var Civi\Core\Themes $theme */
123 $theme = Civi::service('themes');
124 list ($ext, $file) = $snippet['styleFile'];
125 $snippet['styleFileUrls'] = $theme->resolveUrls($theme->getActiveThemeKey(), $ext, $file);
126 }
127 if ($snippet['type'] === 'styleFile' && !isset($snippet['aliases'])) {
128 $snippet['aliases'] = $snippet['styleFileUrls'];
129 }
130
131 if (isset($snippet['aliases']) && !is_array($snippet['aliases'])) {
132 $snippet['aliases'] = [$snippet['aliases']];
133 }
134
135 $this->snippets[$snippet['name']] = $snippet;
136 $this->isSorted = FALSE;
137 return $snippet;
138 }
139
140 protected function nextId() {
141 if (!isset(Civi::$statics['CRM_Core_Resource_Count'])) {
142 $resId = Civi::$statics['CRM_Core_Resource_Count'] = 1;
143 }
144 else {
145 $resId = ++Civi::$statics['CRM_Core_Resource_Count'];
146 }
147
148 return $resId;
149 }
150
151 /**
152 * Update specific properties of a snippet.
153 *
154 * @param string $name
155 * Symbolic of the resource/snippet to update.
156 * @param array $snippet
157 * Resource options. See CollectionInterface docs.
158 * @return static
159 * @see CRM_Core_Resources_CollectionInterface::update()
160 */
161 public function update($name, $snippet) {
162 foreach ($this->resolveName($name) as $realName) {
163 $this->snippets[$realName] = array_merge($this->snippets[$realName], $snippet);
164 $this->isSorted = FALSE;
165 return $this;
166 }
167
168 Civi::log()->warning('Failed to update resource by name ({name})', [
169 'name' => $name,
170 ]);
171 return $this;
172 }
173
174 /**
175 * Remove all snippets.
176 *
177 * @return static
178 * @see CRM_Core_Resources_CollectionInterface::clear()
179 */
180 public function clear() {
181 $this->snippets = [];
182 $this->isSorted = TRUE;
183 return $this;
184 }
185
186 /**
187 * Get snippet.
188 *
189 * @param string $name
190 * @return array|NULL
191 * @see CRM_Core_Resources_CollectionInterface::get()
192 */
193 public function &get($name) {
194 foreach ($this->resolveName($name) as $realName) {
195 return $this->snippets[$realName];
196 }
197
198 $null = NULL;
199 return $null;
200 }
201
202 /**
203 * Get a list of all snippets in this collection.
204 *
205 * @return iterable
206 * @see CRM_Core_Resources_CollectionInterface::getAll()
207 */
208 public function getAll(): iterable {
209 $this->sort();
210 return $this->snippets;
211 }
212
213 /**
214 * Alter the contents of the collection.
215 *
216 * @param callable $callback
217 * The callback is invoked once for each member in the collection.
218 * The callback may return one of three values:
219 * - TRUE: The item is OK and belongs in the collection.
220 * - FALSE: The item is not OK and should be omitted from the collection.
221 * - Array: The item should be revised (using the returned value).
222 * @return static
223 * @see CRM_Core_Resources_CollectionInterface::filter()
224 */
225 public function filter($callback) {
226 $this->sort();
227 $names = array_keys($this->snippets);
228 foreach ($names as $name) {
229 $ret = $callback($this->snippets[$name]);
230 if ($ret === TRUE) {
231 // OK
232 }
233 elseif ($ret === FALSE) {
234 unset($this->snippets[$name]);
235 }
236 elseif (is_array($ret)) {
237 $this->snippets[$name] = $ret;
238 $this->isSorted = FALSE;
239 }
240 else {
241 throw new \RuntimeException("CollectionTrait::filter() - Callback returned invalid value");
242 }
243 }
244 return $this;
245 }
246
247 /**
248 * Find all snippets which match the given criterion.
249 *
250 * @param callable $callback
251 * The callback is invoked once for each member in the collection.
252 * The callback may return one of three values:
253 * - TRUE: The item is OK and belongs in the collection.
254 * - FALSE: The item is not OK and should be omitted from the collection.
255 * @return iterable
256 * List of matching snippets.
257 * @see CRM_Core_Resources_CollectionInterface::find()
258 */
259 public function find($callback): iterable {
260 $r = [];
261 $this->sort();
262 foreach ($this->snippets as $name => $snippet) {
263 if ($callback($snippet)) {
264 $r[$name] = $snippet;
265 }
266 }
267 return $r;
268 }
269
270 /**
271 * Assimilate a list of resources into this list.
272 *
273 * @param iterable $snippets
274 * List of snippets to add.
275 * @return static
276 * @see CRM_Core_Resources_CollectionInterface::merge()
277 */
278 public function merge(iterable $snippets) {
279 foreach ($snippets as $next) {
280 $name = $next['name'];
281 $current = $this->snippets[$name] ?? NULL;
282 if ($current === NULL) {
283 $this->add($next);
284 }
285 elseif ($current['type'] === 'settings' && $next['type'] === 'settings') {
286 $this->addSetting($next['settings']);
287 foreach ($next['settingsFactories'] as $factory) {
288 $this->addSettingsFactory($factory);
289 }
290 $this->isSorted = FALSE;
291 }
292 elseif ($current['type'] === 'settings' || $next['type'] === 'settings') {
293 throw new \RuntimeException(sprintf("Cannot merge snippets of types [%s] and [%s]" . $current['type'], $next['type']));
294 }
295 else {
296 $this->add($next);
297 }
298 }
299 return $this;
300 }
301
302 /**
303 * Ensure that the collection is sorted.
304 *
305 * @return static
306 */
307 protected function sort() {
308 if (!$this->isSorted) {
309 uasort($this->snippets, [__CLASS__, '_cmpSnippet']);
310 $this->isSorted = TRUE;
311 }
312 return $this;
313 }
314
315 /**
316 * @param string $name
317 * Name or alias.
318 * return array
319 * List of real names.
320 */
321 protected function resolveName($name) {
322 if (isset($this->snippets[$name])) {
323 return [$name];
324 }
325 foreach ($this->snippets as $snippetName => $snippet) {
326 if (isset($snippet['aliases']) && in_array($name, $snippet['aliases'])) {
327 return [$snippetName];
328 }
329 }
330 return [];
331 }
332
333 /**
334 * @param $a
335 * @param $b
336 *
337 * @return int
338 */
339 public static function _cmpSnippet($a, $b) {
340 if ($a['weight'] < $b['weight']) {
341 return -1;
342 }
343 if ($a['weight'] > $b['weight']) {
344 return 1;
345 }
346 // fallback to name sort; don't really want to do this, but it makes results more stable
347 if ($a['sortId'] < $b['sortId']) {
348 return -1;
349 }
350 if ($a['sortId'] > $b['sortId']) {
351 return 1;
352 }
353 return 0;
354 }
355
356 // -----------------------------------------------
357
358 /**
359 * Assimilate all the resources listed in a bundle.
360 *
361 * @param iterable|string|\CRM_Core_Resources_Bundle $bundle
362 * Either bundle object, or the symbolic name of a bundle.
363 * Note: For symbolic names, the bundle must be a container service ('bundle.FOO').
364 * @return static
365 */
366 public function addBundle($bundle) {
367 if (is_iterable($bundle)) {
368 foreach ($bundle as $b) {
369 $this->addBundle($b);
370 }
371 return $this;
372 }
373 if (is_string($bundle)) {
374 $bundle = Civi::service('bundle.' . $bundle);
375 }
376 return $this->merge($bundle->getAll());
377 }
378
379 /**
380 * Get a fully-formed/altered list of settings, including the results of
381 * any callbacks/listeners.
382 *
383 * @return array
384 */
385 public function getSettings(): array {
386 $s = &$this->findCreateSettingSnippet();
387 $result = $s['settings'];
388 foreach ($s['settingsFactories'] as $callable) {
389 $result = CRM_Core_Resources_CollectionAdderTrait::mergeSettings($result, $callable());
390 }
391 CRM_Utils_Hook::alterResourceSettings($result);
392 return $result;
393 }
394
395 /**
396 * @return array
397 */
398 public function &findCreateSettingSnippet($options = []): array {
399 $snippet = &$this->get('settings');
400 if ($snippet !== NULL) {
401 return $snippet;
402 }
403
404 $this->add([
405 'name' => 'settings',
406 'type' => 'settings',
407 'settings' => [],
408 'settingsFactories' => [],
409 'weight' => -100000,
410 ]);
411 return $this->get('settings');
412 }
413
414 }