diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml
index b45fa6c2..3a6a8642 100644
--- a/.github/actions/ci-setup/action.yml
+++ b/.github/actions/ci-setup/action.yml
@@ -43,5 +43,5 @@ runs:
coverage: xdebug
- name: Install dependencies
if: ${{ inputs.install-deps == 'yes' }}
- run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.5' && '--ignore-platform-req=php' || '' }}
+ run: composer install --no-interaction --prefer-dist
shell: bash
diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml
index 28ec4fd6..639481b8 100644
--- a/.github/workflows/ci-db-tests.yml
+++ b/.github/workflows/ci-db-tests.yml
@@ -14,7 +14,6 @@ jobs:
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
- continue-on-error: ${{ matrix.php-version == '8.5' }}
env:
LC_ALL: C
steps:
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 1b196ac1..1ee23377 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -14,7 +14,6 @@ jobs:
strategy:
matrix:
php-version: ['8.3', '8.4', '8.5']
- continue-on-error: ${{ matrix.php-version == '8.5' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically
steps:
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index a6b923b4..61fc6940 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
- php-version: ['8.3', '8.4']
+ php-version: ['8.3', '8.4', '8.5']
steps:
- uses: actions/checkout@v4
- uses: './.github/actions/ci-setup'
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bc198d6..c5a3a245 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,45 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
+## [4.6.0] - 2025-11-01
+### Added
+* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URL lists by those not including certain tags.
+
+ Now, the `GET /short-urls` endpoint accepts two new params: `excludeTags`, which is an array of strings with the tags that should not be included, and `excludeTagsMode`, which accepts the values `any` and `all`, and determines if short URLs should be filtered out if they contain any of the excluded tags, or all the excluded tags.
+
+ Additionally, the `short-url:list` command also supports the same feature via `--exclude-tag` option, which requires a value and can be provided multiple times, and `--exclude-tags-all`, which does not expect a value and determines if the mode should be `all`, or `any`.
+
+* [#2192](https://github.com/shlinkio/shlink/issues/2192) Allow filtering short URL lists by the API key that was used to create them.
+
+ Now, the `GET /short-urls` endpoint accepts a new `apiKeyName` param, which is ignored if the request is performed with a non-admin API key which name does not match the one provided here.
+
+ Additionally, the `short-url:list` command also supports the same feature via the `--api-key-name` option.
+
+* [#2330](https://github.com/shlinkio/shlink/issues/2330) Add support to serve Shlink with FrankenPHP, by providing a worker script in `bin/frankenphp-worker.php`.
+
+* [#2449](https://github.com/shlinkio/shlink/issues/2449) Add support to provide redis credentials separately when using redis sentinels, where provided servers are the sentinels and not the redis instances.
+
+ For this, Shlink supports two new env ras / config options, as `REDIS_SERVERS_USER` and `REDIS_SERVERS_PASSWORD`.
+
+* [#2498](https://github.com/shlinkio/shlink/issues/2498) Allow orphan visits, non-orphan visits and tag visits lists to be filtered by domain.
+
+ This is done via the `domain` query parameter in API endpoints, and via the `--domain` option in console commands.
+
+* [#2472](https://github.com/shlinkio/shlink/issues/2472) Add support for PHP 8.5
+
+### Changed
+* [#2424](https://github.com/shlinkio/shlink/issues/2424) Make simple console commands invokable.
+
+### Deprecated
+* *Nothing*
+
+### Removed
+* *Nothing*
+
+### Fixed
+* *Nothing*
+
+
## [4.5.3] - 2025-10-10
### Added
* *Nothing*
diff --git a/README.md b/README.md
index e061467d..dc23d7f6 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,12 @@ Both the API and CLI allow you to do mostly the same operations, except for API
If you are trying to find out how to run the project in development mode or how to provide contributions, read the [CONTRIBUTING](CONTRIBUTING.md) doc.
+## Powered by
+
+Thanks to [JetBrains](https://www.jetbrains.com/) for their continuous support to this project in the form of IDE licenses.
+
+
+
---
> This product includes GeoLite2 data created by MaxMind, available from [https://www.maxmind.com](https://www.maxmind.com)
diff --git a/composer.json b/composer.json
index f04124d4..dc851009 100644
--- a/composer.json
+++ b/composer.json
@@ -20,9 +20,9 @@
"ext-pdo": "*",
"akrabat/ip-address-middleware": "^2.6",
"cakephp/chronos": "^3.1",
- "doctrine/dbal": "^4.2",
- "doctrine/migrations": "^3.8",
- "doctrine/orm": "^3.3",
+ "doctrine/dbal": "^4.3",
+ "doctrine/migrations": "^3.9",
+ "doctrine/orm": "^3.5",
"donatj/phpuseragentparser": "^1.10",
"endroid/qr-code": "^6.0.5",
"friendsofphp/proxy-manager-lts": "^1.0",
@@ -43,11 +43,11 @@
"pagerfanta/core": "^3.8",
"ramsey/uuid": "^4.7",
"shlinkio/doctrine-specification": "^2.2",
- "shlinkio/shlink-common": "^7.1",
+ "shlinkio/shlink-common": "^7.2",
"shlinkio/shlink-config": "^4.0",
"shlinkio/shlink-event-dispatcher": "^4.3",
"shlinkio/shlink-importer": "^5.6",
- "shlinkio/shlink-installer": "^9.6",
+ "shlinkio/shlink-installer": "^9.7",
"shlinkio/shlink-ip-geolocation": "^4.4",
"shlinkio/shlink-json": "^1.2",
"spiral/roadrunner": "^2025.1",
diff --git a/config/autoload/cache.global.php b/config/autoload/cache.global.php
index 6ae37de7..670a6a61 100644
--- a/config/autoload/cache.global.php
+++ b/config/autoload/cache.global.php
@@ -11,6 +11,8 @@ return (static function (): array {
'redis' => [
'servers' => $redisServers,
'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(),
+ 'username' => EnvVars::REDIS_SERVERS_USER->loadFromEnv(),
+ 'password' => EnvVars::REDIS_SERVERS_PASSWORD->loadFromEnv(),
],
];
diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php
index 7f9e8900..a3918aff 100644
--- a/config/autoload/installer.global.php
+++ b/config/autoload/installer.global.php
@@ -33,6 +33,8 @@ return [
Option\Cache\CacheNamespaceConfigOption::class,
Option\Redis\RedisServersConfigOption::class,
Option\Redis\RedisSentinelServiceConfigOption::class,
+ Option\Redis\RedisServersUserConfigOption::class,
+ Option\Redis\RedisServersPasswordConfigOption::class,
Option\Redis\RedisPubSubConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json
index 6ca05c2e..1a58ef14 100644
--- a/docs/swagger/paths/v1_short-urls.json
+++ b/docs/swagger/paths/v1_short-urls.json
@@ -31,7 +31,7 @@
{
"name": "searchTerm",
"in": "query",
- "description": "A query used to filter results by searching for it on the longUrl and shortCode fields. (Since v1.3.0)",
+ "description": "A query used to filter results by searching for it on the longUrl and shortCode fields.",
"required": false,
"schema": {
"type": "string"
@@ -40,7 +40,7 @@
{
"name": "tags[]",
"in": "query",
- "description": "A list of tags used to filter the result set. Only short URLs tagged with at least one of the provided tags will be returned. (Since v1.3.0)",
+ "description": "A list of tags used to filter the result set. Only short URLs **with** these tags will be returned.",
"required": false,
"schema": {
"type": "array",
@@ -52,7 +52,29 @@
{
"name": "tagsMode",
"in": "query",
- "description": "Tells how the filtering by tags should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. It's ignored if no tags are provided, and defaults to \"any\" if not provided.",
+ "description": "Tells how the filtering by `tags` should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. Defaults to \"any\".
It's ignored if `tags` is not provided.",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": ["any", "all"]
+ }
+ },
+ {
+ "name": "excludeTags[]",
+ "in": "query",
+ "description": "A list of tags used to filter the result set. Only short URLs **without** these tags will be returned.",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "name": "excludeTagsMode",
+ "in": "query",
+ "description": "Tells how the filtering by `excludeTags` should work, returning short URLs not containing \"any\" of the tags, or not containing \"all\" the tags. Defaults to \"any\".
It's ignored if `excludeTags` is not provided.",
"required": false,
"schema": {
"type": "string",
@@ -134,6 +156,15 @@
"schema": {
"type": "string"
}
+ },
+ {
+ "name": "apiKeyName",
+ "in": "query",
+ "description": "Only get short URLs created with this API key.
This value is **ignored** if the request is performed with a non-admin API key that does not match this name.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
}
],
"security": [
diff --git a/docs/swagger/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json
index 1f3dabf2..70cfc6e2 100644
--- a/docs/swagger/paths/v2_tags_{tag}_visits.json
+++ b/docs/swagger/paths/v2_tags_{tag}_visits.json
@@ -64,6 +64,10 @@
"type": "string",
"enum": ["true"]
}
+ },
+ {
+ "$ref": "../parameters/domain.json",
+ "description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [
diff --git a/docs/swagger/paths/v2_visits_non-orphan.json b/docs/swagger/paths/v2_visits_non-orphan.json
index 65b11252..3579030a 100644
--- a/docs/swagger/paths/v2_visits_non-orphan.json
+++ b/docs/swagger/paths/v2_visits_non-orphan.json
@@ -55,6 +55,10 @@
"type": "string",
"enum": ["true"]
}
+ },
+ {
+ "$ref": "../parameters/domain.json",
+ "description": "Return visits for short URLs that belong to this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [
diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json
index df2ee0cd..1653e36a 100644
--- a/docs/swagger/paths/v2_visits_orphan.json
+++ b/docs/swagger/paths/v2_visits_orphan.json
@@ -65,6 +65,10 @@
"type": "string",
"enum": ["invalid_short_url", "base_url", "regular_404"]
}
+ },
+ {
+ "$ref": "../parameters/domain.json",
+ "description": "Return only visits for this domain. Use **DEFAULT** keyword to return visits from default domain."
}
],
"security": [
diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php
index a554db40..669a3788 100644
--- a/module/CLI/config/cli.config.php
+++ b/module/CLI/config/cli.config.php
@@ -26,6 +26,7 @@ return [
Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class,
Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class,
+ Command\Api\DeleteKeyCommand::NAME => Command\Api\DeleteKeyCommand::class,
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Api\InitialApiKeyCommand::NAME => Command\Api\InitialApiKeyCommand::class,
Command\Api\RenameApiKeyCommand::NAME => Command\Api\RenameApiKeyCommand::class,
diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php
index 5df74822..b4bf15d2 100644
--- a/module/CLI/config/dependencies.config.php
+++ b/module/CLI/config/dependencies.config.php
@@ -52,6 +52,7 @@ return [
Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class,
+ Command\Api\DeleteKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Api\InitialApiKeyCommand::class => ConfigAbstractFactory::class,
Command\Api\RenameApiKeyCommand::class => ConfigAbstractFactory::class,
@@ -108,6 +109,7 @@ return [
Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class],
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
+ Command\Api\DeleteKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Api\InitialApiKeyCommand::class => [ApiKeyService::class],
Command\Api\RenameApiKeyCommand::class => [ApiKeyService::class],
diff --git a/module/CLI/src/Command/Api/DeleteKeyCommand.php b/module/CLI/src/Command/Api/DeleteKeyCommand.php
new file mode 100644
index 00000000..f57a5e4a
--- /dev/null
+++ b/module/CLI/src/Command/Api/DeleteKeyCommand.php
@@ -0,0 +1,94 @@
+%command.name% command allows you to delete an existing API key via its name.
+
+ If no arguments are provided, you will be prompted to select one of the existing API keys.
+
+ %command.full_name%
+
+ You can optionally pass the API key name to be disabled:
+
+ %command.full_name% the_key_name
+
+ HELP,
+)]
+class DeleteKeyCommand extends Command
+{
+ public const string NAME = 'api-key:delete';
+
+ public function __construct(private readonly ApiKeyServiceInterface $apiKeyService)
+ {
+ parent::__construct();
+ }
+
+ protected function interact(InputInterface $input, OutputInterface $output): void
+ {
+ $apiKeyName = $input->getArgument('name');
+
+ if ($apiKeyName === null) {
+ $apiKeys = $this->apiKeyService->listKeys();
+ $name = (new SymfonyStyle($input, $output))->choice(
+ 'What API key do you want to delete?',
+ map($apiKeys, static fn (ApiKey $apiKey) => $apiKey->name),
+ );
+
+ $input->setArgument('name', $name);
+ }
+ }
+
+ public function __invoke(
+ SymfonyStyle $io,
+ InputInterface $input,
+ #[Argument(description: 'The API key to delete.')]
+ string|null $name = null,
+ ): int {
+ if ($name === null) {
+ $io->warning('An API key name was not provided.');
+ return Command::INVALID;
+ }
+
+ if (! $this->shouldProceed($io, $input)) {
+ return Command::INVALID;
+ }
+
+ try {
+ $this->apiKeyService->deleteByName($name);
+ $io->success(sprintf('API key "%s" properly deleted', $name));
+ return Command::SUCCESS;
+ } catch (ApiKeyNotFoundException $e) {
+ $io->error($e->getMessage());
+ return Command::FAILURE;
+ }
+ }
+
+ private function shouldProceed(SymfonyStyle $io, InputInterface $input): bool
+ {
+ if (! $input->isInteractive()) {
+ return true;
+ }
+
+ $io->warning('You are about to delete an API key. This action cannot be undone.');
+ return $io->confirm('Are you sure you want to delete the API key?');
+ }
+}
diff --git a/module/CLI/src/Command/Api/InitialApiKeyCommand.php b/module/CLI/src/Command/Api/InitialApiKeyCommand.php
index 66968eb3..680135d8 100644
--- a/module/CLI/src/Command/Api/InitialApiKeyCommand.php
+++ b/module/CLI/src/Command/Api/InitialApiKeyCommand.php
@@ -5,11 +5,15 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+#[AsCommand(
+ name: InitialApiKeyCommand::NAME,
+ description: 'Tries to create initial API key',
+)]
class InitialApiKeyCommand extends Command
{
public const string NAME = 'api-key:initial';
@@ -19,22 +23,14 @@ class InitialApiKeyCommand extends Command
parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setHidden()
- ->setName(self::NAME)
- ->setDescription('Tries to create initial API key')
- ->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create');
- }
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The initial API to create')] string $apiKey,
+ ): int {
+ $result = $this->apiKeyService->createInitial($apiKey);
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $key = $input->getArgument('apiKey');
- $result = $this->apiKeyService->createInitial($key);
-
- if ($result === null && $output->isVerbose()) {
- $output->writeln('Other API keys already exist. Initial API key creation skipped.');
+ if ($result === null && $io->isVerbose()) {
+ $io->writeln('Other API keys already exist. Initial API key creation skipped.');
}
return Command::SUCCESS;
diff --git a/module/CLI/src/Command/Config/ReadEnvVarCommand.php b/module/CLI/src/Command/Config/ReadEnvVarCommand.php
index e1cef3fd..e3a38be6 100644
--- a/module/CLI/src/Command/Config/ReadEnvVarCommand.php
+++ b/module/CLI/src/Command/Config/ReadEnvVarCommand.php
@@ -6,9 +6,10 @@ namespace Shlinkio\Shlink\CLI\Command\Config;
use Closure;
use Shlinkio\Shlink\Core\Config\EnvVars;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Exception\InvalidArgumentException;
-use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -18,6 +19,11 @@ use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
use function sprintf;
+#[AsCommand(
+ name: ReadEnvVarCommand::NAME,
+ description: 'Display current value for an env var',
+ hidden: true,
+)]
class ReadEnvVarCommand extends Command
{
public const string NAME = 'env-var:read';
@@ -31,19 +37,10 @@ class ReadEnvVarCommand extends Command
parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setHidden()
- ->setDescription('Display current value for an env var')
- ->addArgument('envVar', InputArgument::REQUIRED, 'The env var to read');
- }
-
protected function interact(InputInterface $input, OutputInterface $output): void
{
$io = new SymfonyStyle($input, $output);
- $envVar = $input->getArgument('envVar');
+ $envVar = $input->getArgument('env-var');
$validEnvVars = enumValues(EnvVars::class);
if ($envVar === null) {
@@ -54,14 +51,14 @@ class ReadEnvVarCommand extends Command
throw new InvalidArgumentException(sprintf('%s is not a valid Shlink environment variable', $envVar));
}
- $input->setArgument('envVar', $envVar);
+ $input->setArgument('env-var', $envVar);
}
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $envVar = $input->getArgument('envVar');
- $output->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
-
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument(description: 'The env var to read')] string $envVar,
+ ): int {
+ $io->writeln(formatEnvVarValue(($this->loadEnvVar)($envVar)));
return Command::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php
index 4c2e4350..1e272c12 100644
--- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php
+++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php
@@ -7,8 +7,9 @@ namespace Shlinkio\Shlink\CLI\Command\Domain;
use Shlinkio\Shlink\Core\Config\NotFoundRedirects;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
@@ -18,6 +19,10 @@ use function array_map;
use function sprintf;
use function str_contains;
+#[AsCommand(
+ name: DomainRedirectsCommand::NAME,
+ description: 'Set specific "not found" redirects for individual domains.',
+)]
class DomainRedirectsCommand extends Command
{
public const string NAME = 'domain:redirects';
@@ -27,18 +32,6 @@ class DomainRedirectsCommand extends Command
parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Set specific "not found" redirects for individual domains.')
- ->addArgument(
- 'domain',
- InputArgument::REQUIRED,
- 'The domain authority to which you want to set the specific redirects',
- );
- }
-
protected function interact(InputInterface $input, OutputInterface $output): void
{
/** @var string|null $domain */
@@ -67,10 +60,11 @@ class DomainRedirectsCommand extends Command
$input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption);
}
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
- $domainAuthority = $input->getArgument('domain');
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('The domain authority to which you want to set the specific redirects', name: 'domain')]
+ string $domainAuthority,
+ ): int {
$domain = $this->domainService->findByAuthority($domainAuthority);
$ask = static function (string $message, string|null $current) use ($io): string|null {
diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php
index 935d272e..a66d6d7e 100644
--- a/module/CLI/src/Command/Domain/ListDomainsCommand.php
+++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php
@@ -8,13 +8,17 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Config\NotFoundRedirectConfigInterface;
use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
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 Symfony\Component\Console\Style\SymfonyStyle;
use function array_map;
+#[AsCommand(
+ name: ListDomainsCommand::NAME,
+ description: 'List all domains that have been ever used for some short URL',
+)]
class ListDomainsCommand extends Command
{
public const string NAME = 'domain:list';
@@ -24,25 +28,17 @@ class ListDomainsCommand extends Command
parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('List all domains that have been ever used for some short URL')
- ->addOption(
- 'show-redirects',
- 'r',
- InputOption::VALUE_NONE,
- 'Will display an extra column with the information of the "not found" redirects for every domain.',
- );
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Option(
+ 'Will display an extra column with the information of the "not found" redirects for every domain.',
+ shortcut: 'r',
+ )]
+ bool $showRedirects = false,
+ ): int {
$domains = $this->domainService->listDomains();
- $showRedirects = $input->getOption('show-redirects');
$commonFields = ['Domain', 'Is default'];
- $table = $showRedirects ? ShlinkTable::withRowSeparators($output) : ShlinkTable::default($output);
+ $table = $showRedirects ? ShlinkTable::withRowSeparators($io) : ShlinkTable::default($io);
$table->render(
$showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields,
@@ -53,7 +49,7 @@ class ListDomainsCommand extends Command
? [
...$commonValues,
$this->notFoundRedirectsToString($domain->notFoundRedirectConfig),
- ]
+ ]
: $commonValues;
}, $domains),
);
diff --git a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php
index c1c22075..f5d8e84c 100644
--- a/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php
+++ b/module/CLI/src/Command/Integration/MatomoSendVisitsCommand.php
@@ -8,10 +8,10 @@ use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
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 Symfony\Component\Console\Style\SymfonyStyle;
use Throwable;
@@ -19,22 +19,9 @@ use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function sprintf;
-class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
-{
- public const string NAME = 'integration:matomo:send-visits';
-
- private readonly bool $matomoEnabled;
- private SymfonyStyle $io;
-
- public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
- {
- $this->matomoEnabled = $matomoOptions->enabled;
- parent::__construct();
- }
-
- protected function configure(): void
- {
- $help = <<%command.name% --since 2022-01-01 --until 2022-12-31
- HELP;
+ HELP,
+)]
+class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTrackerInterface
+{
+ public const string NAME = 'integration:matomo:send-visits';
- $this
- ->setName(self::NAME)
- ->setDescription(sprintf(
- '%sSend existing visits to the configured matomo instance',
- $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
- ))
- ->setHelp($help)
- ->addOption(
- 'since',
- 's',
- InputOption::VALUE_REQUIRED,
- 'Only visits created since this date, inclusively, will be sent to Matomo',
- )
- ->addOption(
- 'until',
- 'u',
- InputOption::VALUE_REQUIRED,
- 'Only visits created until this date, inclusively, will be sent to Matomo',
- );
+ private readonly bool $matomoEnabled;
+ private SymfonyStyle $io;
+
+ public function __construct(MatomoOptions $matomoOptions, private readonly MatomoVisitSenderInterface $visitSender)
+ {
+ $this->matomoEnabled = $matomoOptions->enabled;
+ parent::__construct();
}
- protected function execute(InputInterface $input, OutputInterface $output): int
+ protected function configure(): void
{
- $this->io = new SymfonyStyle($input, $output);
+ $this->setDescription(sprintf(
+ '%sSend existing visits to the configured matomo instance',
+ $this->matomoEnabled ? '' : '[MATOMO INTEGRATION DISABLED] ',
+ ));
+ }
+
+ public function __invoke(
+ SymfonyStyle $io,
+ InputInterface $input,
+ #[Option('Only visits created since this date, inclusively, will be sent to Matomo', shortcut: 's')]
+ string|null $since = null,
+ #[Option('Only visits created until this date, inclusively, will be sent to Matomo', shortcut: 'u')]
+ string|null $until = null,
+ ): int {
+ $this->io = $io;
if (! $this->matomoEnabled) {
$this->io->warning('Matomo integration is not enabled in this Shlink instance');
@@ -87,8 +80,6 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
}
// TODO Validate provided date formats
- $since = $input->getOption('since');
- $until = $input->getOption('until');
$dateRange = buildDateRange(
startDate: $since !== null ? Chronos::parse($since) : null,
endDate: $until !== null ? Chronos::parse($until) : null,
diff --git a/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php
index 2b2abd01..626ac136 100644
--- a/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php
+++ b/module/CLI/src/Command/ShortUrl/DeleteExpiredShortUrlsCommand.php
@@ -6,14 +6,18 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ExpiredShortUrlsConditions;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
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 Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
+#[AsCommand(
+ name: DeleteExpiredShortUrlsCommand::NAME,
+ description: 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
+)]
class DeleteExpiredShortUrlsCommand extends Command
{
public const string NAME = 'short-url:delete-expired';
@@ -23,32 +27,17 @@ class DeleteExpiredShortUrlsCommand extends Command
parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription(
- 'Deletes all short URLs that are considered expired, because they have a validUntil date in the past',
- )
- ->addOption(
- 'evaluate-max-visits',
- mode: InputOption::VALUE_NONE,
- description: 'Also take into consideration short URLs which have reached their max amount of visits.',
- )
- ->addOption('force', 'f', InputOption::VALUE_NONE, 'Delete short URLs with no confirmation')
- ->addOption(
- 'dry-run',
- mode: InputOption::VALUE_NONE,
- description: 'Delete short URLs with no confirmation',
- );
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
- $force = $input->getOption('force') || ! $input->isInteractive();
- $dryRun = $input->getOption('dry-run');
- $conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $input->getOption('evaluate-max-visits'));
+ public function __invoke(
+ SymfonyStyle $io,
+ InputInterface $input,
+ #[Option('Also take into consideration short URLs which have reached their max amount of visits.')]
+ bool $evaluateMaxVisits = false,
+ #[Option('Delete short URLs with no confirmation', shortcut: 'f')] bool $force = false,
+ #[Option('Only check how many short URLs would be affected, without actually deleting them')]
+ bool $dryRun = false,
+ ): int {
+ $conditions = new ExpiredShortUrlsConditions(maxVisitsReached: $evaluateMaxVisits);
+ $force = $force || ! $input->isInteractive();
if (! $force && ! $dryRun) {
$io->warning([
@@ -69,6 +58,7 @@ class DeleteExpiredShortUrlsCommand extends Command
$result = $this->deleteShortUrlService->deleteExpiredShortUrls($conditions);
$io->success(sprintf('%s expired short URLs have been deleted', $result));
+
return self::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
index 5bdc4c81..d850e831 100644
--- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
+++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
+use Shlinkio\Shlink\CLI\Input\TagsOption;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils;
@@ -36,6 +37,7 @@ class ListShortUrlsCommand extends Command
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
+ private readonly TagsOption $tagsOption;
public function __construct(
private readonly ShortUrlListServiceInterface $shortUrlService,
@@ -44,6 +46,7 @@ class ListShortUrlsCommand extends Command
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'short URLs');
$this->endDateOption = new EndDateOption($this, 'short URLs');
+ $this->tagsOption = new TagsOption($this, 'A list of tags that short URLs need to include.');
}
protected function configure(): void
@@ -70,17 +73,22 @@ class ListShortUrlsCommand extends Command
InputOption::VALUE_REQUIRED,
'Used to filter results by domain. Use DEFAULT keyword to filter by default domain',
)
+ ->addOption('including-all-tags', 'i', InputOption::VALUE_NONE, '[DEPRECATED] Use --tags-all instead')
->addOption(
- 'tags',
- 't',
- InputOption::VALUE_REQUIRED,
- 'A comma-separated list of tags to filter results.',
+ 'tags-all',
+ mode: InputOption::VALUE_NONE,
+ description: 'If --tags is provided, returns only short URLs including ALL of them',
)
->addOption(
- 'including-all-tags',
- 'i',
- InputOption::VALUE_NONE,
- 'If tags is provided, returns only short URLs having ALL tags.',
+ 'exclude-tag',
+ 'et',
+ InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ 'A list of tags that short URLs should not have.',
+ )
+ ->addOption(
+ 'exclude-tags-all',
+ mode: InputOption::VALUE_NONE,
+ description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them',
)
->addOption(
'exclude-max-visits-reached',
@@ -101,6 +109,12 @@ class ListShortUrlsCommand extends Command
'The field from which you want to order by. '
. 'Define ordering dir by passing ASC or DESC after "-" or ",".',
)
+ ->addOption(
+ 'api-key-name',
+ 'kn',
+ InputOption::VALUE_REQUIRED,
+ 'List only short URLs created by the API key matching provided name.',
+ )
->addOption(
'show-tags',
null,
@@ -134,33 +148,32 @@ class ListShortUrlsCommand extends Command
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
- $searchTerm = $input->getOption('search-term');
- $domain = $input->getOption('domain');
- $tags = $input->getOption('tags');
- $tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
- $tags = ! empty($tags) ? explode(',', $tags) : [];
- $all = $input->getOption('all');
- $startDate = $this->startDateOption->get($input, $output);
- $endDate = $this->endDateOption->get($input, $output);
- $orderBy = $this->processOrderBy($input);
- $columnsMap = $this->resolveColumnsMap($input);
+ $tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true
+ ? TagsMode::ALL->value
+ : TagsMode::ANY->value;
+ $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
$data = [
- ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
- ShortUrlsParamsInputFilter::DOMAIN => $domain,
- ShortUrlsParamsInputFilter::TAGS => $tags,
+ ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'),
+ ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'),
+ ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input),
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
- ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
- ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
- ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
+ ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'),
+ ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode,
+ ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input),
+ ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(),
+ ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(),
ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'),
ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'),
+ ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'),
];
+ $all = $input->getOption('all');
if ($all) {
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
}
+ $columnsMap = $this->resolveColumnsMap($input);
do {
$data[ShortUrlsParamsInputFilter::PAGE] = $page;
$result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all);
@@ -168,7 +181,7 @@ class ListShortUrlsCommand extends Command
$continue = $result->hasNextPage() && $io->confirm(
sprintf('Continue with page %s>?', $page),
- false,
+ default: false,
);
} while ($continue);
diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php
index 2022a9dc..301cba26 100644
--- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php
+++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php
@@ -5,12 +5,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Attribute\Option;
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 Symfony\Component\Console\Style\SymfonyStyle;
+#[AsCommand(name: DeleteTagsCommand::NAME, description: 'Deletes one or more tags.')]
class DeleteTagsCommand extends Command
{
public const string NAME = 'tag:delete';
@@ -20,24 +20,13 @@ class DeleteTagsCommand extends Command
parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Deletes one or more tags.')
- ->addOption(
- 'name',
- 't',
- InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
- 'The name of the tags to delete',
- );
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
- $tagNames = $input->getOption('name');
-
+ /**
+ * @param string[] $tagNames
+ */
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Option('The name of the tags to delete', name: 'name', shortcut: 't')] array $tagNames = [],
+ ): int {
if (empty($tagNames)) {
$io->warning('You have to provide at least one tag name');
return self::INVALID;
@@ -45,6 +34,7 @@ class DeleteTagsCommand extends Command
$this->tagService->deleteTags($tagNames);
$io->success('Tags properly deleted');
+
return self::SUCCESS;
}
}
diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php
index b3c083bc..bac12ac2 100644
--- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php
+++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php
@@ -5,24 +5,34 @@ 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\Core\Domain\Entity\Domain;
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\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
+use function sprintf;
+
class GetTagVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'tag:visits';
+ private readonly DomainOption $domainOption;
+
public function __construct(
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,
+ ));
}
protected function configure(): void
@@ -39,7 +49,10 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$tag = $input->getArgument('tag');
- return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange));
+ return $this->visitsHelper->visitsForTag($tag, new WithDomainVisitsParams(
+ dateRange: $dateRange,
+ domain: $this->domainOption->get($input),
+ ));
}
/**
diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php
index abd9a0dd..66497737 100644
--- a/module/CLI/src/Command/Tag/ListTagsCommand.php
+++ b/module/CLI/src/Command/Tag/ListTagsCommand.php
@@ -8,12 +8,13 @@ use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
use function array_map;
+#[AsCommand(ListTagsCommand::NAME, 'Lists existing tags.')]
class ListTagsCommand extends Command
{
public const string NAME = 'tag:list';
@@ -23,16 +24,9 @@ class ListTagsCommand extends Command
parent::__construct();
}
- protected function configure(): void
+ public function __invoke(SymfonyStyle $io): int
{
- $this
- ->setName(self::NAME)
- ->setDescription('Lists existing tags.');
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
+ ShlinkTable::default($io)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
return self::SUCCESS;
}
diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php
index 2ae0159c..f9e53f28 100644
--- a/module/CLI/src/Command/Tag/RenameTagCommand.php
+++ b/module/CLI/src/Command/Tag/RenameTagCommand.php
@@ -8,12 +8,12 @@ use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\Renaming;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
+use Symfony\Component\Console\Attribute\Argument;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
+#[AsCommand(RenameTagCommand::NAME, 'Renames one existing tag.')]
class RenameTagCommand extends Command
{
public const string NAME = 'tag:rename';
@@ -23,21 +23,11 @@ class RenameTagCommand extends Command
parent::__construct();
}
- protected function configure(): void
- {
- $this
- ->setName(self::NAME)
- ->setDescription('Renames one existing tag.')
- ->addArgument('oldName', InputArgument::REQUIRED, 'Current name of the tag.')
- ->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.');
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $io = new SymfonyStyle($input, $output);
- $oldName = $input->getArgument('oldName');
- $newName = $input->getArgument('newName');
-
+ public function __invoke(
+ SymfonyStyle $io,
+ #[Argument('Current name of the tag.')] string $oldName,
+ #[Argument('New name of the tag.')] string $newName,
+ ): int {
try {
$this->tagService->renameTag(Renaming::fromNames($oldName, $newName));
$io->success('Tag properly renamed.');
diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
index 4d58a7d3..f76a4dbc 100644
--- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
+++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php
@@ -8,14 +8,17 @@ use Shlinkio\Shlink\Core\Exception\GeolocationDbUpdateFailedException;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDbUpdaterInterface;
use Shlinkio\Shlink\Core\Geolocation\GeolocationDownloadProgressHandlerInterface;
use Shlinkio\Shlink\Core\Geolocation\GeolocationResult;
+use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
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.',
+)]
class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadProgressHandlerInterface
{
public const string NAME = 'visit:download-db';
@@ -28,19 +31,9 @@ class DownloadGeoLiteDbCommand extends Command implements GeolocationDownloadPro
parent::__construct();
}
- protected function configure(): void
+ public function __invoke(SymfonyStyle $io): int
{
- $this
- ->setName(self::NAME)
- ->setDescription(
- '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.',
- );
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): int
- {
- $this->io = new SymfonyStyle($input, $output);
+ $this->io = $io;
try {
$result = $this->dbUpdater->checkDbUpdate($this);
diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php
index 445bd36f..1b40d55e 100644
--- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php
+++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php
@@ -4,23 +4,33 @@ 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\Core\Domain\Entity\Domain;
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\Model\WithDomainVisitsParams;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Input\InputInterface;
+use function sprintf;
+
class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'visit:non-orphan';
+ private readonly DomainOption $domainOption;
+
public function __construct(
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,
+ ));
}
protected function configure(): void
@@ -35,7 +45,10 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
- return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange));
+ return $this->visitsHelper->nonOrphanVisits(new WithDomainVisitsParams(
+ dateRange: $dateRange,
+ domain: $this->domainOption->get($input),
+ ));
}
/**
diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php
index d282d310..0804215a 100644
--- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php
+++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php
@@ -4,11 +4,14 @@ 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\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;
@@ -19,6 +22,17 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
public const string NAME = 'visit:orphan';
+ private readonly DomainOption $domainOption;
+
+ public function __construct(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,
+ ));
+ }
+
protected function configure(): void
{
$this
@@ -37,7 +51,11 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand
{
$rawType = $input->getOption('type');
$type = $rawType !== null ? OrphanVisitType::from($rawType) : null;
- return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type));
+ return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(
+ dateRange: $dateRange,
+ domain: $this->domainOption->get($input),
+ type: $type,
+ ));
}
/**
diff --git a/module/CLI/src/Input/DomainOption.php b/module/CLI/src/Input/DomainOption.php
new file mode 100644
index 00000000..e7a15f52
--- /dev/null
+++ b/module/CLI/src/Input/DomainOption.php
@@ -0,0 +1,29 @@
+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/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php
index 1ff1de3f..908e6536 100644
--- a/module/CLI/src/Input/ShortUrlDataInput.php
+++ b/module/CLI/src/Input/ShortUrlDataInput.php
@@ -13,13 +13,10 @@ use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
-use function array_map;
-use function array_unique;
-use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
-use function Shlinkio\Shlink\Core\splitByComma;
-
final readonly class ShortUrlDataInput
{
+ private readonly TagsOption $tagsOption;
+
public function __construct(Command $command, private bool $longUrlAsOption = false)
{
if ($longUrlAsOption) {
@@ -28,13 +25,9 @@ final readonly class ShortUrlDataInput
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
}
+ $this->tagsOption = new TagsOption($command, 'Tags to apply to the short URL');
+
$command
- ->addOption(
- ShortUrlDataOption::TAGS->value,
- ShortUrlDataOption::TAGS->shortcut(),
- InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
- 'Tags to apply to the short URL',
- )
->addOption(
ShortUrlDataOption::VALID_SINCE->value,
ShortUrlDataOption::VALID_SINCE->shortcut(),
@@ -117,9 +110,8 @@ final readonly class ShortUrlDataInput
$maxVisits = $input->getOption('max-visits');
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
}
- if (ShortUrlDataOption::TAGS->wasProvided($input)) {
- $tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags'))));
- $data[ShortUrlInputFilter::TAGS] = $tags;
+ if ($this->tagsOption->exists($input)) {
+ $data[ShortUrlInputFilter::TAGS] = $this->tagsOption->get($input);
}
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
diff --git a/module/CLI/src/Input/ShortUrlDataOption.php b/module/CLI/src/Input/ShortUrlDataOption.php
index 29c41407..4d8b582e 100644
--- a/module/CLI/src/Input/ShortUrlDataOption.php
+++ b/module/CLI/src/Input/ShortUrlDataOption.php
@@ -10,7 +10,6 @@ use function sprintf;
enum ShortUrlDataOption: string
{
- case TAGS = 'tags';
case VALID_SINCE = 'valid-since';
case VALID_UNTIL = 'valid-until';
case MAX_VISITS = 'max-visits';
@@ -21,7 +20,6 @@ enum ShortUrlDataOption: string
public function shortcut(): string|null
{
return match ($this) {
- self::TAGS => 't',
self::VALID_SINCE => 's',
self::VALID_UNTIL => 'u',
self::MAX_VISITS => 'm',
diff --git a/module/CLI/src/Input/TagsOption.php b/module/CLI/src/Input/TagsOption.php
new file mode 100644
index 00000000..ff02a735
--- /dev/null
+++ b/module/CLI/src/Input/TagsOption.php
@@ -0,0 +1,51 @@
+addOption(
+ 'tag',
+ 't',
+ InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ $description,
+ )
+ ->addOption(
+ 'tags',
+ mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ description: '[DEPRECATED] Use --tag instead',
+ );
+ }
+
+ /**
+ * Whether tags have been set or not, via `--tag`, `-t` or the deprecated `--tags`
+ */
+ public function exists(InputInterface $input): bool
+ {
+ return $input->hasParameterOption(['--tag', '-t']) || $input->hasParameterOption('--tags');
+ }
+
+ /**
+ * @return string[]
+ */
+ public function get(InputInterface $input): array
+ {
+ // FIXME DEPRECATED Remove support for comma-separated tags in next major release
+ $tags = [...$input->getOption('tag'), ...$input->getOption('tags')];
+ return array_unique(flatten(array_map(splitByComma(...), $tags)));
+ }
+}
diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php
index d7c50912..c01a631f 100644
--- a/module/CLI/test-cli/Command/ListShortUrlsTest.php
+++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php
@@ -87,6 +87,15 @@ class ListShortUrlsTest extends CliTestCase
| ghi789 | | http://s.test/ghi789 | https://shlink.io/documentation/ | 2018-05-01T00:00:00+00:00 | 2 |
+------------+---------------+----------------------+--------------------------------------- Page 1 of 1 -------------------------------------------------+---------------------------+--------------+
OUTPUT];
+ yield 'exclude tags' => [['--exclude-tag=foo'], <<