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

Issue:
Make it easy to remove `.md` from `task.file.path`, `task.file.filename` and the query equivalents
### ⚠️ 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.
- [X] I am only requesting a single feature. Multiple changes should be split into individual requests, with links between them.
- [X] I believe my requested feature will be generally applicable to other users, not just me: it is not uniquely tied to my personal workflow.

### 🔖 Feature description

There have been a couple of cases of users wanting to use `query.file.filename` without the file extension `.md`:

---

- https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/2257#discussioncomment-7008068


> @FilSalustri If I understand your intention correctly, this is how you can now write your query using a pure Tasks filter, with needing to use Templater to embed some hard-coded test that won't update if the file gets renamed:
> 
> ```
> filter by function task.description.includes('[[{{query.file.filename}}]]'.replace('.md', ''))
> ```

Then....

> One small problem with that `filter by function`:
> 
> On my Mac, I can use a single quote as a legit filename character.
> So when `query.file.filename` gets expanded, it introduces an extra ' which throws an error - and rightly so.
> 
> Is there a way to get `query.file.filename `to escape such characters?

---

- [On Discord](https://discord.com/channels/686053708261228577/965681451297304596/1155297547938697237)

> Oh okay, I guess it wasn't working because query.file.filename includes the  ".md" at the end, which doesn't work for me


### ✔️ Solution

I would like to provide:

- `query.file.pathWithoutExtension` 
-  `query.file.filenameWithoutExtension`
- `task.file.pathWithoutExtension`
-  `task.file.filenameWithoutExtension`

### ❓ Alternatives

_No response_

### 📝 Additional Context

_No response_

Pull request description:
feat: Scripting enables removing file extension from path and filename properties
<!--- Provide a general summary of your changes in the Title above -->

# Description

Add these new properties:

- `query.file.pathWithoutExtension` 
-  `query.file.filenameWithoutExtension`
- `task.file.pathWithoutExtension`
-  `task.file.filenameWithoutExtension`


## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

Fixes #2287

- #2287

## How has this been tested?

- Adding new tests
- Running existing tests
- Built the plugin and did some exploratory testing

<!--- 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)

## 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] **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 b1f82e61..a0bc2d70 100644
--- a/docs/Introduction.md
+++ b/docs/Introduction.md
@@ -6,6 +6,8 @@ publish: true
 
 ## What's New?
 
