Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions Classes/Backend/SortUrlBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace B13\AiLabel\Backend;

/*
* This file is part of TYPO3 CMS-based extension "ai_label" by b13.
*
* 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.
*/

use B13\AiLabel\Domain\Repository\AiLabelDemand;
use TYPO3\CMS\Backend\Routing\UriBuilder;

/**
* Builds the asc/desc sort URLs per sortable column, preserving all active
* filters. Coupled directly to AiLabelDemand rather than a shared interface:
* this extension has exactly one sortable listing, so a generic abstraction
* would be speculative.
*/
final class SortUrlBuilder
{
public function __construct(
private readonly UriBuilder $uriBuilder,
) {
}

/**
* @return array<string, array{ascUrl: string, descUrl: string, active: bool, direction: string}>
*/
public function build(AiLabelDemand $demand, string $route): array
{
$filterParams = [];
foreach ($demand->getParameters() as $key => $value) {
$filterParams['demand[' . $key . ']'] = $value;
}

$sortUrls = [];
foreach (AiLabelDemand::getOrderFields() as $field) {
$isActive = $demand->getOrderField() === $field;
$sortUrls[$field] = [
'ascUrl' => (string)$this->uriBuilder->buildUriFromRoute($route, array_merge($filterParams, [
'orderField' => $field,
'orderDirection' => AiLabelDemand::ORDER_ASCENDING,
])),
'descUrl' => (string)$this->uriBuilder->buildUriFromRoute($route, array_merge($filterParams, [
'orderField' => $field,
'orderDirection' => AiLabelDemand::ORDER_DESCENDING,
])),
'active' => $isActive,
'direction' => $isActive ? $demand->getOrderDirection() : '',
];
}
return $sortUrls;
}
}
93 changes: 59 additions & 34 deletions Classes/Controller/AiLabelOverviewController.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,82 +12,107 @@
* of the License, or any later version.
*/

use B13\AiLabel\Backend\SortUrlBuilder;
use B13\AiLabel\Domain\Repository\AiLabelDemand;
use B13\AiLabel\Domain\Repository\AiMetadataRecordFinder;
use B13\AiLabel\Pagination\DemandedArrayPaginator;
use B13\AiLabel\Service\AiMetadataBadgeFactory;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\Buttons\Action\ShortcutButton;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Pagination\ArrayPaginator;
use TYPO3\CMS\Core\Pagination\SlidingWindowPagination;
use TYPO3\CMS\Core\Pagination\SimplePagination;
use TYPO3\CMS\Core\Utility\GeneralUtility;

