Convert GetNonOrphanVisitsCommand to invokable command

This commit is contained in:
Alejandro Celaya
2025-12-17 15:22:56 +01:00
parent f9b1f0ebf4
commit 66d35968f4
6 changed files with 110 additions and 70 deletions

View File

@@ -15,11 +15,7 @@ use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function array_keys;
use function array_map;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
abstract class AbstractVisitsListCommand extends Command
{
@@ -38,44 +34,13 @@ abstract class AbstractVisitsListCommand extends Command
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
[$rows, $headers] = $this->resolveRowsAndHeaders($paginator);
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
ShlinkTable::default($output)->render($headers, $rows);
return self::SUCCESS;
}
/**
* @param Paginator<Visit> $paginator
*/
private function resolveRowsAndHeaders(Paginator $paginator): array
{
$extraKeys = [];
$rows = array_map(function (Visit $visit) use (&$extraKeys) {
$extraFields = $this->mapExtraFields($visit);
$extraKeys = array_keys($extraFields);
$rowData = [
'referer' => $visit->referer,
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
...$extraFields,
];
// Filter out unknown keys
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
}, [...$paginator->getCurrentPageResults()]);
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys);
return [
$rows,
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
];
}
/**
* @return Paginator<Visit>
*/

View File

@@ -17,7 +17,7 @@ use function sprintf;
#[AsCommand(
DownloadGeoLiteDbCommand::NAME,
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so.',
'Checks if the GeoLite2 db file is too old or it does not exist, and tries to download an up-to-date copy if so',
)]
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
{

View File

@@ -4,57 +4,56 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\DomainOption;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
#[AsCommand(GetNonOrphanVisitsCommand::NAME, 'Returns the list of non-orphan visits')]
class GetNonOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:non-orphan';
private readonly DomainOption $domainOption;
public function __construct(
VisitsStatsHelperInterface $visitsHelper,
private readonly VisitsStatsHelperInterface $visitsHelper,
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
) {
parent::__construct($visitsHelper);
$this->domainOption = new DomainOption($this, sprintf(
'Return visits that belong to this domain only. Use %s keyword for visits in default domain',
Domain::DEFAULT_AUTHORITY,
));
parent::__construct();
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the list of non-orphan visits.');
}
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
dateRange: $dateRange,
domain: $this->domainOption->get($input),
public function __invoke(
SymfonyStyle $io,
#[MapInput] VisitsDateRangeInput $dateRangeInput,
#[Option(
'Return visits that belong to this domain only. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword for visits '
. 'in default domain',
shortcut: 'd',
)]
string|null $domain = null,
): int {
$paginator = $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
dateRange: $dateRangeInput->toDateRange(),
domain: $domain,
));
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
ShlinkTable::default($io)->render($headers, $rows);
return self::SUCCESS;
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
private function mapExtraFields(Visit $visit): array
{
$shortUrl = $visit->shortUrl;
return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)];

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use function array_keys;
use function array_map;
use function Shlinkio\Shlink\Core\ArrayUtils\select_keys;
use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly;
class VisitsCommandUtils
{
/**
* @param Paginator<Visit> $paginator
* @param callable(Visit $visits): array<string, string> $mapExtraFields
*/
public static function resolveRowsAndHeaders(Paginator $paginator, callable $mapExtraFields): array
{
$extraKeys = [];
$rows = array_map(function (Visit $visit) use (&$extraKeys, $mapExtraFields) {
$extraFields = $mapExtraFields($visit);
$extraKeys = array_keys($extraFields);
$rowData = [
'referer' => $visit->referer,
'date' => $visit->date->toAtomString(),
'userAgent' => $visit->userAgent,
'potentialBot' => $visit->potentialBot,
'country' => $visit->getVisitLocation()->countryName ?? 'Unknown',
'city' => $visit->getVisitLocation()->cityName ?? 'Unknown',
...$extraFields,
];
// Filter out unknown keys
return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]);
}, [...$paginator->getCurrentPageResults()]);
$extra = array_map(camelCaseToHumanFriendly(...), $extraKeys);
return [
$rows,
['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Common\Util\DateRange;
use Symfony\Component\Console\Attribute\Option;
use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
class VisitsDateRangeInput
{
#[Option('Only return visits older than this date', shortcut: 's')]
public string|null $startDate = null;
#[Option('Only return visits newer than this date', shortcut: 'e')]
public string|null $endDate = null;
public function toDateRange(): DateRange
{
return buildDateRange(
startDate: normalizeOptionalDate($this->startDate),
endDate: normalizeOptionalDate($this->endDate),
);
}
}