From 66d35968f49422d8d0fb0b9185631fb740330c07 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:22:56 +0100 Subject: [PATCH 1/7] Convert GetNonOrphanVisitsCommand to invokable command --- composer.json | 4 +- .../Visit/AbstractVisitsListCommand.php | 37 +---------- .../Visit/DownloadGeoLiteDbCommand.php | 2 +- .../Visit/GetNonOrphanVisitsCommand.php | 61 +++++++++---------- .../src/Command/Visit/VisitsCommandUtils.php | 48 +++++++++++++++ module/CLI/src/Input/VisitsDateRangeInput.php | 28 +++++++++ 6 files changed, 110 insertions(+), 70 deletions(-) create mode 100644 module/CLI/src/Command/Visit/VisitsCommandUtils.php create mode 100644 module/CLI/src/Input/VisitsDateRangeInput.php diff --git a/composer.json b/composer.json index eeda898b..8d82756f 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index 5916fc52..0bce0feb 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -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 $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 */ diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index f76a4dbc..e3e98fc6 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -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 { diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 1b40d55e..6291d6db 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -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 - */ - 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 */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { $shortUrl = $visit->shortUrl; return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; diff --git a/module/CLI/src/Command/Visit/VisitsCommandUtils.php b/module/CLI/src/Command/Visit/VisitsCommandUtils.php new file mode 100644 index 00000000..55f425c7 --- /dev/null +++ b/module/CLI/src/Command/Visit/VisitsCommandUtils.php @@ -0,0 +1,48 @@ + $paginator + * @param callable(Visit $visits): array $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], + ]; + } +} diff --git a/module/CLI/src/Input/VisitsDateRangeInput.php b/module/CLI/src/Input/VisitsDateRangeInput.php new file mode 100644 index 00000000..189fa0b7 --- /dev/null +++ b/module/CLI/src/Input/VisitsDateRangeInput.php @@ -0,0 +1,28 @@ +startDate), + endDate: normalizeOptionalDate($this->endDate), + ); + } +} From aecc36a46359d4bb17e69c9e45be459f96de0d12 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:27:39 +0100 Subject: [PATCH 2/7] Convert GetOrphanVisitsCommand into invokable command --- .../Command/Visit/GetOrphanVisitsCommand.php | 70 ++++++++----------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index 0804215a..5640361f 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -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 - */ - 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 */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { return ['type' => $visit->type->value]; } From ce7f334326d3d262de80e5a99e82b389b2a83416 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:32:21 +0100 Subject: [PATCH 3/7] Convert GetTagVisitsCommand into invokable command --- .../src/Command/Tag/GetTagVisitsCommand.php | 69 +++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index bac12ac2..7db1b7e1 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -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 - */ - 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 */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { $shortUrl = $visit->shortUrl; return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; From e265e55917200089eb5c19fbf7a61df087dae713 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:35:27 +0100 Subject: [PATCH 4/7] Convert GetDomainVisitsCommand into invokable command --- .../Command/Domain/GetDomainVisitsCommand.php | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index 2891c44f..c2b8d859 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -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 - */ - 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; } /** From c6b83a64379189d642acb25038e3a7bf92179e68 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:43:14 +0100 Subject: [PATCH 5/7] Convert GetShortUrlVisitsCommand into invokable command --- .../ShortUrl/GetShortUrlVisitsCommand.php | 68 ++++++++----------- .../Visit/AbstractVisitsListCommand.php | 53 --------------- module/CLI/src/Input/DateOption.php | 48 ------------- module/CLI/src/Input/DomainOption.php | 29 -------- module/CLI/src/Input/EndDateOption.php | 30 -------- .../CLI/src/Input/ShortUrlIdentifierInput.php | 34 ---------- module/CLI/src/Input/StartDateOption.php | 30 -------- .../ShortUrl/GetShortUrlVisitsCommandTest.php | 29 +------- 8 files changed, 33 insertions(+), 288 deletions(-) delete mode 100644 module/CLI/src/Command/Visit/AbstractVisitsListCommand.php delete mode 100644 module/CLI/src/Input/DateOption.php delete mode 100644 module/CLI/src/Input/DomainOption.php delete mode 100644 module/CLI/src/Input/EndDateOption.php delete mode 100644 module/CLI/src/Input/ShortUrlIdentifierInput.php delete mode 100644 module/CLI/src/Input/StartDateOption.php diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 8507b9ca..83347319 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -4,61 +4,53 @@ 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\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\Entity\Visit; 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, $this->mapExtraFields(...)); - $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 - */ - protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator - { - $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); - return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); + return self::SUCCESS; } /** * @return array */ - protected function mapExtraFields(Visit $visit): array + private function mapExtraFields(Visit $visit): array { return []; } diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php deleted file mode 100644 index 0bce0feb..00000000 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ /dev/null @@ -1,53 +0,0 @@ -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] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); - - ShlinkTable::default($output)->render($headers, $rows); - - return self::SUCCESS; - } - - /** - * @return Paginator - */ - abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator; - - /** - * @return array - */ - abstract protected function mapExtraFields(Visit $visit): array; -} diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php deleted file mode 100644 index 05c9de94..00000000 --- a/module/CLI/src/Input/DateOption.php +++ /dev/null @@ -1,48 +0,0 @@ -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( - '> Ignored provided "%s" since its value "%s" is not a valid date. <', - $this->name, - $value, - )); - - if ($output->isVeryVerbose()) { - $this->command->getApplication()?->renderThrowable($e, $output); - } - - return null; - } - } -} diff --git a/module/CLI/src/Input/DomainOption.php b/module/CLI/src/Input/DomainOption.php deleted file mode 100644 index e7a15f52..00000000 --- a/module/CLI/src/Input/DomainOption.php +++ /dev/null @@ -1,29 +0,0 @@ -addOption( - name: self::NAME, - shortcut: 'd', - mode: InputOption::VALUE_REQUIRED, - description: $description, - ); - } - - public function get(InputInterface $input): string|null - { - return $input->getOption(self::NAME); - } -} diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php deleted file mode 100644 index a38b9b32..00000000 --- a/module/CLI/src/Input/EndDateOption.php +++ /dev/null @@ -1,30 +0,0 @@ -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); - } -} diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php deleted file mode 100644 index 46ac79da..00000000 --- a/module/CLI/src/Input/ShortUrlIdentifierInput.php +++ /dev/null @@ -1,34 +0,0 @@ -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); - } -} diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php deleted file mode 100644 index 453b31a2..00000000 --- a/module/CLI/src/Input/StartDateOption.php +++ /dev/null @@ -1,30 +0,0 @@ -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); - } -} diff --git a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index e306b0bc..3fd53c48 100644 --- a/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -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( From d0ee6e549bc0f357f95ed5ffb283ad20aecb3d7f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:46:15 +0100 Subject: [PATCH 6/7] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4c36b8..beffec7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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* From a774778822bc8fa9868345f5abcf402ff07e65cc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 17 Dec 2025 15:48:05 +0100 Subject: [PATCH 7/7] Remove unecessary method from GetShortUrlVisitsCommand --- .../src/Command/ShortUrl/GetShortUrlVisitsCommand.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 83347319..38d9e371 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -8,7 +8,6 @@ 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\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Symfony\Component\Console\Attribute\Argument; @@ -40,18 +39,10 @@ class GetShortUrlVisitsCommand extends Command $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); $dateRange = $dateRangeInput->toDateRange(); $paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); - [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...)); + [$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, static fn () => []); ShlinkTable::default($io)->render($headers, $rows); return self::SUCCESS; } - - /** - * @return array - */ - private function mapExtraFields(Visit $visit): array - { - return []; - } }