#[AsController]
final class AiLabelOverviewController
{
private const ITEMS_PER_PAGE = 25;
private const MAX_NUMBER_OF_LINKS = 7;
private const MODULE_IDENTIFIER = 'web_ai_label_overview';

public function __construct(
private readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly AiMetadataRecordFinder $recordFinder,
private readonly AiMetadataBadgeFactory $badgeFactory,
private readonly UriBuilder $uriBuilder,
private readonly PageRenderer $pageRenderer,
private readonly SortUrlBuilder $sortUrlBuilder,
) {
}

public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$this->pageRenderer->addCssFile('EXT:ai_label/Resources/Public/Css/ai-label.css');
$currentPageNumber = max(1, (int)($request->getQueryParams()['currentPage'] ?? 1));
$view = $this->moduleTemplateFactory->create($request);
$languageService = $this->getLanguageService();
$view->setTitle($languageService->sL('LLL:EXT:ai_label/Resources/Private/Language/locallang_mod.xlf:mlang_tabs_tab'));

$returnUrl = (string)$request->getUri();
$records = array_map(
fn (array $record) => [
$shortcutButton = GeneralUtility::makeInstance(ShortcutButton::class)
->setRouteIdentifier(self::MODULE_IDENTIFIER)
->setDisplayName($languageService->sL('LLL:EXT:ai_label/Resources/Private/Language/locallang_mod.xlf:mlang_tabs_tab'));
$view->getDocHeaderComponent()->getButtonBar()->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT);

$demand = AiLabelDemand::fromRequest($request);
$allRecords = $this->recordFinder->findFlaggedRecords();
$statistics = $this->recordFinder->calculateStatistics($allRecords);
$tables = $this->recordFinder->getDistinctTables($allRecords);

$matchingRecords = $this->recordFinder->filterAndSort($allRecords, $demand);
$totalCount = count($matchingRecords);
$pageItems = array_slice($matchingRecords, ($demand->getPage() - 1) * $demand->getLimit(), $demand->getLimit());

$returnUrl = $this->buildOverviewUrl($demand);
$pageItems = array_map(
fn (array $record): array => [
...$record,
'reviewBadge' => $this->badgeFactory->getBadge($record['metadata'], $this->buildEditUrl($record['table'], $record['uid'], $returnUrl)),
],
$this->recordFinder->findFlaggedRecords()
$pageItems,
);

$paginator = new ArrayPaginator($records, $currentPageNumber, self::ITEMS_PER_PAGE);
$pagination = new SlidingWindowPagination($paginator, self::MAX_NUMBER_OF_LINKS);
$paginator = new DemandedArrayPaginator($pageItems, $demand->getPage(), $demand->getLimit(), $totalCount);
$pagination = new SimplePagination($paginator);
$paginationBaseUrl = (string)$this->uriBuilder->buildUriFromRoute(self::MODULE_IDENTIFIER, $this->demandToRouteParams($demand));

$moduleTemplate = $this->moduleTemplateFactory->create($request);
$moduleTemplate->setTitle(
$this->getLanguageService()->sL('LLL:EXT:ai_label/Resources/Private/Language/locallang_mod.xlf:mlang_tabs_tab')
);
$moduleTemplate->assignMultiple([
$view->assignMultiple([
'demand' => $demand,
// Filters are submitted via POST (Overview/Filters.html), so they never
// show up in the request's own URI. It must be rebuilt from the parsed
// demand instead, the same way $paginationBaseUrl is.
'returnUrl' => $returnUrl,
'paginationBaseUrl' => $paginationBaseUrl,
'sortUrls' => $this->sortUrlBuilder->build($demand, self::MODULE_IDENTIFIER),
'paginator' => $paginator,
'pagination' => $pagination,
'pageUris' => $this->buildPageUris($pagination),
'previousPageUri' => $this->buildPageUri($pagination->getPreviousPageNumber()),
'nextPageUri' => $this->buildPageUri($pagination->getNextPageNumber()),
'statistics' => $statistics,
'tables' => $tables,
]);

return $moduleTemplate->renderResponse('Overview/Index');
return $view->renderResponse('Overview/Index');
}

/** @return array<int, string> */
private function buildPageUris(SlidingWindowPagination $pagination): array
/**
* @return array<string, string>
*/
private function demandToRouteParams(AiLabelDemand $demand): array
{
$uris = [];
foreach ($pagination->getAllPageNumbers() as $pageNumber) {
$uris[$pageNumber] = (string)$this->buildPageUri($pageNumber);
$params = [
'orderField' => $demand->getOrderField(),
'orderDirection' => $demand->getOrderDirection(),
];
foreach ($demand->getParameters() as $key => $value) {
$params['demand[' . $key . ']'] = $value;
}
return $uris;
return $params;
}

private function buildPageUri(?int $pageNumber): ?string
private function buildOverviewUrl(AiLabelDemand $demand): string
{
if ($pageNumber === null) {
return null;
}
return (string)$this->uriBuilder->buildUriFromRoute(self::MODULE_IDENTIFIER, ['currentPage' => $pageNumber]);
return (string)$this->uriBuilder->buildUriFromRoute(
self::MODULE_IDENTIFIER,
array_merge($this->demandToRouteParams($demand), ['page' => $demand->getPage()]),
);
}

private function buildEditUrl(string $table, int $uid, string $returnUrl): string
Expand Down
183 changes: 183 additions & 0 deletions Classes/Domain/Repository/AiLabelDemand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
<?php

declare(strict_types=1);

namespace B13\AiLabel\Domain\Repository;

/*
* This file is part of TYPO3 CMS-based extension "ai_label" by b13.
*
* 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.
*/

use Psr\Http\Message\ServerRequestInterface;

/**
* Filter/sort/pagination state for the AI Label overview module. Plain,
* immutable value object parsed once from the request, then threaded
* through the repository, SortUrlBuilder, and pagination base URL builder.
*/
final class AiLabelDemand
{
public const ORDER_ASCENDING = 'asc';
public const ORDER_DESCENDING = 'desc';
private const DEFAULT_ORDER_FIELD = 'reviewed';

/**
* Records needing review bubble to the top by default. reviewed=false
* sorts before reviewed=true ascending, which fits this module's whole
* purpose better than an alphabetical table-name default would.
*/
private const ORDER_FIELDS = ['table', 'title', 'author', 'origin', 'reviewed'];

private const ORIGIN_VALUES = ['created', 'modified'];
private const REVIEW_STATUS_VALUES = ['required', 'reviewed'];

private int $limit = 25;

public function __construct(
private int $page = 1,
private string $orderField = self::DEFAULT_ORDER_FIELD,
private string $orderDirection = self::ORDER_ASCENDING,
private string $table = '',
private string $origin = '',
private string $reviewStatus = '',
private string $search = '',
) {
if (!in_array($orderField, self::ORDER_FIELDS, true)) {
$orderField = self::DEFAULT_ORDER_FIELD;
}
$this->orderField = $orderField;
if (!in_array($orderDirection, [self::ORDER_ASCENDING, self::ORDER_DESCENDING], true)) {
$orderDirection = self::ORDER_ASCENDING;
}
$this->orderDirection = $orderDirection;
if (!in_array($origin, self::ORIGIN_VALUES, true)) {
$this->origin = '';
}
if (!in_array($reviewStatus, self::REVIEW_STATUS_VALUES, true)) {
$this->reviewStatus = '';
}
$this->page = max(1, $page);
}

public static function fromRequest(ServerRequestInterface $request): self
{
$page = (int)($request->getQueryParams()['page'] ?? $request->getParsedBody()['page'] ?? 1);
$orderField = (string)($request->getQueryParams()['orderField'] ?? $request->getParsedBody()['orderField'] ?? self::DEFAULT_ORDER_FIELD);
$orderDirection = (string)($request->getQueryParams()['orderDirection'] ?? $request->getParsedBody()['orderDirection'] ?? self::ORDER_ASCENDING);
$demand = $request->getQueryParams()['demand'] ?? $request->getParsedBody()['demand'] ?? [];
if (!is_array($demand)) {
$demand = [];
}

return new self(
$page,
$orderField,
$orderDirection,
(string)($demand['table'] ?? ''),
(string)($demand['origin'] ?? ''),
(string)($demand['review_status'] ?? ''),
trim((string)($demand['search'] ?? '')),
);
}

/**
* @return list<string>
*/
public static function getOrderFields(): array
{
return self::ORDER_FIELDS;
}

public function getPage(): int
{
return $this->page;
}

public function getLimit(): int
{
return $this->limit;
}

public function getOrderField(): string
{
return $this->orderField;
}

public function getOrderDirection(): string
{
return $this->orderDirection;
}

public function getTable(): string
{
return $this->table;
}

public function hasTable(): bool
{
return $this->table !== '';
}

public function getOrigin(): string
{
return $this->origin;
}

public function hasOrigin(): bool
{
return $this->origin !== '';
}

public function getReviewStatus(): string
{
return $this->reviewStatus;
}

public function hasReviewStatus(): bool
{
return $this->reviewStatus !== '';
}

public function getSearch(): string
{
return $this->search;
}

public function hasSearch(): bool
{
return $this->search !== '';
}

public function hasConstraints(): bool
{
return $this->hasTable() || $this->hasOrigin() || $this->hasReviewStatus() || $this->hasSearch();
}

/**
* Active filter parameters (not sorting/paging), reused by SortUrlBuilder
* and the pagination base URL so neither one drops the current filter.
*
* @return array<string, string>
*/
public function getParameters(): array
{
$parameters = [];
if ($this->hasTable()) {
$parameters['table'] = $this->table;
}
if ($this->hasOrigin()) {
$parameters['origin'] = $this->origin;
}
if ($this->hasReviewStatus()) {
$parameters['review_status'] = $this->reviewStatus;
}
if ($this->hasSearch()) {
$parameters['search'] = $this->search;
}
return $parameters;
}
}
Loading