Repository: obsidian-tasks-group/obsidian-tasks (ts)

Issue:
Feature: New task filters – `has tags` & `no tags`
### ⚠️ Please check that this feature request hasn't been suggested before.

- [X] I searched previous [Ideas in Discussions](https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/categories/ideas-any-new-feature-requests-go-in-issues-please?discussions_q=category%3A%22Ideas%3A+Any+New+Feature+Requests+go+in+Issues+please%22+-label%3A%22status%3A+released%22+-label%3Aduplicate+sort%3Atop) didn't find any similar feature requests.
- [X] I searched previous [Issues](https://github.com/obsidian-tasks-group/obsidian-tasks/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+enhancement%22+sort%3Acomments-desc) didn't find any similar feature requests.

### 🔖 Feature description

Add a way to filter tasks on the presence or absence of tags in the todo description.

### ✔️ Solution

Add two filters:

- `has tags` – if the task has any tags
- `no tags` – if the task has no tags

My workflow uses general tasks with no tags as my main todo list while I keep specialized lists like `#to_read` or `#tracking` that I don't want to pollute my task list. 

### ❓ Alternatives

- Use regex that matches any tags
- Use a special tag for my main task list (ie another tag for all my todos)
- Adding each specialized tags as exceptions to my queries for general todos
- (my current choice) use `tags do not include #` 

### 📝 Additional Context

_No response_

Pull request description:
feat: Add filters for tag presence and absence
<!--- Provide a general summary of your changes in the Title above -->

# Description

<!--- Describe your changes in detail -->

## Motivation and Context

Add `no tag(s)` and `has tag(s)` filters

Fixes #1739 

## How has this been tested?

Unit tests added.

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [ ] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))

Internal changes:

- [ ] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [x] My change requires a change to the documentation.
- [x] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)


