diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6bc198d6..151a3109 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,27 @@ 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).
+## [Unreleased]
+### Added
+* [#2327](https://github.com/shlinkio/shlink/issues/2327) Allow filtering short URLs 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`.
+
+### Changed
+* *Nothing*
+
+### Deprecated
+* *Nothing*
+
+### Removed
+* *Nothing*
+
+### Fixed
+* *Nothing*
+
+
## [4.5.3] - 2025-10-10
### Added
* *Nothing*
diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json
index 6ca05c2e..a29c221e 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",
diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
index 5bdc4c81..b7bbaf3a 100644
--- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
+++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
@@ -27,6 +27,7 @@ use function array_keys;
use function array_pad;
use function explode;
use function implode;
+use function Shlinkio\Shlink\Core\ArrayUtils\flatten;
use function Shlinkio\Shlink\Core\ArrayUtils\map;
use function sprintf;
@@ -72,15 +73,31 @@ class ListShortUrlsCommand extends Command
)
->addOption(
'tags',
- 't',
- InputOption::VALUE_REQUIRED,
- 'A comma-separated list of tags to filter results.',
+ mode: InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ description: '[DEPRECATED] Use --tag instead',
)
->addOption(
- 'including-all-tags',
- 'i',
- InputOption::VALUE_NONE,
- 'If tags is provided, returns only short URLs having ALL tags.',
+ 'tag',
+ 't',
+ InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
+ 'A list of tags that short URLs need to include.',
+ )
+ ->addOption('including-all-tags', 'i', InputOption::VALUE_NONE, '[DEPRECATED] Use --tags-all instead')
+ ->addOption(
+ 'tags-all',
+ mode: InputOption::VALUE_NONE,
+ description: 'If --tags is provided, returns only short URLs including ALL of them',
+ )
+ ->addOption(
+ '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',
@@ -136,9 +153,17 @@ class ListShortUrlsCommand extends Command
$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) : [];
+
+ // FIXME DEPRECATED Remove support for comma-separated tags in next major release
+ $tags = [...$input->getOption('tag'), ...$input->getOption('tags')];
+ $tags = flatten(map($tags, static fn (string $tag) => explode(',', $tag)));
+ $tagsMode = $input->getOption('tags-all') === true || $input->getOption('including-all-tags') === true
+ ? TagsMode::ALL->value
+ : TagsMode::ANY->value;
+
+ $excludeTags = $input->getOption('exclude-tag');
+ $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value;
+
$all = $input->getOption('all');
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
@@ -150,6 +175,8 @@ class ListShortUrlsCommand extends Command
ShortUrlsParamsInputFilter::DOMAIN => $domain,
ShortUrlsParamsInputFilter::TAGS => $tags,
ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
+ ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $excludeTags,
+ ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode,
ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
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'], <<