Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2378 (2023-10-29 10:15:35)

feat: Custom filters access Query Properties directly, without Placeholders
<!--- Provide a general summary of your changes in the Title above -->

# Description

`filter by function` lines can now use `query.file.path` directly just like `task.file.path`.

In custom filters, there is no longer a requirement to use quotes and placeholders to access query properties.

_Note: I pushed this change on `main` accidentally - so reverted the changes on main, then cherry-picked them on to this branch - to avoid the need to force-push._

## Motivation and Context

To simplify the use of query properties in custom filters code.

## How has this been tested?

- Running existing tests
- Adding new tests
- Manually testing in the same vault, with various queries such as:

~~~

### Same file

```tasks
filter by function task.file.path === query.file.path
```

### Same folder

```tasks
filter by function task.file.folder === query.file.folder
```

~~~

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->

## Screenshots (if appropriate)

The bits in red on the left have been removed, as `query.file.path`  etc can all now be used as variables directly in `filter by function`.

![image](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/4840096/8c8d898b-2b46-40b3-88b0-858b9622fed5)


## 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 **for users**)

Internal changes:

- [x] **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)

## 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/Introduction.md b/docs/Introduction.md
index a0f24eef..07545af7 100644
--- a/docs/Introduction.md
+++ b/docs/Introduction.md
@@ -13,7 +13,7 @@ _In recent [releases](https://github.com/obsidian-tasks-group/obsidian-tasks/rel
     Move the older ones down to the top of the comment block below...
 -->
 
-- X.Y.Z: 🔥 Enable [[Custom Grouping|custom grouping]] to use [[Query Properties|query properties]] directly - no placeholders required.
+- X.Y.Z: 🔥 Enable [[Custom Filters|custom filters]] and [[Custom Grouping|custom grouping]] to use [[Query Properties|query properties]] directly - no placeholders required.
 
 <!--
 - 5.0.0: 🔥 Add [[Line Continuations|line continuations]].
diff --git a/docs/Queries/Filters.md b/docs/Queries/Filters.md
index 0f427d57..a729abe5 100644
--- a/docs/Queries/Filters.md
+++ b/docs/Queries/Filters.md
@@ -1137,6 +1137,12 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file path** is now p
 
 In Tasks 4.8.0 `task.file.pathWithoutExtension` was added.
 
+Since Tasks X.Y.Z, the query's file path can be used conveniently in custom filters:
+
+- `query.file.path` or
+- `query.file.pathWithoutExtension`
+- Useful reading: [[Query Properties]].
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.path_docs.approved.md -->
 
 ```javascript
@@ -1181,6 +1187,11 @@ The `root` is the top-level folder of the file that contains the task, that is,
 
 Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by root folder** is now possible, using `task.file.root`.
 
+Since Tasks X.Y.Z, the query's file root can be used conveniently in custom filters:
+
+- `query.file.root`
+- Useful reading: [[Query Properties]].
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.root_docs.approved.md -->
 
 ```javascript
@@ -1219,6 +1230,11 @@ This is the `folder` to the file that contains the task, which will be `/` for f
 
 Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by folder** is now possible, using `task.file.folder`.
 
+Since Tasks X.Y.Z, the query's file root can be used conveniently in custom filters:
+
+- `query.file.root`
+- Useful reading: [[Query Properties]].
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md -->
 
 ```javascript
@@ -1235,14 +1251,13 @@ filter by function task.file.folder.includes("Work/Projects/")
 - Find tasks in files in a specific folder **and any sub-folders**.
 
 ```javascript
-filter by function task.file.folder.includes( '{{query.file.folder}}' )
+filter by function task.file.folder.includes( query.file.folder )
 ```
 
 - Find tasks in files in the folder that contains the query **and any sub-folders**.
-- Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
 
 ```javascript
-filter by function task.file.folder === '{{query.file.folder}}'
+filter by function task.file.folder === query.file.folder
 ```
 
 - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
@@ -1279,6 +1294,12 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file name** is now p
 
 In Tasks 4.8.0 `task.file.filenameWithoutExtension` was added.
 
+Since Tasks X.Y.Z, the query's file name can be used conveniently in custom filters:
+
+- `query.file.filename` or
+- `query.file.filenameWithoutExtension`
+- Useful reading: [[Query Properties]].
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.filename_docs.approved.md -->
 
 ```javascript
diff --git a/docs/Scripting/Custom Filters.md b/docs/Scripting/Custom Filters.md
index 18e989e7..2b11d000 100644
--- a/docs/Scripting/Custom Filters.md
+++ b/docs/Scripting/Custom Filters.md
@@ -16,8 +16,9 @@ publish: true
 - The expression must evaluate to a `boolean`, so `true` or `false`.
 - There are loads of examples in [[Filters]].
   - Search for `filter by function` in that file.
-- Find all the supported tasks properties in [[Task Properties]] and [[Quick Reference]].
+- Find all the **supported tasks properties** in [[Task Properties]] and [[Quick Reference]].
   - A number of properties are only available for custom filters and grouping, and not for built-in grouping instructions.
+- Find all the **supported query properties** in [[Query Properties]].
 - Learn a bit about how expressions work in [[Expressions]].
 
 ## Custom filters introduction
@@ -40,12 +41,12 @@ The available task properties are also shown in the [[Quick Reference]] table.
 
 ### Available Query Properties
 
-The Reference section [[Query Properties]] shows all the query properties available for use via [[Placeholders]] in custom filters.
-
-Any placeholders in custom filters must be surrounded by quotes.
+The Reference section [[Query Properties]] shows all the query properties available for use in custom filters.
 
 > [!released]
-> Query properties and placeholders were introduced in Tasks 4.7.0.
+>
+> - Query properties and placeholders were introduced in Tasks 4.7.0, accessible via Placeholders.
+> - Direct access to Query properties was introduced in Tasks X.Y.Z.
 
 ### Expressions
 
@@ -195,14 +196,13 @@ filter by function task.file.folder.includes("Work/Projects/")
 - Find tasks in files in a specific folder **and any sub-folders**.
 
 ```javascript
-filter by function task.file.folder.includes( '{{query.file.folder}}' )
+filter by function task.file.folder.includes( query.file.folder )
 ```
 
 - Find tasks in files in the folder that contains the query **and any sub-folders**.
-- Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
 
 ```javascript
-filter by function task.file.folder === '{{query.file.folder}}'
+filter by function task.file.folder === query.file.folder
 ```
 
 - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
diff --git a/src/Query/Filter/FunctionField.ts b/src/Query/Filter/FunctionField.ts
index c4f63d03..827e1fd9 100644
--- a/src/Query/Filter/FunctionField.ts
+++ b/src/Query/Filter/FunctionField.ts
@@ -22,7 +22,6 @@ export class FunctionField extends Field {
         }
 
         const expression = match[1];
-        // TODO When filters are allowed to start using the QueryContext, will need to pass in actual QueryContext
         const taskExpression = new TaskExpression(expression);
         if (!taskExpression.isValid()) {
             return FilterOrErrorMessage.fromError(line, taskExpression.parseError!);
@@ -80,15 +79,16 @@ export class FunctionField extends Field {
 // -----------------------------------------------------------------------------------------------------------------
 
 function createFilterFunctionFromLine(expression: TaskExpression): FilterFunction {
-    return (task: Task) => {
-        return filterByFunction(expression, task);
+    return (task: Task, searchInfo: SearchInfo) => {
+        const queryContext = searchInfo.queryContext();
+        return filterByFunction(expression, task, queryContext);
     };
 }
 
-export function filterByFunction(expression: TaskExpression, task: Task): boolean {
+export function filterByFunction(expression: TaskExpression, task: Task, queryContext?: QueryContext): boolean {
     // Allow exceptions to propagate to caller, since this will be called in a tight loop.
     // In searches, it will be caught by Query.applyQueryToTasks().
-    const result = expression.evaluate(task, undefined); // TODO when supporting query.file in filtering, add a QueryContext parameter
+    const result = expression.evaluate(task, queryContext);
 
     // We insist that 'filter by function' returns booleans,
     // to avoid users having to understand truthy and falsey values.
diff --git a/src/Scripting/TaskExpression.ts b/src/Scripting/TaskExpression.ts
index 54d0c381..22f259a4 100644
--- a/src/Scripting/TaskExpression.ts
+++ b/src/Scripting/TaskExpression.ts
@@ -68,7 +68,6 @@ export class TaskExpression {
      * @see evaluateOrCatch
      */
     public evaluate(task: Task, queryContext?: QueryContext) {
-        // TODO When 'filter by function' supports query properties, make queryContext non-optional and simplify its use below.
         if (!this.isValid()) {
             throw Error(
                 `Error: Cannot evaluate an expression which is not valid: "${this.line}" gave error: "${this.parseError}"`,


Test patch:
diff --git a/tests/Query/Filter/FunctionField.test.ts b/tests/Query/Filter/FunctionField.test.ts
index 6379df39..f4991184 100644
--- a/tests/Query/Filter/FunctionField.test.ts
+++ b/tests/Query/Filter/FunctionField.test.ts
@@ -13,6 +13,7 @@ import {
 } from '../../CustomMatchers/CustomMatchersForGrouping';
 import { TaskBuilder } from '../../TestingTools/TaskBuilder';
 import { SearchInfo } from '../../../src/Query/SearchInfo';
+import type { Task } from '../../../src/Task';
 
 window.moment = moment;
 
@@ -23,7 +24,7 @@ window.moment = moment;
 describe('FunctionField - filtering', () => {
     const functionField = new FunctionField();
 
-    it('filter by function - with valid query', () => {
+    it('filter by function - with valid query of Task property', () => {
         const filter = functionField.createFilterOrErrorMessage('filter by function task.description.length > 5');
         expect(filter).toBeValid();
         expect(filter).toMatchTaskWithDescription('123456');
@@ -31,6 +32,21 @@ describe('FunctionField - filtering', () => {
         expect(filter).not.toMatchTaskWithDescription('1234');
     });
 
+    it('filter by function - with valid query of Query property', () => {
+        const tasksInSameFileAsQuery = functionField.createFilterOrErrorMessage(
+            'filter by function task.file.path === query.file.path',
+        );
+        expect(tasksInSameFileAsQuery).toBeValid();
+        const queryFilePath = '/a/b/query.md';
+
+        const taskInQueryFile: Task = new TaskBuilder().path(queryFilePath).build();
+        const taskNotInQueryFile: Task = new TaskBuilder().path('some other path.md').build();
+        const searchInfo = new SearchInfo(queryFilePath, [taskInQueryFile, taskNotInQueryFile]);
+
+        expect(tasksInSameFileAsQuery.filterFunction!(taskInQueryFile, searchInfo)).toEqual(true);
+        expect(tasksInSameFileAsQuery.filterFunction!(taskNotInQueryFile, searchInfo)).toEqual(false);
+    });
+
     it('filter by function - should report syntax errors via FilterOrErrorMessage', () => {
         const instructionWithExtraClosingParen = 'filter by function task.status.name.toUpperCase())';
         const filter = functionField.createFilterOrErrorMessage(instructionWithExtraClosingParen);
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md
index 7c56ef4f..4839f6c5 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_docs.approved.md
@@ -15,14 +15,13 @@ filter by function task.file.folder.includes("Work/Projects/")
 - Find tasks in files in a specific folder **and any sub-folders**.
 
 ```javascript
-filter by function task.file.folder.includes( '{{query.file.folder}}' )
+filter by function task.file.folder.includes( query.file.folder )
 ```
 
 - Find tasks in files in the folder that contains the query **and any sub-folders**.
-- Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
 
 ```javascript
-filter by function task.file.folder === '{{query.file.folder}}'
+filter by function task.file.folder === query.file.folder
 ```
 
 - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt
index ea0916b9..2bbea7f1 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.file_properties_task.file.folder_results.approved.txt
@@ -18,9 +18,8 @@ Find tasks in files in a specific folder **and any sub-folders**.
 ====================================================================================
 
 
-filter by function task.file.folder.includes( '{{query.file.folder}}' )
+filter by function task.file.folder.includes( query.file.folder )
 Find tasks in files in the folder that contains the query **and any sub-folders**.
-Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.
 =>
 - [ ] xyz in a/b.md
 - [ ] xyz in a/b/c.md
@@ -31,7 +30,7 @@ Note that the placeholder text is expanded to a raw string, so needs to be insid
 ====================================================================================
 
 
-filter by function task.file.folder === '{{query.file.folder}}'
+filter by function task.file.folder === query.file.folder
 Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**).
 =>
 - [ ] xyz in a/b.md
diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
index c63bae47..c39a444e 100644
--- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
+++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts
@@ -235,12 +235,11 @@ describe('file properties', () => {
                     'Find tasks in files in a specific folder **and any sub-folders**',
                 ],
                 [
-                    "filter by function task.file.folder.includes( '{{query.file.folder}}' )",
+                    'filter by function task.file.folder.includes( query.file.folder )',
                     'Find tasks in files in the folder that contains the query **and any sub-folders**',
-                    'Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.',
                 ],
                 [
-                    "filter by function task.file.folder === '{{query.file.folder}}'",
+                    'filter by function task.file.folder === query.file.folder',
                     'Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**)',
                 ],
                 [
diff --git a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
index 293fd568..55fcf9f2 100644
--- a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
+++ b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts
@@ -85,13 +85,14 @@ export function verifyFunctionFieldFilterSamplesOnTasks(filters: QueryInstructio
         const instruction = filter[0];
         const comment = filter.slice(1);
 
-        const expandedInstruction = preprocessSingleInstruction(instruction, 'a/b.md');
+        const path = 'a/b.md';
+        const expandedInstruction = preprocessSingleInstruction(instruction, path);
         const filterOrErrorMessage = new FunctionField().createFilterOrErrorMessage(expandedInstruction);
         expect(filterOrErrorMessage).toBeValid();
 
         const filterFunction = filterOrErrorMessage.filterFunction!;
         const matchingTasks: string[] = [];
-        const searchInfo = SearchInfo.fromAllTasks(tasks);
+        const searchInfo = new SearchInfo(path, tasks);
         for (const task of tasks) {
             const matches = filterFunction(task, searchInfo);
             if (matches) {