Patch:
diff --git a/docs/queries/filters.md b/docs/queries/filters.md
index 5ea73044..68b03d55 100644
--- a/docs/queries/filters.md
+++ b/docs/queries/filters.md
@@ -412,6 +412,8 @@ For more information, see [Priorities]({{ site.baseurl }}{% link getting-started
 {: .released }
 Introduced in Tasks 1.6.0.
 
+- `no tags`
+- `has tags`
 - `tags (include|do not include) <tag>` _or_
 - `tag (includes|does not include) <tag>`
   - Matches case-insensitive (disregards capitalization).
@@ -427,7 +429,8 @@ Introduced in Tasks 1.6.0.
   - If searching for sub-tags, remember to escape the slashes in regular expressions: `\/`
 
 {: .released }
-`regex matches` and `regex does not match` were introduced in Tasks 1.13.0.
+`regex matches` and `regex does not match` were introduced in Tasks 1.13.0.<br>
+`no tags` and `has tags` were introduced in Tasks 1.26.0.
 
 #### Tag Query Examples
 {: .no_toc }
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index 30c1f413..caa66c6d 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -37,7 +37,7 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`                                                                                                                                                       | `sort by heading`                           | `group by heading`     |                        |
 |                                                                                                                                                                                                                                                                   |                                             | `group by backlink`    | `hide backlink`        |
 | `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i`                                                                                                                                               | `sort by description`                       |                        |                        |
-| `tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i`                                                               | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`        |                        |
+| `has tags`<br>`no tags`<br>`tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i`                                    | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags`        |                        |
 | [**Combining Filters**][5]                                                                                                                                                                                                                                        |                                             |                        |                        |
 | `(filter 1) AND (filter 2)`                                                                                                                                                                                                                                       |                                             |                        |                        |
 | `(filter 1) OR (filter 2)`                                                                                                                                                                                                                                        |                                             |                        |                        |
diff --git a/src/Query/Filter/TagsField.ts b/src/Query/Filter/TagsField.ts
index 324b0086..f6ceba44 100644
--- a/src/Query/Filter/TagsField.ts
+++ b/src/Query/Filter/TagsField.ts
@@ -1,6 +1,8 @@
 import type { Task } from '../../Task';
 import type { Comparator } from '../Sorter';
 import { Sorter } from '../Sorter';
+import type { FilterOrErrorMessage } from './Filter';
+import { FilterInstructions } from './FilterInstructions';
 import { MultiTextField } from './MultiTextField';
 
 /**
@@ -12,6 +14,33 @@ export class TagsField extends MultiTextField {
     // -----------------------------------------------------------------------------------------------------------------
     // Filtering
     // -----------------------------------------------------------------------------------------------------------------
+    private readonly filterInstructions: FilterInstructions;
+
+    constructor() {
+        super();
+        this.filterInstructions = new FilterInstructions();
+        this.filterInstructions.add(`has ${this.fieldNameSingular()}`, (task: Task) => this.values(task).length > 0);
+        this.filterInstructions.add(`has ${this.fieldNamePlural()}`, (task: Task) => this.values(task).length > 0);
+        this.filterInstructions.add(`no ${this.fieldNameSingular()}`, (task: Task) => this.values(task).length === 0);
+        this.filterInstructions.add(`no ${this.fieldNamePlural()}`, (task: Task) => this.values(task).length === 0);
+    }
+
+    public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
+        const filterResult = this.filterInstructions.createFilterOrErrorMessage(line);
+        if (filterResult.filter !== undefined) {
+            return filterResult;
+        }
+
+        return super.createFilterOrErrorMessage(line);
+    }
+
+    public canCreateFilterForLine(line: string): boolean {
+        if (this.filterInstructions.canCreateFilterForLine(line)) {
+            return true;
+        }
+
+        return super.canCreateFilterForLine(line);
+    }
 
     public fieldNameSingular(): string {
         return 'tag';


Test patch:
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index cd37bfbe..688d2ced 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -52,6 +52,8 @@ describe('Query parsing', () => {
         'has happens date',
         'has scheduled date',
         'has start date',
+        'has tags',
+        'has tag',
         'heading does not include wibble',
         'heading includes AND', // Verify Query doesn't confuse this with a boolean query
         'heading includes wibble',
@@ -63,6 +65,8 @@ describe('Query parsing', () => {
         'no happens date',
         'no scheduled date',
         'no start date',
+        'no tags',
+        'no tag',
         'not done',
         'path does not include some/path',
         'path includes AND', // Verify Query doesn't confuse this with a boolean query
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index 1682f60d..735c38d6 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -8,6 +8,68 @@ import { Sort } from '../../../src/Query/Sort';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { expectTaskComparesAfter, expectTaskComparesBefore } from '../../CustomMatchers/CustomMatchersForSorting';
 
+describe('tag presence & absence', () => {
+    it.each(['has tag', 'has tags'])('should have "%s" filtering', (filterLine: string) => {
+        // Arrange
+        const filter = new TagsField().createFilterOrErrorMessage(filterLine);
+
+        // Act, Assert
+        expect(filter.filterFunction).toBeDefined();
+        expect(filter.error).toBeUndefined();
+
+        expect(filter).toMatchTaskFromLine('- [ ] stuff #one');
+        expect(filter).toMatchTaskFromLine('- [ ] stuff #one #two');
+        expect(filter).not.toMatchTaskFromLine('- [ ] no tag here');
+    });
+
+    it.each(['no tag', 'no tags'])('should have "%s" filtering', (filterLine: string) => {
+        // Arrange
+        const filter = new TagsField().createFilterOrErrorMessage(filterLine);
+
+        // Act, Assert
+        expect(filter.filterFunction).toBeDefined();
+        expect(filter.error).toBeUndefined();
+
+        expect(filter).not.toMatchTaskFromLine('- [ ] stuff #one');
+        expect(filter).not.toMatchTaskFromLine('- [ ] stuff #one #two');
+        expect(filter).toMatchTaskFromLine('- [ ] no tag here');
+    });
+
+    it('should filter together with the global filter ("has tags")', () => {
+        updateSettings({ globalFilter: '#task' });
+
+        // Arrange
+        const filter = new TagsField().createFilterOrErrorMessage('has tags');
+
+        // Act, Assert
+        expect(filter.filterFunction).toBeDefined();
+        expect(filter.error).toBeUndefined();
+
+        expect(filter).toMatchTaskFromLine('- [ ] #task stuff #one ');
+        expect(filter).toMatchTaskFromLine('- [ ] #task stuff #one #two ');
+        expect(filter).not.toMatchTaskFromLine('- [ ] #task global filter is not a tag');
+
+        resetSettings();
+    });
+
+    it('should filter together with the global filter ("no tags")', () => {
+        updateSettings({ globalFilter: '#task' });
+
+        // Arrange
+        const filter = new TagsField().createFilterOrErrorMessage('no tags');
+
+        // Act, Assert
+        expect(filter.filterFunction).toBeDefined();
+        expect(filter.error).toBeUndefined();
+
+        expect(filter).not.toMatchTaskFromLine('- [ ] #task stuff #one ');
+        expect(filter).not.toMatchTaskFromLine('- [ ] #task stuff #one #two ');
+        expect(filter).toMatchTaskFromLine('- [ ] #task global filter is not a tag');
+
+        resetSettings();
+    });
+});
+
 describe('tag/tags', () => {
     it('should honour any # in query', () => {
         const filter = new TagsField().createFilterOrErrorMessage('tags include #home');