PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
fluent-community / app / Services / SpaceMenuService.php

SpaceMenuService.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.11.0, at app/Services/SpaceMenuService.php

459 lines 14.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Services;
4
5 use FluentCommunity\Framework\Support\Arr;
6
7 /**
8 * Reconciles a space's primary menu.
9 *
10 * Runtime items are authoritative for what exists and where it routes; the stored list in
11 * `settings.primary_menu` is authoritative for order, label, icon and enabled state. A stored
12 * row with no runtime counterpart is dropped (page deleted, module deactivated, permission
13 * revoked) and a runtime item absent from the stored list is appended, so a newly created page
14 * appears without the admin having to re-save.
15 *
16 * Every tab, Posts included, can be switched off — the one invariant is that at least one row
17 * stays enabled, because the space has to land a visitor somewhere.
18 */
19 class SpaceMenuService
20 {
21 const SETTINGS_KEY = 'primary_menu';
22
23 const ICON_KEYS = [
24 'emoji',
25 'icon_image',
26 'shape_svg',
27 ];
28
29 /**
30 * The menu as one user sees it, ordered and access filtered.
31 *
32 * @param \FluentCommunity\App\Models\BaseSpace $space formatted space — `permissions` must be
33 * populated, or module contributed items
34 * (Documents, Media) will be missing
35 * @param \FluentCommunity\App\Models\User|null $user
36 * @return array
37 */
38 public static function getMenuLinks($space, $user = null)
39 {
40 $items = self::mergeItems(self::getRuntimeItems($space), self::getStoredItems($space));
41
42 $links = [];
43
44 foreach ($items as $item) {
45 if (!Helper::isLinkAccessible($item, $user)) {
46 continue;
47 }
48
49 $links[] = self::formatMenuLink($item);
50 }
51
52 return $links;
53 }
54
55 /**
56 * The menu as the admin manages it — every row, disabled ones included.
57 *
58 * @param \FluentCommunity\App\Models\BaseSpace $space formatted space
59 * @return array
60 */
61 public static function getManagerItems($space)
62 {
63 $items = self::mergeItems(self::getRuntimeItems($space), self::getStoredItems($space));
64
65 return array_map(function ($item) {
66 return [
67 'slug' => $item['slug'],
68 'title' => (string)Arr::get($item, 'title', ''),
69 'emoji' => (string)Arr::get($item, 'emoji', ''),
70 'icon_image' => (string)Arr::get($item, 'icon_image', ''),
71 'shape_svg' => (string)Arr::get($item, 'shape_svg', ''),
72 'enabled' => Arr::get($item, 'enabled') === 'no' ? 'no' : 'yes',
73 'new_tab' => Arr::get($item, 'new_tab') === 'yes' ? 'yes' : 'no',
74 'privacy' => (string)Arr::get($item, 'privacy', 'public'),
75 'membership_ids' => array_values((array)Arr::get($item, 'membership_ids', [])),
76 'permalink' => (string)Arr::get($item, 'permalink', ''),
77 'route' => Arr::get($item, 'route'),
78 'page_slug' => (string)Arr::get($item, 'page_slug', ''),
79 'link_type' => (string)Arr::get($item, 'link_type', ''),
80 'is_custom' => Arr::get($item, 'is_custom') === 'yes' ? 'yes' : 'no',
81 'is_system' => Arr::get($item, 'is_system') === 'yes' ? 'yes' : 'no',
82 'is_locked' => Arr::get($item, 'is_locked') === 'yes' ? 'yes' : 'no',
83 'source' => (string)Arr::get($item, 'source', 'system'),
84 ];
85 }, $items);
86 }
87
88 /**
89 * Everything that exists for this space right now: the two built-in tabs plus whatever the
90 * modules contribute through `fluent_community/space_header_links`.
91 *
92 * @param \FluentCommunity\App\Models\BaseSpace $space
93 * @return array
94 */
95 public static function getRuntimeItems($space)
96 {
97 $items = [
98 [
99 'slug' => 'space_feeds',
100 'title' => __('Posts', 'fluent-community'),
101 'route' => [
102 'name' => 'space_feeds',
103 ],
104 'is_system' => 'yes',
105 ],
106 ];
107
108 if (Arr::get($space->permissions, 'can_view_members')) {
109 $items[] = [
110 'slug' => 'space_members',
111 'title' => __('Members', 'fluent-community'),
112 'route' => [
113 'name' => 'space_members',
114 ],
115 'is_system' => 'yes',
116 ];
117 }
118
119 $items = apply_filters('fluent_community/space_header_links', $items, $space);
120
121 return self::normalizeRuntimeItems($items);
122 }
123
124 /**
125 * The stored row slug a space page's runtime item must carry. Pro calls this so the two sides
126 * cannot drift.
127 *
128 * @param string $pageSlug
129 * @return string
130 */
131 public static function getPageItemSlug($pageSlug)
132 {
133 return 'page_' . sanitize_title($pageSlug);
134 }
135
136 /**
137 * Persists the stored list.
138 *
139 * `settings` is one serialized column, so a read-modify-write here races any other write
140 * to the same row — callers must not run this concurrently with a `links` save.
141 *
142 * @param \FluentCommunity\App\Models\BaseSpace $space an unformatted space; formatSpaceData()
143 * stamps virtual attributes that make
144 * save() fail
145 * @param array $menuItems already sanitized rows
146 * @return void
147 */
148 public static function storeMenu($space, $menuItems)
149 {
150 $settings = $space->settings;
151 $settings[self::SETTINGS_KEY] = $menuItems;
152 $space->settings = $settings;
153 $space->save();
154 }
155
156 /**
157 * @param array $runtimeItems
158 * @param array $storedItems
159 * @return array
160 */
161 public static function mergeItems($runtimeItems, $storedItems)
162 {
163 $runtimeBySlug = [];
164
165 foreach ($runtimeItems as $runtimeItem) {
166 $runtimeBySlug[$runtimeItem['slug']] = $runtimeItem;
167 }
168
169 $suppressed = self::getSuppressedSlugs($storedItems);
170
171 $merged = [];
172 $consumed = [];
173
174 foreach ($storedItems as $storedItem) {
175 $slug = Arr::get($storedItem, 'slug');
176
177 if (!$slug || isset($consumed[$slug])) {
178 continue;
179 }
180
181 if (Arr::get($storedItem, 'is_custom') === 'yes') {
182 $consumed[$slug] = true;
183 $merged[] = self::normalizeCustomItem($storedItem);
184 continue;
185 }
186
187 if (!isset($runtimeBySlug[$slug]) || isset($suppressed[$slug])) {
188 continue;
189 }
190
191 $consumed[$slug] = true;
192 $merged[] = self::applyOverrides($runtimeBySlug[$slug], $storedItem);
193 }
194
195 foreach ($runtimeItems as $runtimeItem) {
196 $slug = $runtimeItem['slug'];
197
198 if (isset($consumed[$slug]) || isset($suppressed[$slug])) {
199 continue;
200 }
201
202 $merged[] = $runtimeItem;
203 }
204
205 return self::ensureOneEnabled($merged);
206 }
207
208 /**
209 * A space whose every tab is switched off has nowhere to land a visitor, so the first row in
210 * the admin's own order comes back on.
211 *
212 * This replaced a hard lock on the Posts tab. The constraint was never that Posts in
213 * particular must exist, only that something must — a documents-only or pages-only space is a
214 * reasonable thing to want, and `SpaceHomeRedirect` now follows whatever sits first.
215 *
216 * @param array $items
217 * @return array
218 */
219 protected static function ensureOneEnabled($items)
220 {
221 if (!$items) {
222 return $items;
223 }
224
225 foreach ($items as $item) {
226 // A runtime item that was never stored carries no `enabled` key and counts as on
227 if (Arr::get($item, 'enabled') !== 'no') {
228 return $items;
229 }
230 }
231
232 $items[0]['enabled'] = 'yes';
233
234 return $items;
235 }
236
237 /**
238 * @param \FluentCommunity\App\Models\BaseSpace $space
239 * @return array
240 */
241 protected static function getStoredItems($space)
242 {
243 $stored = Arr::get($space->settings, self::SETTINGS_KEY, []);
244
245 return is_array($stored) ? $stored : [];
246 }
247
248 /**
249 * A custom row pointing at a space page replaces that page's own auto appended row, so the
250 * deliberately placed one wins and the tab is not rendered twice.
251 *
252 * @param array $storedItems
253 * @return array
254 */
255 protected static function getSuppressedSlugs($storedItems)
256 {
257 $suppressed = [];
258
259 foreach ($storedItems as $storedItem) {
260 if (Arr::get($storedItem, 'is_custom') !== 'yes') {
261 continue;
262 }
263
264 if (Arr::get($storedItem, 'link_type') !== 'space_page') {
265 continue;
266 }
267
268 $pageSlug = Arr::get($storedItem, 'page_slug');
269
270 if ($pageSlug) {
271 $suppressed[self::getPageItemSlug($pageSlug)] = true;
272 }
273 }
274
275 return $suppressed;
276 }
277
278 /**
279 * @param array $items
280 * @return array
281 */
282 protected static function normalizeRuntimeItems($items)
283 {
284 $normalized = [];
285 $seen = [];
286
287 foreach ((array)$items as $item) {
288 if (!is_array($item)) {
289 continue;
290 }
291
292 $slug = self::resolveRuntimeSlug($item);
293
294 if (isset($seen[$slug])) {
295 continue;
296 }
297
298 $seen[$slug] = true;
299
300 $item['slug'] = $slug;
301 $item['is_system'] = 'yes';
302 $item['is_custom'] = 'no';
303
304 if (empty($item['source'])) {
305 $item['source'] = 'system';
306 }
307
308 $normalized[] = $item;
309 }
310
311 return $normalized;
312 }
313
314 /**
315 * @param array $item
316 * @return string
317 */
318 protected static function resolveRuntimeSlug($item)
319 {
320 $slug = Arr::get($item, 'slug');
321
322 if ($slug) {
323 return sanitize_title($slug);
324 }
325
326 $routeName = Arr::get($item, 'route.name');
327
328 if ($routeName) {
329 $params = (array)Arr::get($item, 'route.params', []);
330
331 // A space page keys the same way whether or not pro set the slug itself. Pro only
332 // sets it when this class exists, so an older pro against this core arrives without
333 // one — and deriving it any other way would orphan every stored page row the moment
334 // pro is updated, silently throwing the admin's page order back to the end of the bar.
335 if ($routeName === 'space_page' && !empty($params['page_slug'])) {
336 return self::getPageItemSlug($params['page_slug']);
337 }
338
339 ksort($params);
340
341 return sanitize_title($routeName . ($params ? '_' . implode('_', array_map('strval', $params)) : ''));
342 }
343
344 // Identity digest for third party filter items that carry no slug of their own. Not a
345 // security hash — it only has to stay stable across requests so a saved order survives.
346 $url = Arr::get($item, 'url', Arr::get($item, 'permalink', ''));
347
348 if ($url) {
349 return 'ext_' . substr(md5($url), 0, 12);
350 }
351
352 return 'item_' . substr(md5((string)Arr::get($item, 'title', '')), 0, 12);
353 }
354
355 /**
356 * @param array $storedItem
357 * @return array
358 */
359 protected static function normalizeCustomItem($storedItem)
360 {
361 $storedItem['is_custom'] = 'yes';
362 $storedItem['is_system'] = 'no';
363 $storedItem['is_locked'] = 'no';
364 $storedItem['source'] = 'custom';
365 $storedItem['enabled'] = Arr::get($storedItem, 'enabled') === 'no' ? 'no' : 'yes';
366
367 return $storedItem;
368 }
369
370 /**
371 * @param array $runtimeItem
372 * @param array $storedItem
373 * @return array
374 */
375 protected static function applyOverrides($runtimeItem, $storedItem)
376 {
377 $item = $runtimeItem;
378
379 $title = Arr::get($storedItem, 'title');
380
381 if ($title) {
382 $item['title'] = $title;
383 }
384
385 foreach (self::ICON_KEYS as $iconKey) {
386 $iconValue = Arr::get($storedItem, $iconKey);
387
388 if ($iconValue) {
389 $item[$iconKey] = $iconValue;
390 }
391 }
392
393 $item['enabled'] = Arr::get($storedItem, 'enabled') === 'no' ? 'no' : 'yes';
394
395 // Nothing in core locks a row any more. The flag stays honoured so a third party
396 // contributing through `space_header_links` can still mark its tab undisableable.
397 if (Arr::get($runtimeItem, 'is_locked') === 'yes') {
398 $item['enabled'] = 'yes';
399 }
400
401 $privacy = Arr::get($storedItem, 'privacy');
402
403 if ($privacy) {
404 $item['privacy'] = $privacy;
405 $item['membership_ids'] = (array)Arr::get($storedItem, 'membership_ids', []);
406 }
407
408 return $item;
409 }
410
411 /**
412 * @param array $item
413 * @return array
414 */
415 protected static function formatMenuLink($item)
416 {
417 $link = [
418 'slug' => $item['slug'],
419 'title' => (string)Arr::get($item, 'title', ''),
420 ];
421
422 foreach (self::ICON_KEYS as $iconKey) {
423 if (!empty($item[$iconKey])) {
424 $link[$iconKey] = $item[$iconKey];
425 }
426 }
427
428 if (!empty($item['route'])) {
429 $link['route'] = $item['route'];
430
431 return $link;
432 }
433
434 if (Arr::get($item, 'link_type') === 'space_page' && Arr::get($item, 'page_slug')) {
435 $link['route'] = [
436 'name' => 'space_page',
437 'params' => [
438 'page_slug' => Arr::get($item, 'page_slug'),
439 ],
440 ];
441
442 return $link;
443 }
444
445 $link['url'] = (string)Arr::get($item, 'permalink', Arr::get($item, 'url', ''));
446 $link['is_external'] = array_key_exists('is_external', $item)
447 ? !empty($item['is_external'])
448 : Arr::get($item, 'new_tab') === 'yes';
449
450 $cssClass = Arr::get($item, 'css_class');
451
452 if ($cssClass) {
453 $link['css_class'] = $cssClass;
454 }
455
456 return $link;
457 }
458 }
459