Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2450 (2023-11-27 22:05:11)

refactor,docs: Refactor code & document case-insensitive queries
<!--- Provide a general summary of your changes in the Title above -->

# Description

These are all changes to un-released code....

- Slight simplification of the implementation of #2418
- `explain` now reflects the case of the original line in most situations
- Add some more tests
- And add more info to the documentation

## Motivation and Context

- Make it ready for release

## How has this been tested?

- Via existing and new tests
- Exploratory testing in the demo vault

## 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] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
    - `explain` now honours case of original query in more cases
- [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 88bfd1e9..8b1733ea 100644
--- a/docs/Introduction.md
+++ b/docs/Introduction.md
@@ -13,6 +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: 🔥 Most query instructions can now include [[About Queries#Capitals in Query Instructions - Case Insensitivity|capital letters]].
 - 5.1.0: 🔥 Add 'Review and check your Statuses' facility: see [[Check your Statuses|check your statuses]].
 - 5.1.0: 🔥 Enable [[Custom Filters|custom filters]] and [[Custom Grouping|custom grouping]] to use [[Query Properties|query properties]] directly - no placeholders required.
 
diff --git a/docs/Queries/About Queries.md b/docs/Queries/About Queries.md
index 45f54e7b..f3e456c8 100644
--- a/docs/Queries/About Queries.md
+++ b/docs/Queries/About Queries.md
@@ -22,7 +22,6 @@ In Live Preview and Reading modes, this will list *all* tasks from your vault, r
 
 This is probably not what you want.
 Therefore, Tasks allows you to set query options to filter the tasks that you want to show.
-*All the queries are case insensitive.*
 
 For instance, you can show only the tasks (from anywhere in the vault) that are due today:
 
@@ -69,6 +68,30 @@ In the following sections we will explain all the various options that are avail
 - [[Grouping]]
 - [[Layout]]
 
+## Query Tips
+
+### Capitals in Query Instructions - Case Insensitivity
+
+> [!released]
+> The ability to use capital letters in query instructions was introduced in Tasks X.Y.Z.
+
+Almost all Tasks query instructions are now case-INsensitive: they can now be typed with capital letters. This is especially helpful when typing them on mobile phones, and for emphasising important words.
+
+For example, the following instructions are identical:
+
+- `due before tomorrow`
+- `Due before tomorrow`
+- `due BEFORE Tomorrow`
+
+The only exceptions to this flexibility are:
+
+- When [[Combining Filters]], the boolean operators such as `AND`, `OR` and `NOT` must still be capitalised.
+- In [[Regular Expressions]], the search pattern and flags are still case-sensitive.
+
+### Why is my query not working?
+
+If a query gives unexpected results, see [[Explaining Queries]] and add the `explain` instruction.
+
 ## Limitations of Queries
 
 > [!warning]
diff --git a/docs/Queries/Examples.md b/docs/Queries/Examples.md
index 27dfd083..bbd99527 100644
--- a/docs/Queries/Examples.md
+++ b/docs/Queries/Examples.md
@@ -17,10 +17,12 @@ All open tasks that are due within the next two weeks, but are not overdue (due
 
     ```tasks
     not done
-    due after yesterday
-    due before in two weeks
+    due AFTER yesterday
+    due BEFORE in two weeks
     ```
 
+Most query instructions can include [[About Queries#Capitals in Query Instructions - Case Insensitivity|capital letters]], which are used here for emphasis.
+
 ---
 
 All open tasks that are due within the next two weeks, and either have the `#inbox` tag, or are in an Inbox note:
@@ -31,6 +33,8 @@ All open tasks that are due within the next two weeks, and either have the `#inb
     (tags include #inbox) OR (path includes Inbox)
     ```
 
+(The `AND` and `OR` must be in capitals.)
+
 ---
 
 All open tasks due during the next calendar month but not scheduled:
diff --git a/docs/_meta/templates/insert line in to What's New.md b/docs/_meta/templates/insert line in to What's New.md
new file mode 100644
index 00000000..7248d9fc
--- /dev/null
+++ b/docs/_meta/templates/insert line in to What's New.md
@@ -0,0 +1,1 @@
+- X.Y.Z: 🔥 
\ No newline at end of file
diff --git a/src/Query/Filter/DateField.ts b/src/Query/Filter/DateField.ts
index 15077d17..ae98fd9b 100644
--- a/src/Query/Filter/DateField.ts
+++ b/src/Query/Filter/DateField.ts
@@ -38,7 +38,6 @@ export abstract class DateField extends Field {
     }
 
     public canCreateFilterForLine(line: string): boolean {
-        line = line?.toLowerCase();
         if (this.filterInstructions.canCreateFilterForLine(line)) {
             return true;
         }
@@ -55,7 +54,7 @@ export abstract class DateField extends Field {
             return FilterOrErrorMessage.fromError(line, errorText);
         }
 
-        const filterResult = this.filterInstructions.createFilterOrErrorMessage(line.toLowerCase());
+        const filterResult = this.filterInstructions.createFilterOrErrorMessage(line);
         if (filterResult.filter !== undefined) {
             return filterResult;
         }
diff --git a/src/Query/Filter/FilterInstruction.ts b/src/Query/Filter/FilterInstruction.ts
index c41e3446..2bafec66 100644
--- a/src/Query/Filter/FilterInstruction.ts
+++ b/src/Query/Filter/FilterInstruction.ts
@@ -19,7 +19,7 @@ export class FilterInstruction {
 
     /**
      * Constructor:
-     * @param instruction - Full text of the instruction for the filter: must be matched exactly
+     * @param instruction - Full text of the instruction for the filter: must be matched exactly, ignoring capitalisation.
      * @param filter
      */
     constructor(instruction: string, filter: FilterFunction) {
@@ -28,11 +28,11 @@ export class FilterInstruction {
     }
 
     public canCreateFilterForLine(line: string): boolean {
-        return line == this._instruction;
+        return line.toLocaleLowerCase() === this._instruction.toLocaleLowerCase();
     }
 
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
-        if (line === this._instruction) {
+        if (this.canCreateFilterForLine(line)) {
             return FilterOrErrorMessage.fromFilter(new Filter(line, this._filter, new Explanation(line)));
         }
 
diff --git a/src/Query/Filter/FilterInstructionsBasedField.ts b/src/Query/Filter/FilterInstructionsBasedField.ts
index 52ceba59..e8069cfc 100644
--- a/src/Query/Filter/FilterInstructionsBasedField.ts
+++ b/src/Query/Filter/FilterInstructionsBasedField.ts
@@ -12,11 +12,11 @@ export abstract class FilterInstructionsBasedField extends Field {
     protected readonly _filters = new FilterInstructions();
 
     public canCreateFilterForLine(line: string): boolean {
-        return this._filters.canCreateFilterForLine(line.toLowerCase());
+        return this._filters.canCreateFilterForLine(line);
     }
 
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
-        return this._filters.createFilterOrErrorMessage(line.toLowerCase());
+        return this._filters.createFilterOrErrorMessage(line);
     }
 
     protected filterRegExp(): RegExp | null {
diff --git a/src/Query/Filter/TagsField.ts b/src/Query/Filter/TagsField.ts
index 279af888..819ed0cb 100644
--- a/src/Query/Filter/TagsField.ts
+++ b/src/Query/Filter/TagsField.ts
@@ -27,7 +27,7 @@ export class TagsField extends MultiTextField {
     }
 
     public createFilterOrErrorMessage(line: string): FilterOrErrorMessage {
-        const filterResult = this.filterInstructions.createFilterOrErrorMessage(line.toLowerCase());
+        const filterResult = this.filterInstructions.createFilterOrErrorMessage(line);
         if (filterResult.filter !== undefined) {
             return filterResult;
         }
@@ -36,7 +36,7 @@ export class TagsField extends MultiTextField {
     }
 
     public canCreateFilterForLine(line: string): boolean {
-        if (this.filterInstructions.canCreateFilterForLine(line.toLowerCase())) {
+        if (this.filterInstructions.canCreateFilterForLine(line)) {
             return true;
         }
 


Test patch:
diff --git a/tests/Query/Filter/DateField.test.ts b/tests/Query/Filter/DateField.test.ts
index 5930dc9d..ea8d5e8b 100644
--- a/tests/Query/Filter/DateField.test.ts
+++ b/tests/Query/Filter/DateField.test.ts
@@ -31,4 +31,9 @@ describe('DateField', () => {
         expect(filter).not.toBeValid();
         expect(filter.error).toContain('Instruction contains unexpanded template text');
     });
+
+    it('should honour original case, when explaining simple filters', () => {
+        const filter = new ScheduledDateField().createFilterOrErrorMessage('HAS SCHEDULED DATE');
+        expect(filter).toHaveExplanation('HAS SCHEDULED DATE');
+    });
 });
diff --git a/tests/Query/Filter/ExcludeSubItemsField.test.ts b/tests/Query/Filter/ExcludeSubItemsField.test.ts
index b2443877..2678b5c4 100644
--- a/tests/Query/Filter/ExcludeSubItemsField.test.ts
+++ b/tests/Query/Filter/ExcludeSubItemsField.test.ts
@@ -12,6 +12,7 @@ describe('sub-items', () => {
         testTaskFilter(filter, fromLine({ line: '  - [ ] Subtask1' }), false);
         testTaskFilter(filter, fromLine({ line: '    - [ ] Subtask2' }), false);
     });
+
     it('subitem has more than one space after last > of blockquotes or callouts', () => {
         // Arrange
         const filter = new ExcludeSubItemsField().createFilterOrErrorMessage('exclude sub-items');
@@ -22,4 +23,9 @@ describe('sub-items', () => {
         testTaskFilter(filter, fromLine({ line: '>>  - [ ] Subtask1' }), false);
         testTaskFilter(filter, fromLine({ line: '> >  - [ ] Subtask2' }), false);
     });
+
+    it('should honour original case, when explaining simple filters', () => {
+        const filter = new ExcludeSubItemsField().createFilterOrErrorMessage('EXCLUDE sub-items');
+        expect(filter).toHaveExplanation('EXCLUDE sub-items');
+    });
 });
diff --git a/tests/Query/Filter/FilterInstruction.test.ts b/tests/Query/Filter/FilterInstruction.test.ts
new file mode 100644
index 00000000..0eb72d43
--- /dev/null
+++ b/tests/Query/Filter/FilterInstruction.test.ts
@@ -0,0 +1,25 @@
+import { FilterInstruction } from '../../../src/Query/Filter/FilterInstruction';
+import type { FilterFunction } from '../../../src/Query/Filter/Filter';
+import type { Task } from '../../../src/Task';
+
+describe('FilterInstruction', () => {
+    const filter: FilterFunction = (_task: Task) => true;
+    const filterInstruction = new FilterInstruction('find me', filter);
+
+    it('canCreateFilterForLine should be case-insensitive exact match', () => {
+        expect(filterInstruction.canCreateFilterForLine('FIND ME')).toEqual(true);
+        expect(filterInstruction.canCreateFilterForLine('xFIND ME')).toEqual(false);
+        expect(filterInstruction.canCreateFilterForLine('FIND MEx')).toEqual(false);
+    });
+
+    it('createFilterOrErrorMessage should be case-insensitive', () => {
+        const filterOrErrorMessage = filterInstruction.createFilterOrErrorMessage('FIND ME');
+        expect(filterOrErrorMessage).toBeValid();
+    });
+
+    it('explanation should be case-insensitive', () => {
+        const line = 'FIND ME';
+        const filterOrErrorMessage = filterInstruction.createFilterOrErrorMessage(line);
+        expect(filterOrErrorMessage).toHaveExplanation(line);
+    });
+});
diff --git a/tests/Query/Filter/FunctionField.test.ts b/tests/Query/Filter/FunctionField.test.ts
index f4991184..af9ba7c0 100644
--- a/tests/Query/Filter/FunctionField.test.ts
+++ b/tests/Query/Filter/FunctionField.test.ts
@@ -70,6 +70,11 @@ describe('FunctionField - filtering', () => {
         expect(t).toThrow(Error);
         expect(t).toThrowError('filtering function must return true or false. This returned "undefined".');
     });
+
+    it('filter by function - explanation should honour original case', () => {
+        const filter = functionField.createFilterOrErrorMessage('filter by FUNCTION task.isRecurring');
+        expect(filter).toHaveExplanation('filter by FUNCTION task.isRecurring');
+    });
 });
 
 // -----------------------------------------------------------------------------------------------------------------
diff --git a/tests/Query/Filter/PriorityField.test.ts b/tests/Query/Filter/PriorityField.test.ts
index 2c67bcb5..6b73fd56 100644
--- a/tests/Query/Filter/PriorityField.test.ts
+++ b/tests/Query/Filter/PriorityField.test.ts
@@ -147,7 +147,7 @@ describe('priority error cases', () => {
 describe('explain priority', () => {
     it('simple case just repeats the supplied line', () => {
         const field = new PriorityField();
-        const instruction = 'priority above none';
+        const instruction = 'priority above NONE';
         const filterOrMessage = field.createFilterOrErrorMessage(instruction);
         expect(filterOrMessage).toHaveExplanation(instruction);
     });
diff --git a/tests/Query/Filter/RecurringField.test.ts b/tests/Query/Filter/RecurringField.test.ts
index 4aeb6b45..25de43e6 100644
--- a/tests/Query/Filter/RecurringField.test.ts
+++ b/tests/Query/Filter/RecurringField.test.ts
@@ -46,6 +46,11 @@ describe('recurring', () => {
         testRecurringFilter(filter, recurring, false);
         testRecurringFilter(filter, invalid, true);
     });
+
+    it('should honour original case, when explaining simple filters', () => {
+        const filter = new RecurringField().createFilterOrErrorMessage('is NOT recurring');
+        expect(filter).toHaveExplanation('is NOT recurring');
+    });
 });
 
 describe('sorting by recurring', () => {
diff --git a/tests/Query/Filter/StatusField.test.ts b/tests/Query/Filter/StatusField.test.ts
index 3cd260f6..d1fd712b 100644
--- a/tests/Query/Filter/StatusField.test.ts
+++ b/tests/Query/Filter/StatusField.test.ts
@@ -40,6 +40,11 @@ describe('status', () => {
         expect(filter).toMatchTaskWithStatus(new StatusConfiguration('!', 'Todo', 'x', true, StatusType.TODO)); // 'not done' type.
         expect(filter).not.toMatchTaskWithStatus(new StatusConfiguration('^', 'Non', 'x', true, StatusType.NON_TASK));
     });
+
+    it('should honour original case, when explaining simple filters', () => {
+        const filter = new StatusField().createFilterOrErrorMessage('NOT done');
+        expect(filter).toHaveExplanation('NOT done');
+    });
 });
 
 describe('sorting by status', () => {
diff --git a/tests/Query/Filter/TagsField.test.ts b/tests/Query/Filter/TagsField.test.ts
index e3b30c6d..f10e00c3 100644
--- a/tests/Query/Filter/TagsField.test.ts
+++ b/tests/Query/Filter/TagsField.test.ts
@@ -71,6 +71,11 @@ describe('tag presence & absence', () => {
 
         GlobalFilter.getInstance().reset();
     });
+
+    it('should honour original case, when explaining simple filters', () => {
+        const filter = new TagsField().createFilterOrErrorMessage('HAS TAGS');
+        expect(filter).toHaveExplanation('HAS TAGS');
+    });
 });
 
 describe('tag/tags', () => {
diff --git a/tests/Query/Filter/TextField.test.ts b/tests/Query/Filter/TextField.test.ts
index 5866e174..eb0b4e72 100644
--- a/tests/Query/Filter/TextField.test.ts
+++ b/tests/Query/Filter/TextField.test.ts
@@ -48,6 +48,11 @@ describe('explains regular sub-string searches', () => {
         const field = new DescriptionField().createFilterOrErrorMessage(instruction);
         expect(field).toHaveExplanation('description includes hello');
     });
+
+    it('should explain simple string search, preserving instruction case', () => {
+        const field = new DescriptionField().createFilterOrErrorMessage('description INCLUDES hello');
+        expect(field).toHaveExplanation('description INCLUDES hello');
+    });
 });
 
 describe('explains regular expression searches', () => {