Repository: obsidian-tasks-group/obsidian-tasks
Pull request #1098 (2022-09-01 20:58:11)

feat: Add filename filter
# Description

Add support for:

```
filename (includes, does not include) <filename>
filename (regex matches, regex does not match) /regex/i
```

And update the documentation - both the filters page and the quick reference table.

## Motivation and Context

https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/72

<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

## How has this been tested?

- By adding tests

## 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:

- [x] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [x] **Documentation** (prefix: `docs` - improvements to any documentation content)

Internal changes:

- [x] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)

## Checklist

- [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://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#updating-documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md#maintaining-the-tests).

## Terms

- [x] My contribution follow this project's [contributing guide](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CONTRIBUTING.md)
- [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)


claremacrae: @schemar It turned out that this was pretty easy, due to recent refactorings, so I went ahead and added it.

If you have time for a review, it would be appreciated.

Patch:
diff --git a/docs/queries/filters.md b/docs/queries/filters.md
index 35cf4931..9f07fe37 100644
--- a/docs/queries/filters.md
+++ b/docs/queries/filters.md
@@ -208,6 +208,8 @@ These filters allow searching for tasks in particular files and sections of file
 
 ### File Path
 
+Note that the path includes the `.md` extension.
+
 - `path (includes|does not include) <path>`
   - Matches case-insensitive (disregards capitalization).
 - `path (regex matches|regex does not match) /<JavaScript-style Regex>/`
@@ -216,6 +218,18 @@ These filters allow searching for tasks in particular files and sections of file
 
 > `regex matches` and `regex does not match` were introduced in Tasks 1.12.0.
 
+### File Name
+
+> Introduced in Tasks 1.13.0.
+
+Note that the file name includes the `.md` extension.
+
+- `filename (includes|does not include) <filename>`
+  - Matches case-insensitive (disregards capitalization).
+- `filename (regex matches|regex does not match) /<JavaScript-style Regex>/`
+  - Does regular expression match (case-sensitive by default).
+  - Essential reading: [Regular Expression Searches]({{ site.baseurl }}{% link queries/regular-expressions.md %}).
+
 ### Heading
 
 - `heading (includes|does not include) <string>`
diff --git a/docs/quick-reference/index.md b/docs/quick-reference/index.md
index 3b6d4f63..8f3f49d4 100644
--- a/docs/quick-reference/index.md
+++ b/docs/quick-reference/index.md
@@ -29,7 +29,7 @@ This table summarizes the filters and other options available inside a `tasks` b
 | `path (includes, does not include) <path>`<br>`path (regex matches, regex does not match) /regex/i`                 | `sort by path`                              | `group by path`       |                        |
 |                                                                                                                     |                                             | `group by root`       |                        |
 |                                                                                                                     |                                             | `group by folder`     |                        |
-|                                                                                                                     |                                             | `group by filename`   |                        |
+| `filename (includes, does not include) <filename>`<br>`filename (regex matches, regex does not match) /regex/i`     |                                             | `group by filename`   |                        |
 | `heading (includes, does not include) <string>`<br>`heading (regex matches, regex does not match) /regex/i`         |                                             | `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`                       |                       |                        |
diff --git a/src/Query/Filter/FilenameField.ts b/src/Query/Filter/FilenameField.ts
new file mode 100644
index 00000000..c6d30423
--- /dev/null
+++ b/src/Query/Filter/FilenameField.ts
@@ -0,0 +1,27 @@
+import type { Task } from '../../Task';
+import { TextField } from './TextField';
+
+/** Support the 'filename' search instruction.
+ *
+ * Note that the current implementation also searches the file extension,
+ * so 'filename includes .md' will typically match all tasks.
+ *
+ */
+export class FilenameField extends TextField {
+    public fieldName(): string {
+        return 'filename';
+    }
+
+    /**
+     * Returns the file name including file extension, or an empty string if the task does not have a filename
+     * @param task
+     * @public
+     */
+    public value(task: Task): string {
+        const filename = task.filename;
+        if (filename === null) {
+            return '';
+        }
+        return filename + '.md';
+    }
+}
diff --git a/src/Query/FilterParser.ts b/src/Query/FilterParser.ts
index 7bed0cee..7c3dec3e 100644
--- a/src/Query/FilterParser.ts
+++ b/src/Query/FilterParser.ts
@@ -12,6 +12,7 @@ import { RecurringField } from './Filter/RecurringField';
 import { StatusField } from './Filter/StatusField';
 import { TagsField } from './Filter/TagsField';
 import { BooleanField } from './Filter/BooleanField';
+import { FilenameField } from './Filter/FilenameField';
 
 import type { FilterOrErrorMessage } from './Filter/Filter';
 
@@ -30,6 +31,7 @@ const fieldCreators = [
     () => new HeadingField(),
     () => new ExcludeSubItemsField(),
     () => new BooleanField(),
+    () => new FilenameField(),
 ];
 
 export function parseFilter(filterString: string): FilterOrErrorMessage | null {
diff --git a/src/Task.ts b/src/Task.ts
index 079b8da9..f00ae1e2 100644
--- a/src/Task.ts
+++ b/src/Task.ts
@@ -674,6 +674,9 @@ export class Task {
         return this._urgency;
     }
 
+    /**
+     * Return the name of the file containing the task, with the .md extension removed.
+     */
     public get filename(): string | null {
         const fileNameMatch = this.path.match(/([^/]+)\.md$/);
         if (fileNameMatch !== null) {


Test patch:
diff --git a/tests/Query.test.ts b/tests/Query.test.ts
index 2578ef3a..951d2e43 100644
--- a/tests/Query.test.ts
+++ b/tests/Query.test.ts
@@ -24,6 +24,7 @@ describe('Query parsing', () => {
         'due before 2021-12-27',
         'due on 2021-12-27',
         'exclude sub-items',
+        'filename includes wibble',
         'happens after 2021-12-27',
         'happens before 2021-12-27',
         'happens on 2021-12-27',
diff --git a/tests/Query/Filter/FilenameField.test.ts b/tests/Query/Filter/FilenameField.test.ts
new file mode 100644
index 00000000..31102afe
--- /dev/null
+++ b/tests/Query/Filter/FilenameField.test.ts
@@ -0,0 +1,90 @@
+import { TaskBuilder } from '../../TestingTools/TaskBuilder';
+import { FilenameField } from '../../../src/Query/Filter/FilenameField';
+import {
+    toBeValid,
+    toMatchTaskWithPath,
+} from '../../CustomMatchers/CustomMatchersForFilters';
+
+expect.extend({
+    toMatchTaskWithPath,
+});
+
+expect.extend({
+    toBeValid,
+});
+
+describe('filename', () => {
+    it('should provide access to the file name with extension', () => {
+        const pathField = new FilenameField();
+
+        const builder = new TaskBuilder();
+
+        expect(pathField.value(builder.path('').build())).toStrictEqual('');
+
+        expect(
+            pathField.value(builder.path('file in root.md').build()),
+        ).toStrictEqual('file in root.md');
+
+        expect(
+            pathField.value(
+                builder.path('directory name/file in sub-directory.md').build(),
+            ),
+        ).toStrictEqual('file in sub-directory.md');
+    });
+});
+
+describe('filename', () => {
+    // Note: We don't need to check all behaviours that are implemented in the base class.
+    // These are minimal tests to confirm that the filters are correctly wired up,
+    // to guard against possible future coding errors.
+
+    it('by filename (includes)', () => {
+        // Arrange
+        const filter = new FilenameField().createFilterOrErrorMessage(
+            'filename includes search_text',
+        );
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).not.toMatchTaskWithPath('');
+        expect(filter).toMatchTaskWithPath('/some/path/SeArch_Text.md');
+        expect(filter).not.toMatchTaskWithPath('/other/search_text/file.md'); // Ignores text in folder names
+    });
+
+    it('by filename (does not include)', () => {
+        // Arrange
+        const filter = new FilenameField().createFilterOrErrorMessage(
+            'filename does not include search_text',
+        );
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).toMatchTaskWithPath('');
+        expect(filter).toMatchTaskWithPath('/other/search_text/file.md'); // Ignores text in folder names
+        expect(filter).not.toMatchTaskWithPath('/SoMe/PaTh/SeArcH_Text.md');
+    });
+
+    it('by filename (regex matches)', () => {
+        // Arrange
+        const filter = new FilenameField().createFilterOrErrorMessage(
+            String.raw`filename regex matches /w.bble/`,
+        );
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).toMatchTaskWithPath('/some/path/wibble.md');
+        expect(filter).not.toMatchTaskWithPath('/some/wibble/filename.md');
+    });
+
+    it('by filename (regex does not match)', () => {
+        // Arrange
+        const filter = new FilenameField().createFilterOrErrorMessage(
+            String.raw`filename regex does not match /w.bble/`,
+        );
+
+        // Assert
+        expect(filter).toBeValid();
+        expect(filter).toMatchTaskWithPath('/some/wobble/path name.md');
+        expect(filter).not.toMatchTaskWithPath('/some/path/wibble.md');
+    });
+});