TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Backend\Breadcrumb;
|
||||
|
||||
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
|
||||
use TYPO3\CMS\Core\Domain\RecordInterface;
|
||||
use TYPO3\CMS\Core\Resource\ResourceInterface;
|
||||
|
||||
/**
|
||||
* Represents a breadcrumb context with the main entity and optional suffix nodes.
|
||||
*
|
||||
* A breadcrumb context consists of:
|
||||
* - A main context (record or resource) that determines the base breadcrumb trail
|
||||
* - Optional suffix nodes that are appended after the main trail
|
||||
*
|
||||
* Suffix nodes are useful for:
|
||||
* - "New Record" indicators when creating records
|
||||
* - "Edit Multiple" indicators when editing multiple records
|
||||
* - Custom action indicators
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* // Edit existing record
|
||||
* $context = new BreadcrumbContext($record, []);
|
||||
*
|
||||
* // Create new record (shows: Pages > Parent Page > "Create New Content")
|
||||
* $suffixNode = new BreadcrumbNode(identifier: 'new', label: 'Create New Content');
|
||||
* $context = new BreadcrumbContext($parentPage, [$suffixNode]);
|
||||
*
|
||||
* @internal Subject to change until v15 LTS
|
||||
*/
|
||||
final readonly class BreadcrumbContext
|
||||
{
|
||||
/**
|
||||
* @param RecordInterface|ResourceInterface|null $mainContext The main entity (record or resource)
|
||||
* @param BreadcrumbNode[] $suffixNodes Additional nodes to append after the main breadcrumb trail
|
||||
*/
|
||||
public function __construct(
|
||||
public RecordInterface|ResourceInterface|null $mainContext,
|
||||
public array $suffixNodes = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Checks if this context has a valid main entity.
|
||||
*/
|
||||
public function hasContext(): bool
|
||||
{
|
||||
return $this->mainContext !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this context has suffix nodes.
|
||||
*/
|
||||
public function hasSuffixNodes(): bool
|
||||
{
|
||||
return $this->suffixNodes !== [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Backend\Breadcrumb;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
|
||||
use TYPO3\CMS\Backend\Utility\BackendUtility;
|
||||
use TYPO3\CMS\Core\Domain\RecordFactory;
|
||||
use TYPO3\CMS\Core\Domain\RecordInterface;
|
||||
use TYPO3\CMS\Core\Imaging\IconFactory;
|
||||
use TYPO3\CMS\Core\Localization\LanguageService;
|
||||
use TYPO3\CMS\Core\Resource\ResourceInterface;
|
||||
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
|
||||
|
||||
/**
|
||||
* Factory for creating breadcrumb contexts from controller actions.
|
||||
*
|
||||
* This factory centralizes the logic for determining what context to show
|
||||
* in breadcrumbs based on different controller actions (edit, new, list, etc.).
|
||||
*
|
||||
* It handles:
|
||||
* - Record lookups and validation
|
||||
* - Creation of "new record" breadcrumb nodes
|
||||
* - Multi-record edit scenarios
|
||||
* - Parent record resolution
|
||||
*
|
||||
* @internal Subject to change until v15 LTS
|
||||
*/
|
||||
final readonly class BreadcrumbFactory
|
||||
{
|
||||
public function __construct(
|
||||
private LoggerInterface $logger,
|
||||
private RecordFactory $recordFactory,
|
||||
private IconFactory $iconFactory,
|
||||
private TcaSchemaFactory $tcaSchemaFactory,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates breadcrumb context for editing an existing record.
|
||||
*
|
||||
* @param string $table The table name
|
||||
* @param int $uid The record UID
|
||||
* @return BreadcrumbContext Context containing the record or null on failure
|
||||
*/
|
||||
public function forEditAction(string $table, int $uid): BreadcrumbContext
|
||||
{
|
||||
$rawRecord = BackendUtility::getRecord($table, $uid);
|
||||
|
||||
if ($rawRecord === null) {
|
||||
$this->logger->warning(
|
||||
'Failed to load record for breadcrumb',
|
||||
['table' => $table, 'uid' => $uid]
|
||||
);
|
||||
return new BreadcrumbContext(null, []);
|
||||
}
|
||||
|
||||
try {
|
||||
$record = $this->recordFactory->createResolvedRecordFromDatabaseRow($table, $rawRecord);
|
||||
return new BreadcrumbContext($record, []);
|
||||
} catch (\Exception $e) {
|
||||
// @todo: Catching \Exception here is a code smell, this shouldn't be so generic and can hide away too many issues.
|
||||
$this->logger->error(
|
||||
'Failed to create record instance for breadcrumb',
|
||||
['table' => $table, 'uid' => $uid, 'exception' => $e->getMessage()]
|
||||
);
|
||||
return new BreadcrumbContext(null, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates breadcrumb context for editing multiple records.
|
||||
*
|
||||
* Shows a generic "Edit Multiple [RecordType]" node instead of individual records.
|
||||
*
|
||||
* @param string $table The table name
|
||||
* @param int $pid The parent page ID
|
||||
* @return BreadcrumbContext Context with parent page and "edit multiple" suffix node
|
||||
*/
|
||||
public function forEditMultipleAction(string $table, int $pid): BreadcrumbContext
|
||||
{
|
||||
$parentRecord = $this->getParentPageRecord($pid);
|
||||
$schema = $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null;
|
||||
|
||||
$recordTypeLabel = $schema?->getTitle($this->getLanguageService()->sL(...))
|
||||
?? $schema?->getTitle()
|
||||
?? $table;
|
||||
|
||||
$suffixNode = new BreadcrumbNode(
|
||||
identifier: 'edit-multiple-' . $table,
|
||||
label: sprintf(
|
||||
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editMultiple'),
|
||||
$recordTypeLabel
|
||||
),
|
||||
icon: $this->iconFactory->getIconForRecord($table, [])->getIdentifier(),
|
||||
);
|
||||
|
||||
return new BreadcrumbContext($parentRecord, [$suffixNode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates breadcrumb context for creating a new record.
|
||||
*
|
||||
* @param string $table The table name
|
||||
* @param int $pid The parent page ID
|
||||
* @param array $defaults Default values for the new record (used for icon overlay)
|
||||
* @return BreadcrumbContext Context with parent page and "create new" suffix node
|
||||
*/
|
||||
public function forNewAction(string $table, int $pid, array $defaults = []): BreadcrumbContext
|
||||
{
|
||||
$parentRecord = $this->getParentPageRecord($pid);
|
||||
$schema = $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null;
|
||||
|
||||
$recordTypeLabel = $schema?->getTitle($this->getLanguageService()->sL(...))
|
||||
?? $schema?->getTitle()
|
||||
?? $table;
|
||||
|
||||
try {
|
||||
$icon = $this->iconFactory->getIconForRecord($table, $defaults);
|
||||
$suffixNode = new BreadcrumbNode(
|
||||
identifier: 'new-' . $table,
|
||||
label: sprintf(
|
||||
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNew'),
|
||||
$recordTypeLabel
|
||||
),
|
||||
icon: $icon->getIdentifier(),
|
||||
iconOverlay: 'overlay-new',
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning(
|
||||
'Failed to create icon for new record breadcrumb',
|
||||
['table' => $table, 'exception' => $e->getMessage()]
|
||||
);
|
||||
$suffixNode = new BreadcrumbNode(
|
||||
identifier: 'new-' . $table,
|
||||
label: sprintf(
|
||||
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNew'),
|
||||
$recordTypeLabel
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new BreadcrumbContext($parentRecord, [$suffixNode]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates breadcrumb context from a page record array.
|
||||
*
|
||||
* Example:
|
||||
* `$view->getDocHeaderComponent()->setBreadcrumbContext($this->breadcrumbFactory->forPageArray($pageInfo));`
|
||||
*
|
||||
* @param array $pageRecord The page record array (must contain 'uid')
|
||||
* @return BreadcrumbContext Context with the page record or null on failure
|
||||
*/
|
||||
public function forPageArray(array $pageRecord): BreadcrumbContext
|
||||
{
|
||||
if (!isset($pageRecord['uid'])) {
|
||||
$this->logger->warning('Page record array must contain uid for breadcrumb');
|
||||
return new BreadcrumbContext(null, []);
|
||||
}
|
||||
|
||||
try {
|
||||
$record = $this->recordFactory->createResolvedRecordFromDatabaseRow('pages', $pageRecord);
|
||||
return new BreadcrumbContext($record, []);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(
|
||||
'Failed to create page record instance for breadcrumb',
|
||||
['uid' => $pageRecord['uid'], 'exception' => $e->getMessage()]
|
||||
);
|
||||
return new BreadcrumbContext(null, []);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates breadcrumb context for any resource (file or folder).
|
||||
*
|
||||
* @param ResourceInterface $resource The resource (file or folder)
|
||||
* @return BreadcrumbContext Context with the resource
|
||||
*/
|
||||
public function forResource(ResourceInterface $resource): BreadcrumbContext
|
||||
{
|
||||
return new BreadcrumbContext($resource, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent page record for a given PID.
|
||||
*
|
||||
* @param int $pid The page ID
|
||||
* @return RecordInterface|null The page record or null if not found/accessible
|
||||
*/
|
||||
private function getParentPageRecord(int $pid): ?RecordInterface
|
||||
{
|
||||
if ($pid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$rawRecord = BackendUtility::getRecord('pages', $pid);
|
||||
if ($rawRecord === null) {
|
||||
$this->logger->warning(
|
||||
'Failed to load parent page for breadcrumb',
|
||||
['pid' => $pid]
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->recordFactory->createResolvedRecordFromDatabaseRow('pages', $rawRecord);
|
||||
} catch (\Exception $e) {
|
||||
// @todo: Catching \Exception here is a code smell, this shouldn't be so generic and can hide away too many issues.
|
||||
$this->logger->error(
|
||||
'Failed to create page record instance for breadcrumb',
|
||||
['pid' => $pid, 'exception' => $e->getMessage()]
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function getLanguageService(): LanguageService
|
||||
{
|
||||
return $GLOBALS['LANG'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Backend\Breadcrumb;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
|
||||
|
||||
/**
|
||||
* Interface for breadcrumb providers that can generate breadcrumb trails
|
||||
* for different types of contexts (records, resources, etc.).
|
||||
*
|
||||
* Providers are responsible for:
|
||||
* - Determining if they can handle a given context
|
||||
* - Generating the appropriate breadcrumb node hierarchy
|
||||
* - Providing the target module identifier for navigation
|
||||
*
|
||||
* @internal Subject to change until v15 LTS
|
||||
*/
|
||||
interface BreadcrumbProviderInterface
|
||||
{
|
||||
/**
|
||||
* Determines whether this provider can handle the given context.
|
||||
*
|
||||
* @param BreadcrumbContext|null $context The breadcrumb context (can be null for virtual pages)
|
||||
*/
|
||||
public function supports(?BreadcrumbContext $context): bool;
|
||||
|
||||
/**
|
||||
* Generates breadcrumb nodes for the given context.
|
||||
*
|
||||
* @param BreadcrumbContext|null $context The breadcrumb context (can be null for virtual pages)
|
||||
* @param ServerRequestInterface|null $request The current request for module detection
|
||||
* @return BreadcrumbNode[] Array of breadcrumb nodes ordered from root to current
|
||||
*/
|
||||
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array;
|
||||
|
||||
/**
|
||||
* Returns the priority of this provider.
|
||||
*
|
||||
* Higher priority providers are checked first. Use this to override
|
||||
* default providers or to establish a specific order.
|
||||
*
|
||||
* @return int Priority (higher = checked first)
|
||||
*/
|
||||
public function getPriority(): int;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Backend\Breadcrumb;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
|
||||
use TYPO3\CMS\Backend\Module\ModuleInterface;
|
||||
use TYPO3\CMS\Backend\Module\ModuleResolver;
|
||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Core\Localization\LanguageService;
|
||||
use TYPO3\CMS\Core\Resource\StorageRepository;
|
||||
|
||||
/**
|
||||
* Breadcrumb provider for null contexts (virtual pages, empty states).
|
||||
*
|
||||
* Provides fallback breadcrumbs when no record or resource context is available,
|
||||
* such as virtual pages (e.g., id=0) or file storage roots.
|
||||
*
|
||||
* @internal This class is not part of TYPO3's public API.
|
||||
*/
|
||||
final readonly class NullContextBreadcrumbProvider implements BreadcrumbProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private ModuleResolver $moduleResolver,
|
||||
private StorageRepository $storageRepository,
|
||||
private UriBuilder $uriBuilder,
|
||||
) {}
|
||||
|
||||
public function supports(?BreadcrumbContext $context): bool
|
||||
{
|
||||
// This provider handles null contexts
|
||||
return $context === null || !$context->hasContext();
|
||||
}
|
||||
|
||||
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array
|
||||
{
|
||||
$breadcrumb = [];
|
||||
|
||||
$currentModule = $this->moduleResolver->resolveModule($request);
|
||||
if ($currentModule !== null) {
|
||||
// Add parent modules first (for third-level modules)
|
||||
$breadcrumb = $this->buildModuleHierarchy($currentModule);
|
||||
}
|
||||
|
||||
// Handle file storage tree
|
||||
if ($currentModule?->getNavigationComponent() === '@typo3/backend/tree/file-storage-tree-container') {
|
||||
$id = $request?->getQueryParams()['id'] ?? null;
|
||||
$label = $this->getLanguageService()->sL($currentModule->getTitle());
|
||||
$icon = 'apps-filetree-folder';
|
||||
|
||||
if ($id !== null && $storage = $this->storageRepository->findByCombinedIdentifier($id)) {
|
||||
$label = $storage->getName();
|
||||
if (!$storage->isOnline() || !$storage->isBrowsable()) {
|
||||
$icon = 'apps-filetree-folder-locked';
|
||||
}
|
||||
}
|
||||
|
||||
$breadcrumb[] = new BreadcrumbNode(
|
||||
identifier: (string)$id,
|
||||
label: $label,
|
||||
icon: $icon,
|
||||
);
|
||||
}
|
||||
|
||||
// Handle page tree (default for null context or no module)
|
||||
if ($currentModule === null || $currentModule->getNavigationComponent() === '@typo3/backend/tree/page-tree-element') {
|
||||
$breadcrumb[] = new BreadcrumbNode(
|
||||
identifier: '0',
|
||||
label: (string)$GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'],
|
||||
icon: 'apps-pagetree-root',
|
||||
);
|
||||
}
|
||||
|
||||
return $breadcrumb;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
// Low priority - only handles null contexts as fallback
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the module hierarchy including parent modules.
|
||||
*
|
||||
* For third-level modules, this returns [parent, current].
|
||||
* For second-level modules, this returns [current].
|
||||
* For standalone modules, this returns [current].
|
||||
*
|
||||
* @return BreadcrumbNode[]
|
||||
*/
|
||||
private function buildModuleHierarchy(ModuleInterface $currentModule): array
|
||||
{
|
||||
$modules = [];
|
||||
$moduleChain = [];
|
||||
|
||||
// Build chain from current to root
|
||||
$module = $currentModule;
|
||||
while ($module !== null) {
|
||||
$moduleChain[] = $module;
|
||||
$module = $module->getParentModule();
|
||||
}
|
||||
|
||||
// Reverse to get root-to-current order and skip the top-level parent (main menu item)
|
||||
$moduleChain = array_reverse($moduleChain);
|
||||
|
||||
// Skip the first item if we have more than one (first is the main menu container like "web")
|
||||
if (count($moduleChain) > 1) {
|
||||
array_shift($moduleChain);
|
||||
}
|
||||
|
||||
// Build breadcrumb nodes for each module in the chain
|
||||
foreach ($moduleChain as $module) {
|
||||
$modules[] = new BreadcrumbNode(
|
||||
identifier: $module->getIdentifier(),
|
||||
label: $this->getLanguageService()->sL($module->getTitle()),
|
||||
icon: $module->getIconIdentifier(),
|
||||
url: (string)$this->uriBuilder->buildUriFromRoute($module->getIdentifier(), $module->getNavigationComponent() === '@typo3/backend/tree/page-tree-element' ? ['id' => '0'] : []),
|
||||
forceShowIcon: true,
|
||||
);
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
private function getLanguageService(): LanguageService
|
||||
{
|
||||
return $GLOBALS['LANG'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Backend\Breadcrumb;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
|
||||
use TYPO3\CMS\Backend\Module\ModuleInterface;
|
||||
use TYPO3\CMS\Backend\Module\ModuleResolver;
|
||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Backend\Utility\BackendUtility;
|
||||
use TYPO3\CMS\Core\Domain\RecordInterface;
|
||||
use TYPO3\CMS\Core\Imaging\IconFactory;
|
||||
use TYPO3\CMS\Core\Imaging\IconSize;
|
||||
use TYPO3\CMS\Core\Localization\LanguageService;
|
||||
|
||||
/**
|
||||
* Breadcrumb provider for TYPO3 records (pages, content elements, etc.).
|
||||
*
|
||||
* Generates breadcrumb trails based on page rootlines and record hierarchies.
|
||||
*
|
||||
* @internal This class is not part of TYPO3's public API.
|
||||
*/
|
||||
final readonly class RecordBreadcrumbProvider implements BreadcrumbProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private IconFactory $iconFactory,
|
||||
private ModuleResolver $moduleResolver,
|
||||
private UriBuilder $uriBuilder,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function supports(?BreadcrumbContext $context): bool
|
||||
{
|
||||
return $context?->mainContext instanceof RecordInterface;
|
||||
}
|
||||
|
||||
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array
|
||||
{
|
||||
if ($context === null || !$context->mainContext instanceof RecordInterface) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$record = $context->mainContext;
|
||||
$breadcrumb = [];
|
||||
$currentModule = $this->moduleResolver->resolveModule($request);
|
||||
$showRootline = $this->shouldShowRootline($currentModule);
|
||||
$targetModule = $currentModule !== null
|
||||
? $this->extractRouteIdentifier($request, $currentModule)
|
||||
: $this->getTargetModule();
|
||||
|
||||
// Add module hierarchy (for third-level modules, this includes parent modules)
|
||||
if ($currentModule !== null) {
|
||||
$breadcrumb = array_merge($breadcrumb, $this->buildModuleHierarchy($currentModule, $request, $showRootline));
|
||||
} else {
|
||||
$breadcrumb[] = new BreadcrumbNode(
|
||||
identifier: '0',
|
||||
label: (string)$GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'],
|
||||
icon: 'apps-pagetree-root',
|
||||
url: (string)$this->uriBuilder->buildUriFromRoute($targetModule, $showRootline ? ['id' => '0'] : []),
|
||||
);
|
||||
}
|
||||
|
||||
// Add page rootline if applicable
|
||||
if ($showRootline) {
|
||||
$breadcrumb = array_merge($breadcrumb, $this->buildRootline($record, $targetModule));
|
||||
}
|
||||
|
||||
// Add the current record
|
||||
$breadcrumb[] = $this->buildRecordNode($record, $targetModule);
|
||||
|
||||
return $breadcrumb;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the target module identifier for navigation.
|
||||
*/
|
||||
private function getTargetModule(): string
|
||||
{
|
||||
// Default to web_layout for page-based navigation
|
||||
return 'web_layout';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the page rootline for a record.
|
||||
*
|
||||
* @return BreadcrumbNode[]
|
||||
*/
|
||||
private function buildRootline(RecordInterface $record, string $targetModule): array
|
||||
{
|
||||
$breadcrumb = [];
|
||||
$pid = $record->getPid();
|
||||
|
||||
try {
|
||||
$rootline = BackendUtility::BEgetRootLine($pid);
|
||||
if ($rootline === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Remove the site root (already added as first node)
|
||||
array_pop($rootline);
|
||||
ksort($rootline);
|
||||
|
||||
foreach ($rootline as $item) {
|
||||
if (!is_array($item) || !isset($item['uid'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$icon = $this->iconFactory->getIconForRecord('pages', $item, IconSize::SMALL);
|
||||
$breadcrumb[] = new BreadcrumbNode(
|
||||
identifier: (string)$item['uid'],
|
||||
label: BackendUtility::cropToTitleLength($item['title'] ?? ''),
|
||||
icon: $icon->getIdentifier(),
|
||||
iconOverlay: $icon->getOverlayIcon()?->getIdentifier(),
|
||||
url: (string)$this->uriBuilder->buildUriFromRoute($targetModule, ['id' => $item['uid']]),
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning(
|
||||
'Failed to create breadcrumb node for page',
|
||||
['uid' => $item['uid'], 'exception' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning(
|
||||
'Failed to build rootline for record',
|
||||
['table' => $record->getMainType(), 'uid' => $record->getUid(), 'exception' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
|
||||
return $breadcrumb;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a breadcrumb node for the current record.
|
||||
*/
|
||||
private function buildRecordNode(RecordInterface $record, string $targetModule): BreadcrumbNode
|
||||
{
|
||||
try {
|
||||
$icon = $this->iconFactory->getIconForRecord(
|
||||
$record->getMainType(),
|
||||
$record->getRawRecord()?->toArray(),
|
||||
IconSize::SMALL
|
||||
);
|
||||
|
||||
$recordTitle = BackendUtility::getRecordTitle($record->getMainType(), $record->getRawRecord()?->toArray());
|
||||
return new BreadcrumbNode(
|
||||
identifier: (string)$record->getUid(),
|
||||
label: BackendUtility::cropToTitleLength($recordTitle),
|
||||
icon: $icon->getIdentifier(),
|
||||
iconOverlay: $icon->getOverlayIcon()?->getIdentifier(),
|
||||
url: $record->getMainType() === 'pages' ? (string)$this->uriBuilder->buildUriFromRoute($targetModule, ['id' => (string)$record->getUid()]) : null,
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->error(
|
||||
'Failed to create breadcrumb node for record',
|
||||
['table' => $record->getMainType(), 'uid' => $record->getUid(), 'exception' => $e->getMessage()]
|
||||
);
|
||||
|
||||
// Return a minimal fallback node
|
||||
return new BreadcrumbNode(
|
||||
identifier: (string)$record->getUid(),
|
||||
label: $record->getMainType() . ' [' . $record->getUid() . ']',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the module hierarchy including parent modules.
|
||||
*
|
||||
* For third-level modules, this returns [parent, current].
|
||||
* For second-level modules, this returns [current].
|
||||
* For standalone modules, this returns [current].
|
||||
*
|
||||
* @return BreadcrumbNode[]
|
||||
*/
|
||||
private function buildModuleHierarchy(ModuleInterface $currentModule, ?ServerRequestInterface $request, bool $showRootline): array
|
||||
{
|
||||
$modules = [];
|
||||
$moduleChain = [];
|
||||
|
||||
// Build chain from current to root
|
||||
$module = $currentModule;
|
||||
while ($module !== null) {
|
||||
$moduleChain[] = $module;
|
||||
$module = $module->getParentModule();
|
||||
}
|
||||
|
||||
// Reverse to get root-to-current order and skip the top-level parent (main menu item)
|
||||
$moduleChain = array_reverse($moduleChain);
|
||||
|
||||
// Skip the first item if we have more than one (first is the main menu container like "web")
|
||||
if (count($moduleChain) > 1) {
|
||||
array_shift($moduleChain);
|
||||
}
|
||||
|
||||
// Build breadcrumb nodes for each module in the chain
|
||||
foreach ($moduleChain as $index => $module) {
|
||||
$isLastModule = $index === count($moduleChain) - 1;
|
||||
// For the last module (current), use the full route identifier to preserve route/action
|
||||
// For parent modules, use base module identifier
|
||||
$routeIdentifier = $isLastModule ? $this->extractRouteIdentifier($request, $module) : $module->getIdentifier();
|
||||
$modules[] = new BreadcrumbNode(
|
||||
identifier: $module->getIdentifier(),
|
||||
label: $this->getLanguageService()->sL($module->getTitle()),
|
||||
icon: $module->getIconIdentifier(),
|
||||
url: (string)$this->uriBuilder->buildUriFromRoute($routeIdentifier, $showRootline ? ['id' => '0'] : []),
|
||||
forceShowIcon: true,
|
||||
);
|
||||
}
|
||||
|
||||
return $modules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the rootline should be shown based on the current module.
|
||||
*
|
||||
* Modules using the page tree navigation component typically support page-based navigation.
|
||||
*/
|
||||
private function shouldShowRootline(?ModuleInterface $currentModule): bool
|
||||
{
|
||||
// @todo This is quite implicit, but using the page-tree-element as navigation component
|
||||
// signals that the current module can handle ?id= as a page parameter.
|
||||
return $currentModule === null || $currentModule->getNavigationComponent() === '@typo3/backend/tree/page-tree-element';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the route identifier from the current request.
|
||||
*
|
||||
* This returns the full route identifier (e.g., 'manage_search_index.Administration_externalDocuments')
|
||||
* to preserve sub-routes and actions in breadcrumb navigation.
|
||||
*
|
||||
* @return string The route identifier or module identifier as fallback
|
||||
*/
|
||||
private function extractRouteIdentifier(?ServerRequestInterface $request, ModuleInterface $module): string
|
||||
{
|
||||
// Try to get the full route identifier from routing attribute
|
||||
if ($request !== null
|
||||
&& ($routeResult = $request->getAttribute('routing')) !== null
|
||||
&& ($route = $routeResult->getRoute()) !== null
|
||||
&& !empty(($routeIdentifier = $route->getOption('_identifier')))
|
||||
) {
|
||||
return $routeIdentifier;
|
||||
}
|
||||
|
||||
// Fallback to module identifier
|
||||
return $module->getIdentifier();
|
||||
}
|
||||
|
||||
private function getLanguageService(): LanguageService
|
||||
{
|
||||
return $GLOBALS['LANG'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the TYPO3 CMS project.
|
||||
*
|
||||
* It is free software; you can redistribute it and/or modify it under
|
||||
* the terms of the GNU General Public License, either version 2
|
||||
* of the License, or any later version.
|
||||
*
|
||||
* For the full copyright and license information, please read the
|
||||
* LICENSE.txt file that was distributed with this source code.
|
||||
*
|
||||
* The TYPO3 project - inspiring people to share!
|
||||
*/
|
||||
|
||||
namespace TYPO3\CMS\Backend\Breadcrumb;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
|
||||
use TYPO3\CMS\Backend\Module\ModuleResolver;
|
||||
use TYPO3\CMS\Backend\Routing\UriBuilder;
|
||||
use TYPO3\CMS\Core\Imaging\IconFactory;
|
||||
use TYPO3\CMS\Core\Imaging\IconSize;
|
||||
use TYPO3\CMS\Core\Localization\LanguageService;
|
||||
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
|
||||
use TYPO3\CMS\Core\Resource\FileInterface;
|
||||
use TYPO3\CMS\Core\Resource\Folder;
|
||||
use TYPO3\CMS\Core\Resource\FolderInterface;
|
||||
use TYPO3\CMS\Core\Resource\ResourceInterface;
|
||||
|
||||
/**
|
||||
* Breadcrumb provider for FAL resources (files and folders).
|
||||
*
|
||||
* Generates breadcrumb trails based on folder hierarchies and storage structures.
|
||||
*
|
||||
* @internal This class is not part of TYPO3's public API.
|
||||
*/
|
||||
final readonly class ResourceBreadcrumbProvider implements BreadcrumbProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private IconFactory $iconFactory,
|
||||
private ModuleResolver $moduleResolver,
|
||||
private UriBuilder $uriBuilder,
|
||||
private LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function supports(?BreadcrumbContext $context): bool
|
||||
{
|
||||
return $context?->mainContext instanceof ResourceInterface;
|
||||
}
|
||||
|
||||
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array
|
||||
{
|
||||
if ($context === null || !$context->mainContext instanceof ResourceInterface) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$resource = $context->mainContext;
|
||||
$breadcrumb = [];
|
||||
$currentModule = $this->moduleResolver->resolveModule($request);
|
||||
|
||||
// Add module node
|
||||
if ($currentModule !== null) {
|
||||
$languageService = $this->getLanguageService();
|
||||
$breadcrumb[] = new BreadcrumbNode(
|
||||
identifier: $currentModule->getIdentifier(),
|
||||
label: $languageService->sL($currentModule->getTitle()),
|
||||
icon: $currentModule->getIconIdentifier(),
|
||||
iconOverlay: null,
|
||||
url: (string)$this->uriBuilder->buildUriFromRoute($currentModule->getIdentifier(), ['id' => '']),
|
||||
forceShowIcon: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Build resource hierarchy
|
||||
$resourceHierarchy = $this->buildResourceHierarchy($resource);
|
||||
|
||||
// Add resource nodes
|
||||
foreach ($resourceHierarchy as $item) {
|
||||
try {
|
||||
$icon = $this->iconFactory->getIconForResource($item, IconSize::SMALL);
|
||||
$label = $item->getName();
|
||||
$combinedIdentifier = $this->getCombinedIdentifier($item);
|
||||
|
||||
// Use storage name for root folder
|
||||
if ($item->getIdentifier() === $item->getStorage()->getRootLevelFolder()->getIdentifier()) {
|
||||
$label = $item->getStorage()->getName();
|
||||
}
|
||||
|
||||
$breadcrumb[] = new BreadcrumbNode(
|
||||
identifier: $combinedIdentifier,
|
||||
label: $label,
|
||||
icon: $icon->getIdentifier(),
|
||||
iconOverlay: $icon->getOverlayIcon()?->getIdentifier(),
|
||||
url: (string)$this->uriBuilder->buildUriFromRoute($this->getTargetModule(), ['id' => $combinedIdentifier]),
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning(
|
||||
'Failed to create breadcrumb node for resource',
|
||||
['identifier' => $item->getIdentifier(), 'exception' => $e->getMessage()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $breadcrumb;
|
||||
}
|
||||
|
||||
public function getPriority(): int
|
||||
{
|
||||
return 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the target module identifier for navigation.
|
||||
*/
|
||||
private function getTargetModule(): string
|
||||
{
|
||||
return 'media_management';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the resource hierarchy from root to current resource.
|
||||
*
|
||||
* @return ResourceInterface[]
|
||||
*/
|
||||
private function buildResourceHierarchy(ResourceInterface $resource): array
|
||||
{
|
||||
$hierarchy = [];
|
||||
$folder = null;
|
||||
|
||||
// Start with the resource itself
|
||||
if ($resource instanceof FileInterface) {
|
||||
$hierarchy[] = $resource;
|
||||
try {
|
||||
$folder = $resource->getParentFolder();
|
||||
} catch (\Exception $e) {
|
||||
$this->logger->warning(
|
||||
'Failed to get parent folder for file',
|
||||
['identifier' => $resource->getIdentifier(), 'exception' => $e->getMessage()]
|
||||
);
|
||||
return $hierarchy;
|
||||
}
|
||||
} elseif ($resource instanceof FolderInterface) {
|
||||
$folder = $resource;
|
||||
}
|
||||
|
||||
// Traverse up the folder hierarchy
|
||||
if ($folder instanceof Folder) {
|
||||
$currentFolder = $folder;
|
||||
$hierarchy[] = $folder;
|
||||
|
||||
// Walk up to the root folder
|
||||
$maxDepth = 100; // Safety limit to prevent infinite loops
|
||||
$depth = 0;
|
||||
|
||||
while ($depth < $maxDepth) {
|
||||
$depth++;
|
||||
|
||||
try {
|
||||
$parent = $currentFolder->getParentFolder();
|
||||
} catch (InsufficientFolderAccessPermissionsException $e) {
|
||||
// User doesn't have access to parent folder, stop here
|
||||
$this->logger->info(
|
||||
'Stopped breadcrumb traversal due to insufficient folder access',
|
||||
['folder' => $currentFolder->getCombinedIdentifier()]
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we've reached the root (parent points to itself)
|
||||
if ($parent->getCombinedIdentifier() === $currentFolder->getCombinedIdentifier()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Add parent to hierarchy and continue upwards
|
||||
$hierarchy[] = $parent;
|
||||
$currentFolder = $parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Reverse to get root-to-current order
|
||||
return array_reverse($hierarchy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the combined identifier for a resource.
|
||||
* Constructs it from storage UID and resource identifier.
|
||||
*/
|
||||
private function getCombinedIdentifier(ResourceInterface $resource): string
|
||||
{
|
||||
return $resource->getStorage()->getUid() . ':' . $resource->getIdentifier();
|
||||
}
|
||||
|
||||
private function getLanguageService(): LanguageService
|
||||
{
|
||||
return $GLOBALS['LANG'];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user