Repository navigation
Expand file tree
/
Copy pathModule.php
More file actions
286 lines (270 loc) · 12.1 KB
/
Copy pathModule.php
File metadata and controls
286 lines (270 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
<?php
namespace AdminMenuEditor;
use AdminMenuEditor\Stdlib\Badges;
use AdminMenuEditor\Stdlib\Builder;
use AdminMenuEditor\Stdlib\Inventory;
use AdminMenuEditor\Stdlib\Layout;
use AdminMenuEditor\Stdlib\MoreIcons;
use Laminas\EventManager\Event;
use Laminas\EventManager\SharedEventManagerInterface;
use Laminas\Mvc\Controller\AbstractController;
use Laminas\Mvc\MvcEvent;
use Laminas\ServiceManager\ServiceLocatorInterface;
use Laminas\View\Renderer\PhpRenderer;
use Omeka\Module\AbstractModule;
/**
* THE ADMIN LEFT MENU, ARRANGED.
*
* Omeka draws the admin menu from navigation containers in a fixed set of sections (Resources,
* Admin, Modules), each module adding its entries where its config file says, so a site with
* many modules ends up with a long pile under "Modules" and tools mixed with data under
* "Resources". This module keeps one layout (Stdlib\Layout) — groups, order, names, icons,
* links of its own, hidden entries, who sees what — and draws the menu from it.
*
* It comes LAST and only arranges what is there:
* - view.layout, at the lowest priority: every other module has added, moved or hidden its
* entries by then (at load time or while the page was made). The menu is drawn from the
* containers as they are now (Stdlib\Builder).
* - EVENT_FINISH: the inside of <nav id="menu"> in the finished page is replaced by that menu.
* Omeka's layout file is not overridden: a new Omeka version keeps its own layout.
* - in the browser (asset/js/menu.js): modules that add or move entries with a script
* (Guest's "Users" section, links added by a page script) do so on the page; the script
* then puts every entry back in the layout's place, and new ones in "Unsorted".
* No layout stored = nothing is done: Omeka's menu as every module made it. Turning the module
* off is the same. No other module's settings or code are ever touched: an entry another module
* hides stays hidden unless the layout says "show anyway" for it, and dropping that override
* (or this module) gives the other module its way back.
*
* The editor: /admin/admin-menu (global admins only; Controller\Admin\IndexController).
*/
class Module extends AbstractModule
{
/** @var string|null the drawn menu for this request, put in the page at EVENT_FINISH */
protected $menu;
public function getConfig()
{
return include __DIR__ . '/config/module.config.php';
}
public function onBootstrap(MvcEvent $event)
{
parent::onBootstrap($event);
// the editor is for global admins only. Omeka allows site_admin everything it does not deny,
// and a deny for all roles at once does not beat that blanket allow in Laminas' ACL: so each
// role is denied by name (the controller checks the role as well, for roles added later)
$acl = $this->getServiceLocator()->get('Omeka\Acl');
foreach ($acl->getRoles() as $role) {
if ($role !== 'global_admin') {
$acl->deny($role, Controller\Admin\IndexController::class);
}
}
$event->getApplication()->getEventManager()->attach(MvcEvent::EVENT_FINISH, [$this, 'onFinish'], -1000);
}
public function attachListeners(SharedEventManagerInterface $sharedEventManager)
{
// the lowest priority there is in practice: after every other module's view.layout
$sharedEventManager->attach('*', 'view.layout', [$this, 'onLayout'], -100000);
}
public function onLayout(Event $event): void
{
$view = $event->getTarget();
try {
if (!$view instanceof PhpRenderer || !$view->status()->isAdminRequest() || !$view->identity()) {
return;
}
$services = $this->getServiceLocator();
$inventory = new Inventory($services);
if ($this->isEditor($services)) {
$this->editorData($view, $inventory);
}
$layout = Layout::load($services->get('Omeka\Settings'));
if ($layout === null) {
return;
}
$builder = new Builder($services, $view, $inventory);
$t = microtime(true);
$this->menu = $builder->render($layout);
if ($builder->badges) {
// how long the menu and its numbers took, for whoever measures (a header, no body change)
$spent = round((microtime(true) - $t) * 1000, 1);
$parts = $builder->badges->spent;
$services->get('Application')->getResponse()->getHeaders()->addHeaderLine('X-Admin-Menu-Ms',
$spent . (isset($parts['cache']) ? ' cached' : ' counted'));
}
$view->headLink()->appendStylesheet($view->assetUrl('css/menu.css', 'AdminMenuEditor'));
if (MoreIcons::used($layout)) {
// the "More icons" font only when an entry uses one (a menu without them stays as light)
$view->headLink()->appendStylesheet($view->assetUrl('css/more-icons.css', 'AdminMenuEditor'));
}
$view->headScript()->appendScript('window.AME_MENU=' . json_encode($builder->client(),
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . ';');
$view->headScript()->appendFile($view->assetUrl('js/menu.js', 'AdminMenuEditor'), 'text/javascript', ['defer' => 'defer']);
} catch (\Throwable $e) {
// Omeka's own menu rather than a broken page
$this->menu = null;
try {
$this->getServiceLocator()->get('Omeka\Logger')->err('AdminMenuEditor: ' . $e->getMessage());
} catch (\Throwable $e2) {
}
}
}
/**
* The finished page: the inside of <nav id="menu"> becomes the drawn menu. A site's own
* section (<nav id="site-nav">, on a site's admin pages) is kept, after the group that holds
* "Sites", or first when no group does.
*/
public function onFinish(MvcEvent $event): void
{
if ($this->menu === null) {
return;
}
$menu = $this->menu;
$this->menu = null;
$response = $event->getResponse();
if (!$response || !method_exists($response, 'getContent')) {
return;
}
$html = $response->getContent();
if (!is_string($html)) {
return;
}
$out = self::replaceMenu($html, $menu);
if ($out !== null) {
$response->setContent($out);
}
}
/**
* Replace the inside of the first <nav id="menu" …> … </nav> (nested <nav> counted).
* Null when the page has no such menu or it cannot be read with certainty.
*/
public static function replaceMenu(string $html, string $menu): ?string
{
if (!preg_match('/<nav\b[^>]*\bid="menu"[^>]*>/i', $html, $m, PREG_OFFSET_CAPTURE)) {
return null;
}
$open = $m[0][0];
$start = $m[0][1] + strlen($open);
if (!preg_match_all('#<(/?)nav\b[^>]*>#i', $html, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER, $start)) {
return null;
}
$depth = 1;
$end = null;
$siteNav = '';
$siteStart = null;
foreach ($tags as $t) {
if ($t[1][0] === '') {
if ($depth === 1 && $siteStart === null && preg_match('/\bid="site-nav"/i', $t[0][0])) {
$siteStart = $t[0][1];
}
$depth++;
} else {
$depth--;
if ($depth === 1 && $siteStart !== null && $siteNav === '') {
$siteNav = substr($html, $siteStart, $t[0][1] + strlen($t[0][0]) - $siteStart);
}
if ($depth === 0) {
$end = $t[0][1];
break;
}
}
}
if ($end === null) {
return null;
}
if ($siteNav !== '') {
$at = self::afterSitesGroup($menu);
$menu = substr($menu, 0, $at) . $siteNav . substr($menu, $at);
}
$open = preg_replace('/^<nav\b/i', '<nav data-ame="1"', $open);
return substr($html, 0, $m[0][1]) . $open . $menu . substr($html, $end);
}
/** Where the list holding the "Sites" entry ends; 0 when there is none. */
protected static function afterSitesGroup(string $menu): int
{
$p = strpos($menu, 'data-ame-key="admin/site"');
if ($p === false) {
$p = strpos($menu, 'data-ame-key="admin/site"');
}
if ($p === false) {
return 0;
}
// the next group starts with its heading or its list (Builder::group); "Unsorted" always follows
$next = [];
foreach (['<h5 data-ame-head=', '<ul class="navigation" data-ame-group='] as $s) {
$q = strpos($menu, $s, $p);
if ($q !== false) {
$next[] = $q;
}
}
return $next ? min($next) : strlen($menu);
}
protected function isEditor($services): bool
{
$event = $services->get('Application')->getMvcEvent();
$match = $event ? $event->getRouteMatch() : null;
return $match && $match->getParam('controller') === Controller\Admin\IndexController::class;
}
/** What the editor needs that only exists now: the menu as other modules have left it. */
protected function editorData(PhpRenderer $view, Inventory $inventory): void
{
$acl = $this->getServiceLocator()->get('Omeka\Acl');
$roles = $acl->getRoles();
$entries = $inventory->describe($view, $roles);
// the numbers each entry could show, now (not from the cache), for the editor to offer:
// Omeka's lists counted here, every other entry asked of the providers
$countable = [];
foreach ($inventory->pages() as $key => [$page]) {
if (Badges::countable($page)) {
$countable[$key] = $page;
}
}
$keys = array_keys($entries);
$layout = Layout::load($this->getServiceLocator()->get('Omeka\Settings'));
foreach ($layout['groups'] ?? [] as $g) {
foreach ($g['items'] as $it) {
$keys[] = $it['key'];
}
}
$config = $this->getServiceLocator()->get('Config')['admin_menu_editor'] ?? [];
$keys = array_values(array_unique(array_merge($keys, array_keys($config['waiting'] ?? []), array_keys($config['count'] ?? []))));
$values = (new Badges($this->getServiceLocator()))->get($countable + array_fill_keys($keys, null), $keys, true);
$data = [
'entries' => array_values($entries),
'containers' => $inventory->containers(),
'hints' => $inventory->hints(),
'badges' => $values,
];
$view->headScript()->appendScript('window.AME_INVENTORY=' . json_encode($data,
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) . ';');
}
/* =================================================================== install === */
public function install(ServiceLocatorInterface $services)
{
$services->get('Omeka\Connection')->executeStatement(<<<'SQL'
CREATE TABLE IF NOT EXISTS admin_menu_editor_log (
id INT AUTO_INCREMENT NOT NULL,
created DATETIME NOT NULL,
user_id INT DEFAULT NULL,
user_name VARCHAR(190) NOT NULL DEFAULT '',
action VARCHAR(20) NOT NULL,
summary TEXT NOT NULL,
layout LONGTEXT DEFAULT NULL,
PRIMARY KEY(id)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci ENGINE = InnoDB
SQL);
}
public function uninstall(ServiceLocatorInterface $services)
{
$services->get('Omeka\Connection')->executeStatement('DROP TABLE IF EXISTS admin_menu_editor_log');
$services->get('Omeka\Settings')->delete(Layout::SETTING);
}
/** The module's "Configure" button leads to the editor. */
public function getConfigForm(PhpRenderer $renderer)
{
return '<p>' . $renderer->hyperlink($renderer->translate('Open the admin menu editor'), // @translate
$renderer->url('admin/admin-menu-editor'), ['class' => 'button']) . '</p>';
}
public function handleConfigForm(AbstractController $controller)
{
return true;
}
}