1000, ]; /** @var AdminController */ protected $controller; /** * @return bool */ public static function checkRequirements(): bool { return version_compare(GRAV_VERSION, static::MIN_GRAV_VERSION, '>='); } /** * @return array * * The getSubscribedEvents() gives the core a list of events * that the plugin wants to listen to. The key of each * array section is the event that the plugin listens to * and the value (in the form of an array) contains the * callable (or function) as well as the priority. The * higher the number the higher the priority. */ public static function getSubscribedEvents(): array { if (!static::checkRequirements()) { return []; } return [ PluginsLoadedEvent::class => [ ['initializeFlex', 10] ], PermissionsRegisterEvent::class => [ ['onRegisterPermissions', 100] ], FlexRegisterEvent::class => [ ['onRegisterFlex', 100] ], 'onCliInitialize' => [ ['autoload', 100000], ['initializeFlex', 10] ], 'onPluginsInitialized' => [ ['onPluginsInitialized', 0], ], 'onFormRegisterTypes' => [ ['onFormRegisterTypes', 0] ], 'onApiRegisterRoutes' => [ ['onApiRegisterRoutes', 0] ], 'onApiSidebarItems' => [ ['onApiSidebarItems', 0] ], 'onApiBlueprintResolved' => [ ['onApiBlueprintResolved', 0] ], 'onShortcodeHandlers' => [ ['onShortcodeHandlers', 0] ] ]; } /** * Get list of form field types specified in this plugin. Only special types needs to be listed. * * @return array */ public function getFormFieldTypes() { return [ 'list' => [ 'array' => true ], 'pagemedia' => [ 'array' => true, 'media_field' => true, 'validate' => [ 'type' => 'ignore' ] ], 'filepicker' => [ 'media_picker_field' => true ], ]; } /** * @return ClassLoader */ public function autoload(): ClassLoader { return require __DIR__ . '/vendor/autoload.php'; } /** * [PluginsLoadedEvent:10]: Initialize Flex * * @return void */ public function initializeFlex(): void { $config = $this->config->get('plugins.flex-objects.directories') ?? []; // Add to DI container $this->grav['flex_objects'] = static function (Grav $grav) use ($config) { /** @var FlexInterface $flex */ $flex = $grav['flex']; $flexObjects = new Flex($flex, $config); // This event is for backwards compatibility only, do not use it! $grav->fireEvent('onFlexInit', new Event(['flex' => $flexObjects])); return $flexObjects; }; } /** * Initialize the plugin * * @return void */ public function onPluginsInitialized(): void { if ($this->isAdmin()) { /** @var UserInterface|null $user */ $user = $this->grav['user'] ?? null; if (null === $user || !$user->authorize('login', 'admin')) { return; } $this->enable([ 'onAdminTwigTemplatePaths' => [ ['onAdminTwigTemplatePaths', 10] ], 'onAdminMenu' => [ ['onAdminMenu', 0] ], 'onAdminPage' => [ ['onAdminPage', 0] ], 'onAdminCompilePresetSCSS' => [ ['onAdminCompilePresetSCSS', 0] ], 'onDataTypeExcludeFromDataManagerPluginHook' => [ ['onDataTypeExcludeFromDataManagerPluginHook', 0] ], 'onAdminControllerInit' => [ ['onAdminControllerInit', 0] ], 'onThemeInitialized' => [ ['onThemeInitialized', 0] ], 'onPageInitialized' => [ ['onAdminPageInitialized', 0] ], 'onTwigSiteVariables' => [ ['onTwigAdminVariables', 0] ], 'onGetPageTemplates' => ['onGetPageTemplates', 0] ]); } else { $this->enable([ 'onTwigTemplatePaths' => [ ['onTwigTemplatePaths', 0] ], 'onTwigInitialized' => [ ['onTwigInitialized', 0] ], 'onFlexObjectMedia' => [ ['onFlexObjectMedia', 0] ], 'onPagesInitialized' => [ // Serve Flex Object media through the permission-aware proxy // before the default flex router / 404 handler runs. ['serveMediaProxy', 100000], ['onPagesInitialized', -10000] ], 'onPageInitialized' => [ ['authorizePage', 10000] ], 'onBeforeFlexFormInitialize' => [ ['onBeforeFlexFormInitialize', -10] ], 'onPageTask' => [ ['onPageTask', -10] ], ]); } } /** * @param FlexRegisterEvent $event * @return void */ public function onRegisterFlex(FlexRegisterEvent $event): void { /** @var \Grav\Framework\Flex\Flex $flex */ $flex = $event->flex; $types = (array)$this->config->get('plugins.flex-objects.directories', []); $this->registerDirectories($flex, $types); } /** * @return void */ public function onThemeInitialized(): void { // Register directories defined in the theme. /** @var \Grav\Framework\Flex\Flex $flex */ $flex = $this->grav['flex']; $types = (array)$this->config->get('plugins.flex-objects.directories', []); $this->registerDirectories($flex, $types, true); $this->controller = new AdminController(); /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $names = implode(', ', array_keys($flex->getDirectories())); $debugger->addMessage(sprintf('Registered flex types: %s', $names), 'debug'); } /** * @param Event $event */ public function onBeforeFlexFormInitialize(Event $event): void { /** @var array $form */ $form = $event['form']; $edit = $form['actions']['edit'] ?? false; if (!isset($form['flex']['key']) && $edit === true) { /** @var Route $route */ $route = $this->grav['route']; $id = rawurldecode($route->getGravParam('id')); if (null !== $id) { $form['flex']['key'] = $id; $event['form'] = $form; } } } /** * [onPagesInitialized:-10000] Default router for flex pages. * * @param Event $event */ public function onPagesInitialized(Event $event): void { /** @var Route|null $route */ $route = $event['route'] ?? null; if (null === $route) { // Stop if in CLI. return; } /** @var PageInterface|null $page */ $page = $this->grav['page'] ?? null; $base = ''; $path = []; if (!$page->routable() || $page->template() === 'notfound') { /** @var Pages $pages */ $pages = $this->grav['pages']; // Find first existing and routable parent page. $parts = explode('/', $route->getRoute()); array_shift($parts); $page = null; while (!$page && $parts) { $path[] = array_pop($parts); $base = '/' . implode('/', $parts); $page = $pages->find($base); if ($page && !$page->routable()) { $page = null; } } } // If page is found, check if it contains flex directory router. if ($page) { $flex = $this->grav['flex']; $options = $page->header()->flex ?? null; $router = $options['router'] ?? null; $type = $options['directory'] ?? null; $directory = $type ? $flex->getDirectory($type) : null; if (\is_string($router)) { $path = implode('/', array_reverse($path)); $response = null; $flexEvent = new Event([ 'flex' => $flex, 'directory' => $directory, 'parent' => $page, 'page' => $page, 'base' => $base, 'path' => $path, 'route' => $route, 'options' => $options, 'request' => $event['request'], 'response' => &$response, ]); $flexEvent = $this->grav->fireEvent("flex.router.{$router}", $flexEvent); if ($response) { $this->grav->close($response); } /** @var PageInterface|null $routedPage */ $routedPage = $flexEvent['page']; if ($routedPage) { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $debugger->addMessage(sprintf('Flex uses page %s', $routedPage->route())); unset($this->grav['page']); $this->grav['page'] = $routedPage; $event->stopPropagation(); } } } } /** * [onPagesInitialized:100000] Serve Flex Object media through the * permission-aware proxy (prototype — see docs/specs/media-proxy.md). * * Matches `///` (base configurable, default * `/flex-media`) and streams the file via MediaProxyController, so media can * stay under a locked-down `user/data` instead of being linked directly. * * @param Event $event */ public function serveMediaProxy(Event $event): void { if (!$this->config->get('plugins.flex-objects.media_proxy.enabled', false)) { return; } /** @var Route|null $route */ $route = $event['route'] ?? null; if (null === $route) { return; } $base = '/' . trim((string) $this->config->get('plugins.flex-objects.media_proxy.base', '/flex-media'), '/'); $path = $route->getRoute(); if ($path !== $base && !str_starts_with($path, $base . '/')) { return; } // // — filename may contain sub-paths. $rest = trim(substr($path, strlen($base)), '/'); $parts = explode('/', $rest); if (count($parts) < 3) { return; } $type = array_shift($parts); $key = array_shift($parts); $filename = rawurldecode(implode('/', $parts)); $field = $route->getQueryParam('field'); $controller = new \Grav\Plugin\FlexObjects\Controllers\MediaProxyController($this->grav); $response = $controller->serve($type, $key, $filename, is_string($field) ? $field : null, $event['request']); $this->grav->close($response); } /** * [onFlexObjectMedia] Stamp a proxy `url` override on each of an object's * media items so `medium.url` routes the original through the proxy * (prototype — see docs/specs/media-proxy.md). Core's ImageMedium honours the * override only for unmodified originals, so resized/cropped derivatives keep * serving straight from `images/`. No-op unless the proxy is enabled. * * @param Event $event */ public function onFlexObjectMedia(Event $event): void { if (!$this->config->get('plugins.flex-objects.media_proxy.enabled', false)) { return; } $object = $event['object'] ?? null; $media = $event['media'] ?? null; if (!$object instanceof \Grav\Framework\Flex\Interfaces\FlexObjectInterface || !$media instanceof \Grav\Common\Media\Interfaces\MediaCollectionInterface) { return; } foreach ($media as $filename => $medium) { $medium->set('url', \Grav\Plugin\FlexObjects\Controllers\MediaProxyController::url($object, (string) $filename)); } } /** * [onTwigInitialized] Register the `flex_media_url()` Twig helper so templates * can link object media through the proxy while it is opt-in. */ public function onTwigInitialized(): void { $twig = $this->grav['twig']->twig(); $twig->addFunction(new \Twig\TwigFunction( 'flex_media_url', static function ($object, string $filename, ?string $field = null): string { if (!$object instanceof \Grav\Framework\Flex\Interfaces\FlexObjectInterface) { return ''; } return \Grav\Plugin\FlexObjects\Controllers\MediaProxyController::url($object, $filename, $field); } )); } /** * [onPageInitialized:10000] Authorize Flex Objects Page * * @param Event $event */ public function authorizePage(Event $event): void { /** @var PageInterface|null $page */ $page = $event['page']; if (!$page instanceof PageInterface) { return; } $header = $page->header(); $forms = $page->getForms(); // Update dynamic flex forms from the page. $form = null; foreach ($forms as $name => $test) { $type = $form['type'] ?? null; if ($type === 'flex') { $form = $test; // Update the form and add it back to the page. $this->grav->fireEvent('onBeforeFlexFormInitialize', new Event(['page' => $page, 'name' => $name, 'form' => &$form])); $page->addForms([$form], true); } } // Make sure the page contains flex. $config = $header->flex ?? null; if (!is_array($config) && !$form) { return; } /** @var Route $route */ $route = $this->grav['route']; $type = $form['flex']['type'] ?? $config['directory'] ?? $route->getGravParam('directory') ?? null; $key = $form['flex']['key'] ?? $config['id'] ?? $route->getGravParam('id') ?? ''; if (\is_string($type)) { /** @var Flex $flex */ $flex = $this->grav['flex_objects']; $directory = $flex->getDirectory($type); } else { $directory = null; } if (!$directory) { return; } $create = (bool)($form['actions']['create'] ?? false); $edit = (bool)($form['actions']['edit'] ?? false); $scope = $config['access']['scope'] ?? null; $object = $key !== '' ? $directory->getObject($key) : null; $hasAccess = null; $action = $config['access']['action'] ?? null; if (null === $action) { if (!$form) { $action = $key !== '' ? 'read' : 'list'; if (null === $scope) { $hasAccess = true; } } elseif ($object) { if ($edit) { $scope = $scope ?? 'admin'; $action = 'update'; } else { $hasAccess = false; } } elseif ($create) { $object = $directory->createObject([], $key); $scope = $scope ?? 'admin'; $action = 'create'; } else { $hasAccess = false; } } if ($action && $hasAccess === null) { if ($object instanceof FlexAuthorizeInterface) { $hasAccess = $object->isAuthorized($action, $scope); } else { $hasAccess = $directory->isAuthorized($action, $scope); } } if (!$hasAccess) { // Hide the page (404). $page->routable(false); $page->visible(false); // If page is not a module, replace the current page with unauthorized page. if (!$page->isModule()) { $login = $this->grav['login'] ?? null; $unauthorized = $login ? $login->addPage('unauthorized') : null; if ($unauthorized) { unset($this->grav['page']); $this->grav['page'] = $unauthorized; } } } elseif ($config['access']['override'] ?? false) { // Override page access settings (allow). $page->modifyHeader('access', []); } } /** * @param Event $event */ public function onPageTask(Event $event): void { /** @var FormInterface|null $form */ $form = $event['form'] ?? null; if (!$form instanceof FlexForm) { return; } $object = $form->getObject(); /** @var ServerRequestInterface $request */ $request = $event['request']; $request = $request ->withAttribute('type', $object->getFlexType()) ->withAttribute('key', $object->getKey()) ->withAttribute('object', $object) ->withAttribute('form', $form); $controller = new ObjectController(); $response = $controller->handle($request); if ($response->getStatusCode() !== 418) { $this->grav->close($response); } } /** * @param \Grav\Framework\Flex\Flex $flex * @param array $types * @param bool $report */ protected function registerDirectories(\Grav\Framework\Flex\Flex $flex, array $types, bool $report = false): void { $map = Flex::getLegacyBlueprintMap(false); foreach ($types as $blueprint) { // Backwards compatibility to v1.0.0-rc.3 $blueprint = $map[$blueprint] ?? $blueprint; $type = Utils::basename((string)$blueprint, '.yaml'); if (!$type) { continue; } if (!file_exists($blueprint)) { if ($report) { /** @var Debugger $debugger */ $debugger = Grav::instance()['debugger']; $debugger->addMessage(sprintf('Flex: blueprint for flex type %s is missing', $type), 'error'); } continue; } $directory = $flex->getDirectory($type); if (!$directory || !$directory->isEnabled()) { $flex->addDirectoryType($type, $blueprint); } } } /** * Initial stab at registering permissions (WIP) * * @param PermissionsRegisterEvent $event * @return void */ public function onRegisterPermissions(PermissionsRegisterEvent $event): void { /** @var Flex $flex */ $flex = $this->grav['flex_objects']; $directories = $flex->getDirectories(); $permissions = $event->permissions; $actions = []; foreach ($directories as $directory) { $data = $directory->getConfig('admin.permissions', []); $actions[] = PermissionsReader::fromArray($data, $permissions->getTypes()); } $actions[] = PermissionsReader::fromYaml("plugin://{$this->name}/permissions.yaml"); $permissions->addActions(array_replace(...$actions)); } /** * @param Event $event * @return void */ public function onFormRegisterTypes(Event $event): void { /** @var Forms $forms */ $forms = $event['forms']; $forms->registerType('flex', new FlexFormFactory()); } /** * [onShortcodeHandlers]: register the [flex-objects] / [flex] shortcode. * * Only fires when the Shortcode Core plugin is installed and active, so it * adds no hard dependency — without it the event simply never runs. * * @return void */ public function onShortcodeHandlers(): void { $this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/classes/shortcodes'); } /** * @param Event $event * @return void */ public function onAdminPage(Event $event): void { if ($this->controller->isActive()) { $event->stopPropagation(); /** @var PageInterface $page */ $page = $event['page']; $page->init(new \SplFileInfo(__DIR__ . '/admin/pages/flex-objects.md')); $page->slug($this->controller->getLocation()); $header = $page->header(); $header->access = ['admin.login']; $header->controller = $this->controller->getInfo(); } } /** * [onPageInitialized:0]: Run controller * * @return void */ public function onAdminPageInitialized(): void { if ($this->controller->isActive()) { $this->controller->execute(); $this->controller->redirect(); } } /** * @param Event $event * @return void */ public function onAdminControllerInit(Event $event): void { $eventController = $event['controller']; // Blacklist all admin routes, including aliases and redirects. $eventController->blacklist_views[] = 'flex-objects'; foreach ($this->controller->getAdminRoutes() as $route => $info) { $eventController->blacklist_views[] = trim($route, '/'); } } /** * Add Flex-Object's preset.scss to the Admin Preset SCSS compile process * * @param Event $event * @return void */ public function onAdminCompilePresetSCSS(Event $event): void { $event['scss']->add($this->grav['locator']->findResource('plugins://flex-objects/scss/_preset.scss')); } /** * @param Event $event * @return void */ public function onGetPageTemplates(Event $event): void { /** @var Types $types */ $types = $event->types; $types->register('flex-objects', 'plugins://flex-objects/blueprints/pages/flex-objects.yaml'); } /** * Form select options listing all enabled directories. * * @return array */ public static function directoryOptions(): array { /** @var Flex $flex */ $flex = Grav::instance()['flex_objects']; $directories = $flex->getDirectories(); $list = []; /** * @var string $type * @var FlexDirectory $directory */ foreach ($directories as $type => $directory) { if (!$directory->getConfig('site.hidden')) { $list[$type] = $directory->getTitle(); } } return $list; } /** * @return array */ public function getAdminMenu(): array { /** @var Flex $flex */ $flex = $this->grav['flex_objects']; $list = []; foreach ($flex->getAdminMenuItems() as $name => $item) { $route = trim($item['route'] ?? $name, '/'); $list[$route] = $item; } return $list; } /** * Add Flex Directory to admin menu * * @return void */ public function onAdminMenu(): void { /** @var Flex $flex */ $flex = $this->grav['flex_objects']; /** @var Admin $admin */ $admin = $this->grav['admin']; foreach ($this->getAdminMenu() as $route => $item) { $directory = null; if (isset($item['directory'])) { $directory = $flex->getDirectory($item['directory']); if (!$directory || !$directory->isEnabled()) { continue; } } $title = $item['title'] ?? 'PLUGIN_FLEX_OBJECTS.TITLE'; $index = $item['index'] ?? 0; if (($this->grav['twig']->plugins_hooked_nav[$title]['index'] ?? 1000) <= $index) { continue; } $location = $item['location'] ?? $route; $hidden = $item['hidden'] ?? false; $icon = $item['icon'] ?? 'fa-list'; $authorize = $item['authorize'] ?? ($directory ? null : ['admin.flex-objects', 'admin.super']); if ($hidden || (null === $authorize && $directory->isAuthorized('list', 'admin', $admin->user))) { continue; } $cache = $directory ? $directory->getCache('index') : null; $count = $cache ? $cache->get('admin-count-' . md5($admin->user->username)) : false; if (null === $count) { try { $collection = $directory->getCollection(); if (is_callable([$collection, 'isAuthorized'])) { $count = $collection->isAuthorized('list', 'admin', $admin->user)->count(); } else { $count = $collection->count(); } $cache->set('admin-count-' . md5($admin->user->username), $count); } catch (\InvalidArgumentException $e) { continue; } } $badge = $directory ? ['badge' => ['count' => $count]] : []; $priority = $item['priority'] ?? 0; $this->grav['twig']->plugins_hooked_nav[$title] = [ 'location' => $location, 'route' => $route, 'index' => $index, 'icon' => $icon, 'authorize' => $authorize, 'priority' => $priority ] + $badge; } } /** * Register API routes for admin-next. */ public function onApiRegisterRoutes(Event $event): void { if (!$this->config->get('plugins.flex-objects.enabled', true)) { return; } $routes = $event['routes']; $controller = \Grav\Plugin\FlexObjects\Api\FlexApiController::class; $blueprintController = \Grav\Plugin\FlexObjects\Api\FlexBlueprintController::class; // Config endpoint — static routes first (FastRoute constraint) $routes->get('/flex-objects/config', [$controller, 'config']); // Available blueprints (powers the plugin-settings `directories` field) $routes->get('/flex-objects/blueprints', [$controller, 'blueprints']); // Directory listing $routes->get('/flex-objects', [$controller, 'directories']); // CRUD routes — static paths before parameterized (FastRoute constraint) $routes->get('/flex-objects/{type}/export', [$controller, 'export']); $routes->get('/flex-objects/{type}', [$controller, 'index']); $routes->post('/flex-objects/{type}', [$controller, 'create']); $routes->get('/flex-objects/{type}/{key}', [$controller, 'show']); $routes->patch('/flex-objects/{type}/{key}', [$controller, 'update']); $routes->delete('/flex-objects/{type}/{key}', [$controller, 'delete']); // Object media — stored alongside the object file in folder-based // directories (e.g. user-data://flex-objects/contacts/{id}). $routes->get('/flex-objects/{type}/{key}/media', [$controller, 'mediaList']); $routes->post('/flex-objects/{type}/{key}/media', [$controller, 'mediaUpload']); $routes->delete('/flex-objects/{type}/{key}/media/{filename}', [$controller, 'mediaDelete']); // Blueprint endpoint $routes->get('/blueprints/flex-objects/{type}', [$blueprintController, 'flexBlueprint']); } /** * Inject the shared Flex configure tabs (Caching) into admin-next plugin * page blueprints owned by a plugin that registers a Flex directory. * * In admin-classic, FlexDirectory::getDirectoryBlueprint() automatically * merges system/blueprints/flex/shared/configure.yaml on top of each * directory's configure view, which is how the "Caching" tab appears in * the configure form. Admin-next plugin pages don't go through the Flex * configure pipeline — they're served by the API plugin's * BlueprintController::pluginPageBlueprint, which fires * onApiBlueprintResolved with context='plugin-page'. We hook that event * here and append the same Caching tab so the two UIs match. */ public function onApiBlueprintResolved(Event $event): void { if (($event['context'] ?? null) !== 'plugin-page') { return; } $plugin = (string) ($event['plugin'] ?? ''); if ($plugin === '' || !$this->pluginOwnsFlexDirectory($plugin)) { return; } $cacheTab = $this->buildSharedCacheTab(); if ($cacheTab === null) { return; } $fields = $event['fields']; $fields = $this->appendTab($fields, $cacheTab); $event['fields'] = $fields; } /** * Walk every registered Flex directory's blueprint path and check if it * lives under plugin://{$slug}/. If so, the plugin owns at least one * Flex directory and is a candidate for caching-tab injection. */ private function pluginOwnsFlexDirectory(string $slug): bool { $flex = $this->grav['flex_objects'] ?? null; if (!$flex) { return false; } /** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */ $locator = $this->grav['locator']; $pluginPath = $locator->findResource("plugin://{$slug}", true); if (!$pluginPath) { return false; } $pluginPath = rtrim($pluginPath, '/') . '/'; foreach ($flex->getDirectories() as $directory) { if (!$directory instanceof FlexDirectory) { continue; } $blueprintFile = $directory->getBlueprint()->getFilename(); if (!$blueprintFile) { continue; } $resolved = $locator->isStream($blueprintFile) ? $locator->findResource($blueprintFile, true) : $blueprintFile; if ($resolved && str_starts_with((string) $resolved, $pluginPath)) { return true; } } return false; } /** * Load system/blueprints/flex/shared/configure.yaml, isolate the * `cache` tab, and serialize it into the same shape the API plugin's * BlueprintController emits — so the result can be appended to a * resolved field list directly. */ private function buildSharedCacheTab(): ?array { /** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */ $locator = $this->grav['locator']; $path = $locator->findResource('blueprints://flex/shared/configure.yaml'); if (!$path) { return null; } $blueprint = new \Grav\Common\Data\Blueprint($path); $blueprint->load(); $form = $blueprint->form(); $cacheTab = $form['fields']['tabs']['fields']['cache'] ?? null; if (!is_array($cacheTab)) { return null; } // Translate language keys (best-effort — admin-next will also try). $language = $this->grav['language'] ?? null; $translate = static function ($value) use ($language) { if (!is_string($value) || $language === null) { return $value; } $translated = $language->translate($value); return is_string($translated) ? $translated : $value; }; return $this->serializeCacheTab($cacheTab, $translate); } /** * Minimal field serializer mirroring BlueprintController::serializeFields * for the property set the shared cache tab actually uses (toggle / text * with toggleable, options, validate, config-default@). We don't need * the full serializer here — the input is fixed and well-known. */ private function serializeCacheTab(array $cacheTab, callable $translate): array { $serialized = [ 'name' => 'cache', 'type' => 'tab', 'title' => $translate($cacheTab['title'] ?? 'Caching'), 'fields' => [], ]; foreach ($cacheTab['fields'] ?? [] as $name => $field) { if (!is_array($field)) { continue; } $entry = [ 'name' => (string) $name, 'type' => (string) ($field['type'] ?? 'text'), ]; foreach (['label', 'help', 'highlight', 'toggleable', 'default', 'size'] as $prop) { if (isset($field[$prop])) { $entry[$prop] = $field[$prop]; } } if (isset($entry['label'])) { $entry['label'] = $translate($entry['label']); } if (isset($field['options']) && is_array($field['options'])) { $ordered = []; foreach ($field['options'] as $optKey => $optLabel) { $ordered[] = ['value' => (string) $optKey, 'label' => $translate($optLabel)]; } $entry['options'] = $ordered; } if (isset($field['validate']) && is_array($field['validate'])) { $entry['validate'] = $field['validate']; } // Resolve config-default@ — read the system config value so the // form falls back to the system-wide cache defaults when the // plugin hasn't overridden them. if (isset($field['config-default@'])) { $key = (string) $field['config-default@']; $entry['default'] = $this->grav['config']->get($key); } $serialized['fields'][] = $entry; } return $serialized; } /** * Append a tab into the first `tabs` container we find in the serialized * fields list. If no tabs container exists, wrap the existing top-level * fields under a synthesized tabs container so the Caching tab still * has somewhere to live. */ private function appendTab(array $fields, array $tab): array { foreach ($fields as $i => $field) { if (($field['type'] ?? null) === 'tabs' && isset($field['fields']) && is_array($field['fields'])) { $fields[$i]['fields'][] = $tab; return $fields; } } return [ [ 'name' => 'tabs', 'type' => 'tabs', 'fields' => array_merge($fields, [$tab]), ], ]; } /** * Register sidebar items for admin-next. */ public function onApiSidebarItems(Event $event): void { if (!$this->config->get('plugins.flex-objects.enabled', true)) { return; } /** @var Flex $flex */ $flex = $this->grav['flex_objects']; $user = $event['user'] ?? null; $items = $event['items'] ?? []; // Skip built-in types that already have dedicated admin-next UI $builtIn = ['pages', 'user-accounts', 'user-groups']; // Accept either super-admin scope: admin-next sessions use api.super, // legacy admin-classic accounts use admin.super. $isSuperAdmin = $user && ($user->get('access.api.super') || $user->get('access.admin.super')); $language = $this->grav['language']; // We iterate every enabled directory, not just those returned by // getAdminMenuItems(). Directories whose blueprint omits `admin.menu` // get collapsed into a single empty-key entry by getAdminMenuItems() // and would otherwise never appear in the admin-next sidebar (admin // classic shows them via the Flex Objects index page instead). Pull // the menu config per-directory so we can synthesize a fallback when // it's absent. $menuItems = $flex->getAdminMenuItems(); // Track the sidebar ids we've already emitted so a directory can never // appear twice in the nav (issue #4122). A collection registered under // two type keys — e.g. a migrated site where it sits in both the // flex-objects `directories` config and a theme/plugin registration — // would otherwise yield two identical entries. Seed from any items // already on the event so a duplicate is dropped even if this handler // somehow runs more than once. $seen = []; foreach ($items as $existing) { if (isset($existing['id'])) { $seen[$existing['id']] = true; } } foreach ($flex->getDirectories() as $directory) { $type = $directory->getFlexType(); if (empty($type) || in_array($type, $builtIn, true) || !$directory->isEnabled()) { continue; } if (!empty($directory->getConfig('admin.disabled'))) { continue; } $id = 'flex-objects-' . $type; if (isset($seen[$id])) { continue; } $menuItem = $menuItems[$type] ?? null; // Plugins that ship their own admin-next sidebar entry can opt out // of the generic flex auto-registration via the menu blueprint. if ($menuItem && !empty($menuItem['hidden_in_admin_next'])) { continue; } // Check if user has list permission for this directory if ($user && !$isSuperAdmin && !$directory->isAuthorized('list', 'admin', $user)) { continue; } // Get object count $count = null; try { $count = $directory->getCollection()->count(); } catch (\Exception $e) { // Non-critical } // Derive the api permission key from the blueprint's permission config $perms = $directory->getConfig('admin.permissions', []); $authorizeKey = null; foreach ($perms as $prefix => $cfg) { if (str_starts_with($prefix, 'api.')) { $authorizeKey = $prefix . '.list'; break; } } // Fall back to the directory's own title/icon when the blueprint // doesn't define an admin.menu block (issue #209). This mirrors // admin-classic, which lists every enabled directory regardless. $rawLabel = $menuItem['title'] ?? $directory->getTitle(); $rawIcon = $menuItem['icon'] ?? $directory->getConfig('icon') ?? 'fa-database'; $seen[$id] = true; $items[] = [ 'id' => $id, 'plugin' => 'flex-objects', 'label' => $language->translate($rawLabel) ?: $rawLabel, 'icon' => $this->normalizeFaIcon($rawIcon), 'route' => '/flex-objects/' . $type, 'priority' => $menuItem['priority'] ?? 0, 'badge' => $count, 'authorize' => $authorizeKey, ]; } $event['items'] = $items; } /** * Normalize legacy FontAwesome 4 icon names (e.g. `fa-clock-o`) to the * FA5+ equivalents that admin-next renders. Falls back to the input. */ private function normalizeFaIcon(string $icon): string { // Strip any leading style prefix so we can normalize the bare name. $stripped = preg_replace('/^(fas|far|fab|fa-solid|fa-regular|fa-brands)\s+/', '', $icon) ?? $icon; if (str_ends_with($stripped, '-o')) { return substr($stripped, 0, -2); } return $stripped; } /** * Exclude Flex Directory data from the Data Manager plugin * * @return void */ public function onDataTypeExcludeFromDataManagerPluginHook(): void { $this->grav['admin']->dataTypesExcludedFromDataManagerPlugin[] = 'flex-objects'; } /** * Add current directory to twig lookup paths. * * @return void */ public function onTwigTemplatePaths(): void { $extra_site_twig_path = $this->config->get('plugins.flex-objects.extra_site_twig_path'); $extra_path = $extra_site_twig_path ? $this->grav['locator']->findResource($extra_site_twig_path) : null; if ($extra_path) { $this->grav['twig']->twig_paths[] = $extra_path; } $this->grav['twig']->twig_paths[] = __DIR__ . '/templates'; } /** * Add plugin templates path * * @param Event $event * @return void */ public function onAdminTwigTemplatePaths(Event $event): void { $extra_admin_twig_path = $this->config->get('plugins.flex-objects.extra_admin_twig_path'); $extra_path = $extra_admin_twig_path ? $this->grav['locator']->findResource($extra_admin_twig_path) : null; $paths = $event['paths']; if ($extra_path) { $paths[] = $extra_path; } $paths[] = __DIR__ . '/admin/templates'; $event['paths'] = $paths; } /** * Set needed variables to display directory. * * @return void */ public function onTwigAdminVariables(): void { if ($this->controller->isActive()) { // Twig shortcuts $this->grav['twig']->twig_vars['controller'] = $this->controller; $this->grav['twig']->twig_vars['action'] = $this->controller->getAction(); $this->grav['twig']->twig_vars['task'] = $this->controller->getTask(); $this->grav['twig']->twig_vars['target'] = $this->controller->getTarget(); $this->grav['twig']->twig_vars['key'] = $this->controller->getId(); $this->grav['twig']->twig_vars['flex'] = $this->grav['flex_objects']; $this->grav['twig']->twig_vars['directory'] = $this->controller->getDirectory(); $this->grav['twig']->twig_vars['collection'] = $this->controller->getCollection(); $this->grav['twig']->twig_vars['object'] = $this->controller->getObject(); // CSS / JS Assets $this->grav['assets']->addCss('plugin://flex-objects/css/admin.css'); $this->grav['assets']->addCss('plugin://admin/themes/grav/css/codemirror/codemirror.css'); } } }