mirror of
https://github.com/shlinkio/shlink.git
synced 2026-02-28 04:03:12 +08:00
Merge pull request #2553 from acelaya-forks/visits-invokable-commands
Convert all visits list commands into invokable commands
This commit is contained in:
@@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||
Instead, if you have more than 1 proxy in front of Shlink, you should provide `TRUSTED_PROXIES` env var, with either a comma-separated list of the IP addresses of your proxies, or a number indicating how many proxies are there in front of Shlink.
|
||||
|
||||
* [#2540](https://github.com/shlinkio/shlink/issues/2540) Update Symfony packages to 8.0.
|
||||
* [#2512](https://github.com/shlinkio/shlink/issues/2512) Make all remaining console commands invokable.
|
||||
|
||||
### Deprecated
|
||||
* *Nothing*
|
||||
|
||||
@@ -44,8 +44,8 @@
|
||||
"shlinkio/shlink-common": "dev-main#f2550b5 as 7.3.0",
|
||||
"shlinkio/shlink-config": "dev-main#fb186e4 as 4.1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "dev-main#54d4701 as 4.4.0",
|
||||
"shlinkio/shlink-importer": "dev-main#4498f0a as 5.7.0",
|
||||
"shlinkio/shlink-installer": "dev-develop#40e08cb as 10.0.0",
|
||||
"shlinkio/shlink-importer": "dev-main#af03f6b as 5.7.0",
|
||||
"shlinkio/shlink-installer": "dev-develop#a225b16 as 10.0.0",
|
||||
"shlinkio/shlink-ip-geolocation": "dev-main#e0c45b2 as 5.0.0",
|
||||
"shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0",
|
||||
"spiral/roadrunner": "^2025.1",
|
||||
|
||||
@@ -4,42 +4,44 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Domain;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
|
||||
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Ask;
|
||||
use Symfony\Component\Console\Attribute\MapInput;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class GetDomainVisitsCommand extends AbstractVisitsListCommand
|
||||
#[AsCommand(GetDomainVisitsCommand::NAME, 'Returns the list of visits for provided domain')]
|
||||
class GetDomainVisitsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'domain:visits';
|
||||
|
||||
public function __construct(
|
||||
VisitsStatsHelperInterface $visitsHelper,
|
||||
private readonly VisitsStatsHelperInterface $visitsHelper,
|
||||
private readonly ShortUrlStringifierInterface $shortUrlStringifier,
|
||||
) {
|
||||
parent::__construct($visitsHelper);
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Returns the list of visits for provided domain.')
|
||||
->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.');
|
||||
}
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('The domain which visits we want to get'), Ask('For what domain do you want to get visits?')]
|
||||
string $domain,
|
||||
#[MapInput] VisitsDateRangeInput $dateRangeInput,
|
||||
): int {
|
||||
$paginator = $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRangeInput->toDateRange()));
|
||||
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$domain = $input->getArgument('domain');
|
||||
return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange));
|
||||
ShlinkTable::default($io)->render($headers, $rows);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,62 +4,45 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
|
||||
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Ask;
|
||||
use Symfony\Component\Console\Attribute\MapInput;
|
||||
use Symfony\Component\Console\Attribute\Option;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
||||
#[AsCommand(GetShortUrlVisitsCommand::NAME, 'Returns the detailed visits information for provided short code')]
|
||||
class GetShortUrlVisitsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'short-url:visits';
|
||||
|
||||
private ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
protected function configure(): void
|
||||
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Returns the detailed visits information for provided short code');
|
||||
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
|
||||
$this,
|
||||
shortCodeDesc: 'The short code which visits we want to get.',
|
||||
domainDesc: 'The domain for the short code.',
|
||||
);
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$shortCode = $this->shortUrlIdentifierInput->shortCode($input);
|
||||
if (! empty($shortCode)) {
|
||||
return;
|
||||
}
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('The short code which visits we want to get'), Ask('Which short code do you want to use?')]
|
||||
string $shortCode,
|
||||
#[MapInput] VisitsDateRangeInput $dateRangeInput,
|
||||
#[Option('The domain for the short code', shortcut: 'd')]
|
||||
string|null $domain = null,
|
||||
): int {
|
||||
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
|
||||
$dateRange = $dateRangeInput->toDateRange();
|
||||
$paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
|
||||
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, static fn () => []);
|
||||
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$shortCode = $io->ask('A short code was not provided. Which short code do you want to use?');
|
||||
if (! empty($shortCode)) {
|
||||
$input->setArgument('shortCode', $shortCode);
|
||||
}
|
||||
}
|
||||
ShlinkTable::default($io)->render($headers, $rows);
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function mapExtraFields(Visit $visit): array
|
||||
{
|
||||
return [];
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,61 +4,60 @@ declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
|
||||
use Shlinkio\Shlink\CLI\Input\DomainOption;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
|
||||
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\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Attribute\Argument;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Attribute\Ask;
|
||||
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 GetTagVisitsCommand extends AbstractVisitsListCommand
|
||||
#[AsCommand(GetTagVisitsCommand::NAME, 'Returns the list of visits for provided tag')]
|
||||
class GetTagVisitsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'tag:visits';
|
||||
|
||||
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 visits for provided tag.')
|
||||
->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$tag = $input->getArgument('tag');
|
||||
return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
|
||||
dateRange: $dateRange,
|
||||
domain: $this->domainOption->get($input),
|
||||
public function __invoke(
|
||||
SymfonyStyle $io,
|
||||
#[Argument('The tag which visits we want to get'), Ask('For what tag do you want to get visits')] string $tag,
|
||||
#[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->visitsForTag($tag, 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)];
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\Visit;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\EndDateOption;
|
||||
use Shlinkio\Shlink\CLI\Input\StartDateOption;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
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
|
||||
{
|
||||
private readonly StartDateOption $startDateOption;
|
||||
private readonly EndDateOption $endDateOption;
|
||||
|
||||
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->startDateOption = new StartDateOption($this, 'visits');
|
||||
$this->endDateOption = new EndDateOption($this, 'visits');
|
||||
}
|
||||
|
||||
final protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$startDate = $this->startDateOption->get($input, $output);
|
||||
$endDate = $this->endDateOption->get($input, $output);
|
||||
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
|
||||
[$rows, $headers] = $this->resolveRowsAndHeaders($paginator);
|
||||
|
||||
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>
|
||||
*/
|
||||
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
abstract protected function mapExtraFields(Visit $visit): array;
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -4,64 +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\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
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 Shlinkio\Shlink\Core\enumToString;
|
||||
use function sprintf;
|
||||
|
||||
class GetOrphanVisitsCommand extends AbstractVisitsListCommand
|
||||
#[AsCommand(GetOrphanVisitsCommand::NAME, 'Returns the list of orphan visits')]
|
||||
class GetOrphanVisitsCommand extends Command
|
||||
{
|
||||
public const string NAME = 'visit:orphan';
|
||||
|
||||
private readonly DomainOption $domainOption;
|
||||
|
||||
public function __construct(VisitsStatsHelperInterface $visitsHelper)
|
||||
public function __construct(private readonly VisitsStatsHelperInterface $visitsHelper)
|
||||
{
|
||||
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 orphan visits.')
|
||||
->addOption('type', 't', InputOption::VALUE_REQUIRED, sprintf(
|
||||
'Return visits only with this type. One of %s',
|
||||
enumToString(OrphanVisitType::class),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Paginator<Visit>
|
||||
*/
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$rawType = $input->getOption('type');
|
||||
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
|
||||
return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
|
||||
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,
|
||||
#[Option('Return visits only with this type', shortcut: 't')] OrphanVisitType|null $type = null,
|
||||
): int {
|
||||
$paginator = $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
|
||||
dateRange: $dateRangeInput->toDateRange(),
|
||||
domain: $domain,
|
||||
type: $type,
|
||||
));
|
||||
[$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
|
||||
{
|
||||
return ['type' => $visit->type->value];
|
||||
}
|
||||
|
||||
48
module/CLI/src/Command/Visit/VisitsCommandUtils.php
Normal file
48
module/CLI/src/Command/Visit/VisitsCommandUtils.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
|
||||
use function is_string;
|
||||
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
|
||||
use function sprintf;
|
||||
|
||||
readonly class DateOption
|
||||
{
|
||||
public function __construct(private Command $command, private string $name, string $shortcut, string $description)
|
||||
{
|
||||
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): Chronos|null
|
||||
{
|
||||
$value = $input->getOption($this->name);
|
||||
if (empty($value) || ! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeOptionalDate($value);
|
||||
} catch (Throwable $e) {
|
||||
$output->writeln(sprintf(
|
||||
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
|
||||
$this->name,
|
||||
$value,
|
||||
));
|
||||
|
||||
if ($output->isVeryVerbose()) {
|
||||
$this->command->getApplication()?->renderThrowable($e, $output);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
final readonly class DomainOption
|
||||
{
|
||||
private const string NAME = 'domain';
|
||||
|
||||
public function __construct(Command $command, string $description)
|
||||
{
|
||||
$command->addOption(
|
||||
name: self::NAME,
|
||||
shortcut: 'd',
|
||||
mode: InputOption::VALUE_REQUIRED,
|
||||
description: $description,
|
||||
);
|
||||
}
|
||||
|
||||
public function get(InputInterface $input): string|null
|
||||
{
|
||||
return $input->getOption(self::NAME);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class EndDateOption
|
||||
{
|
||||
private DateOption $dateOption;
|
||||
|
||||
public function __construct(Command $command, string $descriptionHint)
|
||||
{
|
||||
$this->dateOption = new DateOption($command, 'end-date', 'e', sprintf(
|
||||
'Allows to filter %s, returning only those newer than provided date.',
|
||||
$descriptionHint,
|
||||
));
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): Chronos|null
|
||||
{
|
||||
return $this->dateOption->get($input, $output);
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
final readonly class ShortUrlIdentifierInput
|
||||
{
|
||||
public function __construct(Command $command, string $shortCodeDesc, string $domainDesc)
|
||||
{
|
||||
$command
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc)
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc);
|
||||
}
|
||||
|
||||
public function shortCode(InputInterface $input): string|null
|
||||
{
|
||||
return $input->getArgument('shortCode');
|
||||
}
|
||||
|
||||
public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier
|
||||
{
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$domain = $input->getOption('domain');
|
||||
|
||||
return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class StartDateOption
|
||||
{
|
||||
private DateOption $dateOption;
|
||||
|
||||
public function __construct(Command $command, string $descriptionHint)
|
||||
{
|
||||
$this->dateOption = new DateOption($command, 'start-date', 's', sprintf(
|
||||
'Allows to filter %s, returning only those older than provided date.',
|
||||
$descriptionHint,
|
||||
));
|
||||
}
|
||||
|
||||
public function get(InputInterface $input, OutputInterface $output): Chronos|null
|
||||
{
|
||||
return $this->dateOption->get($input, $output);
|
||||
}
|
||||
}
|
||||
28
module/CLI/src/Input/VisitsDateRangeInput.php
Normal file
28
module/CLI/src/Input/VisitsDateRangeInput.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||
use function sprintf;
|
||||
|
||||
class GetShortUrlVisitsCommandTest extends TestCase
|
||||
{
|
||||
@@ -47,7 +46,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
|
||||
new VisitsParams(DateRange::allTime()),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$this->commandTester->execute(['short-code' => $shortCode]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
@@ -75,34 +74,12 @@ class GetShortUrlVisitsCommandTest extends TestCase
|
||||
)->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'shortCode' => $shortCode,
|
||||
'short-code' => $shortCode,
|
||||
'--start-date' => $startDate,
|
||||
'--end-date' => $endDate,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function providingInvalidDatesPrintsWarning(): void
|
||||
{
|
||||
$shortCode = 'abc123';
|
||||
$startDate = 'foo';
|
||||
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
|
||||
new VisitsParams(DateRange::allTime()),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([])));
|
||||
|
||||
$this->commandTester->execute([
|
||||
'shortCode' => $shortCode,
|
||||
'--start-date' => $startDate,
|
||||
]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertStringContainsString(
|
||||
sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate),
|
||||
$output,
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function outputIsProperlyGenerated(): void
|
||||
{
|
||||
@@ -115,7 +92,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
|
||||
$this->anything(),
|
||||
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
|
||||
|
||||
$this->commandTester->execute(['shortCode' => $shortCode]);
|
||||
$this->commandTester->execute(['short-code' => $shortCode]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(
|
||||
|
||||
Reference in New Issue
Block a user