+- X.Y.Z: 🔥 Add [[Query Properties#Values for Query File Properties|query file properties]] `query.file.pathWithoutExtension` and `query.file.filenameWithoutExtension`
+- X.Y.Z: 🔥 Add [[Task Properties#Values for File Properties|task file properties]] `task.file.pathWithoutExtension` and `task.file.filenameWithoutExtension`
 - 4.7.0: 🔥 Use [[Query Properties]] and [[Placeholders]] to filter and group with the query's file path, root, folder and name.
 - 4.6.0: 🔥 Add `on or before` and `on or after` to [[Filters#Date search options|date search options]]
 - 4.6.0: 🔥 Add `in or before` and `in or after` to [[Filters#Date range options|date range search search options]]
diff --git a/docs/Queries/Filters.md b/docs/Queries/Filters.md
index 6ac8ce46..2d05f3a3 100644
--- a/docs/Queries/Filters.md
+++ b/docs/Queries/Filters.md
@@ -975,7 +975,7 @@ Note that the path includes the `.md` extension.
 
 - `path (includes|does not include) <path>`
   - Matches case-insensitive (disregards capitalization).
-  - Use `{{query.file.path}}` as a placeholder for the path of the file containing the current query.
+  - Use `{{query.file.path}}` or `{{query.file.pathWithoutExtension}}` as a placeholder for the path of the file containing the current query.
     - For example, `path equals {{query.file.path}}`
     - Useful reading: [[Query Properties]] and [[Placeholders]]
 - `path (regex matches|regex does not match) /<JavaScript-style Regex>/`
@@ -989,6 +989,8 @@ Note that the path includes the `.md` extension.
 
 Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file path** is now possible, using `task.file.path`.
 
+In Tasks X.Y.Z `task.file.pathWithoutExtension` was added.
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.path_docs.approved.md -->
 
 - ```filter by function task.file.path.includes('tasks releases/4.1.0 Release.md')```
@@ -1083,7 +1085,7 @@ Note that the file name includes the `.md` extension.
 
 - `filename (includes|does not include) <filename>`
   - Matches case-insensitive (disregards capitalization).
-  - Use `{{query.file.filename}}` as a placeholder for the file name of the file containing the current query.
+  - Use `{{query.file.filename}}`  or `{{query.file.filenameWithoutExtension}}` as a placeholder for the file name of the file containing the current query.
     - For example, `filename includes {{query.file.filename}}`
     - Useful reading: [[Query Properties]] and [[Placeholders]]
 - `filename (regex matches|regex does not match) /<JavaScript-style Regex>/`
@@ -1092,6 +1094,8 @@ Note that the file name includes the `.md` extension.
 
 Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file name** is now possible, using `task.file.filename`.
 
+In Tasks X.Y.Z `task.file.filenameWithoutExtension` was added.
+
 <!-- placeholder to force blank line before included text --><!-- include: CustomFilteringExamples.test.file_properties_task.file.filename_docs.approved.md -->
 
 - ```filter by function task.file.filename === "4.1.0 Release.md"```
diff --git a/docs/Queries/Grouping.md b/docs/Queries/Grouping.md
index 6e0481bf..84046c67 100644
--- a/docs/Queries/Grouping.md
+++ b/docs/Queries/Grouping.md
@@ -532,7 +532,9 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by file path** is now p
 
 Since Tasks 4.7.0, the query's file path can be used in custom groups.
 
-- It must be quoted: `'{{query.file.folder}}'`
+- It must be quoted:
+  - `'{{query.file.path}}'` or
+  - `'{{query.file.pathWithoutExtension}}'` (since Tasks X.Y.Z)
 - Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped.
 - Useful reading: [[Query Properties]] and [[Placeholders]].
 
@@ -595,14 +597,16 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by file name** is now p
 
 - ```group by function task.file.filename```
   - Like 'group by filename' but does not link to the file.
-- ```group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
+- ```group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
   - Like 'group by backlink' but links to the heading in the file.
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
 Since Tasks 4.7.0, the query's file name can be used in custom groups.
 
-- It must be quoted: `'{{query.file.filename}}'`
+- It must be quoted:
+  - `'{{query.file.filename}}'` or
+  - `'{{query.file.filenameWithoutExtension}}'` (since Tasks X.Y.Z)
 - Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped.
 - Useful reading: [[Query Properties]] and [[Placeholders]].
 
diff --git a/docs/Scripting/Query Properties.md b/docs/Scripting/Query Properties.md
index ffe060b6..af7830cc 100644
--- a/docs/Scripting/Query Properties.md
+++ b/docs/Scripting/Query Properties.md
@@ -30,12 +30,16 @@ This page documents all the available pieces of information in Queries that you
 | Field | Type | Example |
 | ----- | ----- | ----- |
 | `query.file.path` | `string` | `'root/sub-folder/file containing query.md'` |
+| `query.file.pathWithoutExtension` | `string` | `'root/sub-folder/file containing query'` |
 | `query.file.root` | `string` | `'root/'` |
 | `query.file.folder` | `string` | `'root/sub-folder/'` |
 | `query.file.filename` | `string` | `'file containing query.md'` |
+| `query.file.filenameWithoutExtension` | `string` | `'file containing query'` |
 
 <!-- placeholder to force blank line after included text --><!-- endInclude -->
 
 1. `query.file` is a `TasksFile` object.
 1. You can see the current [TasksFile source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksFile.ts), to explore its capabilities.
 1. The presence of `.md` filename extensions is chosen to match the existing conventions in the Tasks filter instructions [[Filters#File Path|path]] and [[Filters#File Name|filename]].
+1. `query.file.pathWithoutExtension` was added in Tasks X.Y.Z.
+1. `query.file.filenameWithoutExtension` was added in Tasks X.Y.Z.
diff --git a/docs/Scripting/Task Properties.md b/docs/Scripting/Task Properties.md
index 3fe2a3ac..478776ae 100644
--- a/docs/Scripting/Task Properties.md
+++ b/docs/Scripting/Task Properties.md
@@ -97,9 +97,11 @@ For more information, including adding your own customised statuses, see [[Statu
 | Field | Type 1 | Example 1 | Type 2 | Example 2 |
 | ----- | ----- | ----- | ----- | ----- |
 | `task.file.path` | `string` | `'some/folder/fileName.md'` | `string` | `''` |
+| `task.file.pathWithoutExtension` | `string` | `'some/folder/fileName'` | `string` | `''` |
 | `task.file.root` | `string` | `'some/'` | `string` | `'/'` |
 | `task.file.folder` | `string` | `'some/folder/'` | `string` | `'/'` |
 | `task.file.filename` | `string` | `'fileName.md'` | `string` | `''` |
+| `task.file.filenameWithoutExtension` | `string` | `'fileName'` | `string` | `''` |
 | `task.hasHeading` | `boolean` | `true` | `boolean` | `false` |
 | `task.heading` | `string` | `'My Header'` | `null` | `null` |
 
@@ -108,3 +110,5 @@ For more information, including adding your own customised statuses, see [[Statu
 1. `task.file` is a `TasksFile` object.
 1. You can see the current [TasksFile source code](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/src/Scripting/TasksFile.ts), to explore its capabilities.
 1. The presence of `.md` filename extensions is chosen to match the existing conventions in the Tasks filter instructions [[Filters#File Path|path]] and [[Filters#File Name|filename]].
+1. `task.file.pathWithoutExtension` was added in Tasks X.Y.Z.
+1. `task.file.filenameWithoutExtension` was added in Tasks X.Y.Z.
diff --git a/src/Scripting/TasksFile.ts b/src/Scripting/TasksFile.ts
index 3c597bc0..44e540b1 100644
--- a/src/Scripting/TasksFile.ts
+++ b/src/Scripting/TasksFile.ts
@@ -15,6 +15,17 @@ export class TasksFile {
         return this._path;
     }
 
+    /**
+     * Return the path to the file, with the filename extension removed.
+     */
+    get pathWithoutExtension(): string {
+        return this.withoutExtension(this.path);
+    }
+
+    private withoutExtension(value: string) {
+        return value.replace(/\.md$/, '');
+    }
+
     /**
      * Return the root to the file.
      */
@@ -54,4 +65,8 @@ export class TasksFile {
             return '';
         }
     }
+
+    get filenameWithoutExtension(): string {
+        return this.withoutExtension(this.filename);
+    }
 }


Test patch:
diff --git a/tests/Scripting/QueryProperties.test.query_file_properties.approved.md b/tests/Scripting/QueryProperties.test.query_file_properties.approved.md
index fd6709cd..46fae447 100644
--- a/tests/Scripting/QueryProperties.test.query_file_properties.approved.md
+++ b/tests/Scripting/QueryProperties.test.query_file_properties.approved.md
@@ -3,9 +3,11 @@
 | Field | Type | Example |
 | ----- | ----- | ----- |
 | `query.file.path` | `string` | `'root/sub-folder/file containing query.md'` |
+| `query.file.pathWithoutExtension` | `string` | `'root/sub-folder/file containing query'` |
 | `query.file.root` | `string` | `'root/'` |
 | `query.file.folder` | `string` | `'root/sub-folder/'` |
 | `query.file.filename` | `string` | `'file containing query.md'` |
+| `query.file.filenameWithoutExtension` | `string` | `'file containing query'` |
 
 
 <!-- placeholder to force blank line after included text -->
diff --git a/tests/Scripting/QueryProperties.test.ts b/tests/Scripting/QueryProperties.test.ts
index ed4e4dc8..92cfc11e 100644
--- a/tests/Scripting/QueryProperties.test.ts
+++ b/tests/Scripting/QueryProperties.test.ts
@@ -24,9 +24,11 @@ describe('query', () => {
     it('file properties', () => {
         verifyFieldDataForReferenceDocs([
             'query.file.path',
+            'query.file.pathWithoutExtension',
             'query.file.root',
             'query.file.folder',
             'query.file.filename',
+            'query.file.filenameWithoutExtension',
         ]);
     });
 });
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md
index 09d2ba31..d5f6b918 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_docs.approved.md
@@ -2,7 +2,7 @@
 
 - ```group by function task.file.filename```
     - Like 'group by filename' but does not link to the file.
-- ```group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
+- ```group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'```
     - Like 'group by backlink' but links to the heading in the file.
 
 
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt
index f4c0fe08..56faa75f 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.filename_results.approved.txt
@@ -12,7 +12,7 @@ c.md
 ====================================================================================
 
 
-group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'
+group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'
 Like 'group by backlink' but links to the heading in the file.
 =>
 [[_c_]]
diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
index 998e5025..5e3674ea 100644
--- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
+++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts
@@ -234,7 +234,7 @@ describe('file properties', () => {
             [
                 ['group by function task.file.filename', "Like 'group by filename' but does not link to the file"],
                 [
-                    "group by function  '[[' + task.file.filename.replace('.md', '') + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'",
+                    "group by function  '[[' + task.file.filenameWithoutExtension + ( task.hasHeading ? ('#' + task.heading) : '')  + ']]'",
                     "Like 'group by backlink' but links to the heading in the file",
                 ],
             ],
diff --git a/tests/Scripting/TaskProperties.test.task_file_properties.approved.md b/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
index de861ee4..2a54f7ad 100644
--- a/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
+++ b/tests/Scripting/TaskProperties.test.task_file_properties.approved.md
@@ -3,9 +3,11 @@
 | Field | Type 1 | Example 1 | Type 2 | Example 2 |
 | ----- | ----- | ----- | ----- | ----- |
 | `task.file.path` | `string` | `'some/folder/fileName.md'` | `string` | `''` |
+| `task.file.pathWithoutExtension` | `string` | `'some/folder/fileName'` | `string` | `''` |
 | `task.file.root` | `string` | `'some/'` | `string` | `'/'` |
 | `task.file.folder` | `string` | `'some/folder/'` | `string` | `'/'` |
 | `task.file.filename` | `string` | `'fileName.md'` | `string` | `''` |
+| `task.file.filenameWithoutExtension` | `string` | `'fileName'` | `string` | `''` |
 | `task.hasHeading` | `boolean` | `true` | `boolean` | `false` |
 | `task.heading` | `string` | `'My Header'` | `null` | `null` |
 
diff --git a/tests/Scripting/TaskProperties.test.ts b/tests/Scripting/TaskProperties.test.ts
index a25d3157..e4f0e839 100644
--- a/tests/Scripting/TaskProperties.test.ts
+++ b/tests/Scripting/TaskProperties.test.ts
@@ -84,9 +84,11 @@ describe('task', () => {
     it('file properties', () => {
         verifyFieldDataForReferenceDocs([
             'task.file.path',
+            'task.file.pathWithoutExtension',
             'task.file.root',
             'task.file.folder',
             'task.file.filename',
+            'task.file.filenameWithoutExtension',
             'task.hasHeading',
             'task.heading',
         ]);
diff --git a/tests/Scripting/TasksFile.test.ts b/tests/Scripting/TasksFile.test.ts
index 53ba8dfa..4fb8cf8d 100644
--- a/tests/Scripting/TasksFile.test.ts
+++ b/tests/Scripting/TasksFile.test.ts
@@ -7,6 +7,13 @@ describe('TasksFile', () => {
         expect(file.path).toEqual(path);
     });
 
+    it('should provide access to path without extension', () => {
+        const path = 'a/b/c/d.md';
+        const file = new TasksFile(path);
+        expect(file.pathWithoutExtension).toEqual('a/b/c/d');
+        expect(new TasksFile('/root/folder.mds/file.md').pathWithoutExtension).toStrictEqual('/root/folder.mds/file');
+    });
+
     it('should provide access to the root', () => {
         expect(new TasksFile('').root).toStrictEqual('/');
         expect(new TasksFile('outside/inside/A.md').root).toStrictEqual('outside/');
@@ -25,4 +32,17 @@ describe('TasksFile', () => {
         expect(new TasksFile('file in root.md').filename).toEqual('file in root.md');
         expect(new TasksFile('directory name/file in sub-directory.md').filename).toEqual('file in sub-directory.md');
     });
+
+    it('should provide access to the filename without extension', () => {
+        expect(new TasksFile('').filenameWithoutExtension).toEqual('');
+        expect(new TasksFile('file in root.md').filenameWithoutExtension).toEqual('file in root');
+        expect(new TasksFile('directory name/file in sub-directory.md').filenameWithoutExtension).toEqual(
+            'file in sub-directory',
+        );
+        // Check it only replaces the last .md
+        expect(new TasksFile('1.md.only-replace.2.md').filenameWithoutExtension).toEqual('1.md.only-replace.2');
+
+        // Check it escapes the '.' in the file extension
+        expect(new TasksFile('1.md.only-replace.2,md').filenameWithoutExtension).toEqual('1.md.only-replace.2,md');
+    });
 });