From 8237ef0ede91d2a334c413334227c5317f32de8b Mon Sep 17 00:00:00 2001 From: Ilyas Landikov <93825870+ilandikov@users.noreply.github.com> Date: Tue, 12 Sep 2023 14:49:31 +0600 Subject: [PATCH] feat: support placeholders like '{{query.file.path}}' in queries (#2254) * feat: @ bring changes from bleeding-edge * vault: Add sample file demonstrating {{query.file.path}} etc * docs: Update docs on finding tasks in current file * docs: Fix typo in 'How to get tasks in current file' * test: Copy in MustacheExperiments.test.ts from claremacrae fork Copied from https://github.com/claremacrae/obsidian-tasks/blob/ae657e0c09b6a040b9a94e81a2b6478a1828c7e6/tests/Query/MustacheExperiments.test.ts * test: Rename MustacheExperiments.test.ts to ExpandTemplate.test.ts * test: Generate table of query.file values, for use in docs * docs: Fix column alignment in Quick Reference markdown source * docs: Add {{query.file.path}} etc to Quick Reference * docs: Interim commit of templating documentation * fix: Query.explainQuery() reports any error message, to avoid misleading results. * test: Show explanation of instructions using templating. * docs: Update 'Explaining Queries.md' to explain templating * docs: Add 'Checking template variables' to Templating.md * docs: Remove some clutter from early experiments * docs: Record the name of the templating library used. * docs: Add "Comments" plugin to track notes to track my feedback * docs: Interim commit on documentation templating code * test: Show the output when an invalid template variable is used. * docs: markdownlint removed stray whitespace at end of snippets * test: Remove 'explain' instruction which is not needed where snippet is used * docs: Document how invalid template variables are handled * docs: Rename Templating to Placeholders in documentation * refactor: . Rename expandMustacheTemplate() to expandPlaceholders() * refactor: . Rename ExpandTemplate.ts to ExpandPlaceholders.ts * test: . Rename ExpandTemplate.test.ts to ExpandPlaceholders.test.ts * refactor: Move ExpandPlaceholders.ts to src/Scripting/ * tests: . Move ExpandPlaceholders.test.ts to tests/Scripting/ * test: - Reduce scope of a variable in ExpandPlaceholders.test.ts * test: t Add test for expanding placeholders with no query location supplied * fix: Replace 'template' with 'placeholder' in an error message * test: Capture current behaviour: Query.source still has placeholders * test: Show that filters have placeholder expanded, and rename test * refactor: - Remove Query.rawSource as it was same as Query.source * test: Show behaviour when invalid placeholder is used * fix: Update vocabulary: template -> placeholder in error message. * test: Move placeholder tests to parsing section * fix: Remove reference to Mustache library in error messages * docs: Update error text in Placeholders.md - via mdsnippets * comment: Correct function name in TODO to expandPlaceholders() * test: Remove a test that now serves no value * docs: Convert 'templating' vocabulary to 'placeholders' * test: Remove a reference to templating, in approved filenames * docs: Remove a reference to templating, in approved filenames * comment: Convert 'templating' vocabulary to 'placeholders' in Query.ts * refactor: - Extract method Query.expandPlaceholders() * refactor: . inline makeQueryContext() * jsdoc: Document new functions and types * refactor: . Move QueryContext to Scripting/ directory from lib/ * jsdoc: Delete some out-of-date comments and move mustache link to ExpandPlaceholders.ts * test: Add comments to give 2 tests some structure * test: Add another test showing unknown property errors are displayed * refactor: Move the code for improving Mustache errors to ExpandPlaceholders.ts * comment: Convert 'template' vocabulary to 'placeholders' in ExpandPlaceholders.ts * refactor: . Extract variable source * refactor: . Add source parameter to Query.expandPlaceholders() * refactor: - Show user the exact line with unrecognised placeholder(s) * refactor: - Slightly more general error text in expandPlaceholders() * test: - Increase scope of path variable, to allow re-use * test: - Confirm that mustache only does expansion if {{ is in the input * refactor: - Indent user inputs in error messages, for readability * docs: Update Error-checking section for better messages * docs: Near finalisation of Placeholders.md * docs: Document basic use of placeholders in Filters page * test: Add sample file 'a/b.md' to allPathsAndHeadings() - needed to demo placeholders in docs * test: Add examples of placeholders in custom filters * test: Add example of placeholders in custom groups * docs: Update machined-generated examples in docs for placeholders * docs: Document basic use of placeholders in Grouping page * docs: Document placeholders in custom filters and groups * docs: Add query properties and placeholders to Introduction * vault: Remove "Comments" plugin as no longer needed --------- Co-authored-by: Clare Macrae --- .../How to get tasks in current file.md | 42 +++--- docs/Introduction.md | 1 + docs/Queries/Explaining Queries.md | 33 +++++ docs/Queries/Filters.md | 35 ++++- docs/Queries/Grouping.md | 29 ++++ docs/Quick Reference.md | 96 +++++++------- docs/Scripting/About Scripting.md | 5 + docs/Scripting/Custom Filters.md | 16 ++- docs/Scripting/Custom Grouping.md | 9 ++ docs/Scripting/Placeholders.md | 124 ++++++++++++++++++ docs/Scripting/Query Properties.md | 41 ++++++ package.json | 3 + ... in file or folder containing the Query.md | 20 +++ src/Query/Query.ts | 59 ++++++++- src/QueryRenderer.ts | 8 +- src/Scripting/ExpandPlaceholders.ts | 45 +++++++ src/Scripting/QueryContext.ts | 30 +++++ src/lib/QueryRendererHelper.ts | 14 +- tests/Query.test.ts | 67 ++++++++++ ...ain_placeholders.approved.explanation.text | 9 ++ ...t.explain_placeholders.approved.query.text | 6 + ...aceholders_error.approved.explanation.text | 10 ++ ...ain_placeholders_error.approved.query.text | 4 + .../Explain/DocsSamplesForExplain.test.ts | 26 ++++ tests/Query/Filter/BacklinkField.test.ts | 1 + tests/Query/Filter/FilenameField.test.ts | 2 +- tests/Query/Filter/FolderField.test.ts | 2 +- tests/Query/Filter/PathField.test.ts | 1 + tests/Scripting/ExpandPlaceholders.test.ts | 67 ++++++++++ tests/Scripting/QueryContext.test.ts | 38 ++++++ ...ies.test.query_file_properties.approved.md | 11 ++ tests/Scripting/QueryProperties.test.ts | 32 +++++ ...operties_task.file.folder_docs.approved.md | 7 +- ...ties_task.file.folder_results.approved.txt | 22 +++- .../CustomFilteringExamples.test.ts | 11 +- ...es_task.file.filename_results.approved.txt | 2 + ...ties_task.file.folder_results.approved.txt | 2 + ...properties_task.file.path_docs.approved.md | 5 + ...erties_task.file.path_results.approved.txt | 16 +++ .../CustomGroupingExamples.test.ts | 7 + .../VerifyFunctionFieldSamples.ts | 11 +- tests/TestHelpers.ts | 1 + tests/TestingTools/ApprovalTestHelpers.ts | 2 +- tests/lib/QueryRendererHelper.test.ts | 22 +++- yarn.lock | 15 +++ 45 files changed, 911 insertions(+), 98 deletions(-) create mode 100644 docs/Scripting/Placeholders.md create mode 100644 docs/Scripting/Query Properties.md create mode 100644 resources/sample_vaults/Tasks-Demo/Filters/Search for tasks in file or folder containing the Query.md create mode 100644 src/Scripting/ExpandPlaceholders.ts create mode 100644 src/Scripting/QueryContext.ts create mode 100644 tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text create mode 100644 tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.query.text create mode 100644 tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text create mode 100644 tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text create mode 100644 tests/Scripting/ExpandPlaceholders.test.ts create mode 100644 tests/Scripting/QueryContext.test.ts create mode 100644 tests/Scripting/QueryProperties.test.query_file_properties.approved.md create mode 100644 tests/Scripting/QueryProperties.test.ts diff --git a/docs/How To/How to get tasks in current file.md b/docs/How To/How to get tasks in current file.md index 1846845552..abd7868656 100644 --- a/docs/How To/How to get tasks in current file.md +++ b/docs/How To/How to get tasks in current file.md @@ -4,7 +4,7 @@ publish: true # How to get all tasks in the current file -#plugin/dataview +#feature/scripting #plugin/dataview ## Motivation and assumptions @@ -13,37 +13,45 @@ for example to make sure no task gets accidentally missed. This page documents ways of setting this up. -Assumptions: +## Using pure Tasks blocks - with placeholders -- We assume that you know how to install and enable the [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin. +> [!released] +> Placeholders were introduced in Tasks X.Y.Z. -## Using pure Tasks blocks - fragile and error-prone +We want to search for tasks in the file with the same `path` that the query is in. -Tasks does not provide an automated way to include the location of the `tasks` block in a query. +Tasks now provides an automated way to include the location of the `tasks` block in a query. -It is possible to use the `path` instruction, but unfortunately you have to insert the path to the file yourself: +We can use the `path` instruction with the placeholder text `{{query.file.path}}` which will be replaced with the path of the file containing the current query, like this: ## Summary of Tasks within this note ```tasks not done - path includes [insert current note's name or full path] + path includes path includes {{query.file.path}} ``` -For example: +The following placeholders are available: - ## Summary of Tasks within this note +```text +{{query.file.path}} +{{query.file.root}} +{{query.file.folder}} +{{query.file.filename}} +``` - ```tasks - not done - path includes Obsidian/tasks/tasks user support/03 Done - tasks user support/1.11.0 release - ``` +They can be used with any text filter, not just `path`, `file`, `folder`, `filename`. For example, they might be useful with `description` and `heading` filters. -> [!warning] -> Using `path includes` to search for a particular file name or folder is error-prone, as if you rename the file, -you have to remember to manually update the location in the tasks block, and this is very error-prone. +For more information, see: -## Using Dataview to generate Tasks blocks - safe and convenient +- [[Placeholders]] +- [[Query Properties]] + +## Using Dataview to generate Tasks blocks - the old way + +: + +- We assume that you know how to install and enable the [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin. There is a nice property that the [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin can write out code blocks that are then processed by other plugins. diff --git a/docs/Introduction.md b/docs/Introduction.md index 20edd1118b..2acc3d680e 100644 --- a/docs/Introduction.md +++ b/docs/Introduction.md @@ -6,6 +6,7 @@ publish: true ## What's New? +- X.Y.Z: 🔥 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]] - 4.5.0: 🔥 Support task in list items starting with [[Getting Started#Finding tasks in your vault|`+` signs]] diff --git a/docs/Queries/Explaining Queries.md b/docs/Queries/Explaining Queries.md index af8f322909..44d2b2748b 100644 --- a/docs/Queries/Explaining Queries.md +++ b/docs/Queries/Explaining Queries.md @@ -190,6 +190,39 @@ due next week => ``` +### Template values are expanded + +> [!released] +> Templating was introduced in Tasks X.Y.Z. + +For example, when the following query with [[Query Properties]] in [[Placeholders|placeholders]] is placed in a tasks query block in the file `some/sample/file path.md`: + + +```text +explain +path includes {{query.file.path}} +root includes {{query.file.root}} +folder includes {{query.file.folder}} +filename includes {{query.file.filename}} +``` + + +the results begin with the following: + + +```text +Explanation of this Tasks code block query: + +path includes some/sample/file path.md + +root includes some/ + +folder includes some/sample/ + +filename includes file path.md +``` + + ## Styling explain results ### Default style diff --git a/docs/Queries/Filters.md b/docs/Queries/Filters.md index 5329a99466..1f2da7ebe6 100644 --- a/docs/Queries/Filters.md +++ b/docs/Queries/Filters.md @@ -975,12 +975,17 @@ Note that the path includes the `.md` extension. - `path (includes|does not include) ` - Matches case-insensitive (disregards capitalization). + - Use `{{query.file.path}}` 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) //` - Does regular expression match (case-sensitive by default). - Essential reading: [[Regular Expressions|Regular Expression Searches]]. > [!released] -`regex matches` and `regex does not match` were introduced in Tasks 1.12.0. +> +> - `regex matches` and `regex does not match` were introduced in Tasks 1.12.0. +> - Placeholders were released in Tasks X.Y.Z. Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file path** is now possible, using `task.file.path`. @@ -1000,12 +1005,17 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by file path** is now p ### Root > [!released] -> Introduced in Tasks 3.4.0. +> +> - Introduced in Tasks 3.4.0. +> - Placeholders were released in Tasks X.Y.Z. The `root` is the top-level folder of the file that contains the task, that is, the first directory in the path, which will be `/` for files in the root of the vault. - `root (includes|does not include) ` - Matches case-insensitive (disregards capitalization). + - Use `{{query.file.root}}` as a placeholder for the root of the file containing the current query. + - For example, `root includes {{query.file.root}}` + - Useful reading: [[Query Properties]] and [[Placeholders]] - `root (regex matches|regex does not match) //` - Does regular expression match (case-sensitive by default). - Essential reading: [[Regular Expressions|Regular Expression Searches]]. @@ -1026,12 +1036,17 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by root folder** is now ### Folder > [!released] -> Introduced in Tasks 3.4.0. +> +> - Introduced in Tasks 3.4.0. +> - Placeholders were released in Tasks X.Y.Z. This is the `folder` to the file that contains the task, which will be `/` for files in root of the vault. - `folder (includes|does not include) ` - Matches case-insensitive (disregards capitalization). + - Use `{{query.file.folder}}` as a placeholder for the folder of the file containing the current query. + - For example, `folder includes {{query.file.folder}}`, which will match tasks in the folder containing the query **and all sub-folders**. + - Useful reading: [[Query Properties]] and [[Placeholders]] - `folder (regex matches|regex does not match) //` - Does regular expression match (case-sensitive by default). - Essential reading: [[Regular Expressions|Regular Expression Searches]]. @@ -1044,7 +1059,12 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by folder** is now poss - Find tasks in files in any file in the given folder **only**, and not any sub-folders. - The equality test, `===`, requires that the trailing slash (`/`) be included. - ```filter by function task.file.folder.includes("Work/Projects/")``` - - Find tasks in files in any folder **and any sub-folders**. + - Find tasks in files in a specific folder **and any sub-folders**. +- ```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}}'``` + - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**). - ```filter by function task.file.folder.includes("Work/Projects")``` - By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as: - `Work/Projects 2023/` @@ -1055,12 +1075,17 @@ Since Tasks 4.2.0, **[[Custom Filters|custom filtering]] by folder** is now poss ### File Name > [!released] -Introduced in Tasks 1.13.0. +> +> - Introduced in Tasks 3.4.0. +> - Placeholders were released in Tasks X.Y.Z. Note that the file name includes the `.md` extension. - `filename (includes|does not include) ` - Matches case-insensitive (disregards capitalization). + - Use `{{query.file.filename}}` 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) //` - Does regular expression match (case-sensitive by default). - Essential reading: [[Regular Expressions|Regular Expression Searches]]. diff --git a/docs/Queries/Grouping.md b/docs/Queries/Grouping.md index 506b0ec174..7fc4d646e0 100644 --- a/docs/Queries/Grouping.md +++ b/docs/Queries/Grouping.md @@ -522,9 +522,20 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by file path** is now p - ```group by function task.file.path``` - Like 'group by path' but includes the file extension. +- ```group by function task.file.path.replace('{{query.file.folder}}', '')``` + - Group by the task's file path, but remove the query's folder from the group. + - For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths. + - Note that the placeholder text is expanded to a raw string, so needs to be inside quotes. + - This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line. +Since Tasks X.Y.Z, the query's file path can be used in custom groups. + +- It must be quoted: `'{{query.file.folder}}'` +- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped. +- Useful reading: [[Query Properties]] and [[Placeholders]]. + ### Root - `group by root` (the top-level folder of the file that contains the task, that is, the first directory in the path, which will be `/` for files in root of the vault) @@ -541,6 +552,12 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by root folder** is now +Since Tasks X.Y.Z, the query's file root can be used in custom groups. + +- It must be quoted: `'{{query.file.root}}'` +- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped. +- Useful reading: [[Query Properties]] and [[Placeholders]]. + ### Folder - `group by folder` (the folder to the file that contains the task, which always ends in `/` and will be exactly `/` for files in root of the vault) @@ -561,6 +578,12 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by folder** is now poss +Since Tasks X.Y.Z, the query's folder can be used in custom groups. + +- It must be quoted: `'{{query.file.folder}}'` +- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped. +- Useful reading: [[Query Properties]] and [[Placeholders]]. + ### File Name - `group by filename` (the link to the file that contains the task, without the `.md` extension) @@ -577,6 +600,12 @@ Since Tasks 4.0.0, **[[Custom Grouping|custom grouping]] by file name** is now p +Since Tasks X.Y.Z, the query's file name can be used in custom groups. + +- It must be quoted: `'{{query.file.filename}}'` +- Beware if using placeholder text in regular expressions: Any special characters in filenames would need to be escaped. +- Useful reading: [[Query Properties]] and [[Placeholders]]. + ### Backlink - `group by backlink` (the text that would be shown in the task's [[Backlinks|backlink]], combining the task's file name and heading, but with no link added) diff --git a/docs/Quick Reference.md b/docs/Quick Reference.md index c6c19c58bc..ae32ccb804 100644 --- a/docs/Quick Reference.md +++ b/docs/Quick Reference.md @@ -8,57 +8,57 @@ aliases: This table summarizes the filters and other options available inside a `tasks` block. -| [[Filters]] | [[Sorting\|Sort]] | [[Grouping\|Group]] | [[Layout\|Display]] | [[About Scripting\|Scripting]] | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------- | ---------------------- | --------------------------------------------------- | -| **[[Filters#Filters for Dates in Tasks\|Status]]** | | | | | -| `done`
`not done` | `sort by status` | `group by status` | | `task.isDone` | -| `status.name (includes, does not include) `
`status.name (regex matches, regex does not match) /regex/i` | `sort by status.name` | `group by status.name` | | `task.status.name` | -| `status.type (is, is not) (TODO, DONE, IN_PROGRESS, CANCELLED, NON_TASK)` | `sort by status.type` | `group by status.type` | | `task.status.type` | -| | | | | `task.status.symbol` | -| | | | | `task.status.nextSymbol` | -| **[[Filters#Filters for Dates in Tasks\|Dates]]** | | | | | +| [[Filters]] | [[Sorting\|Sort]] | [[Grouping\|Group]] | [[Layout\|Display]] | [[About Scripting\|Scripting]] | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | ---------------------- | ---------------------- | --------------------------------------------------- | +| **[[Filters#Filters for Dates in Tasks\|Status]]** | | | | | +| `done`
`not done` | `sort by status` | `group by status` | | `task.isDone` | +| `status.name (includes, does not include) `
`status.name (regex matches, regex does not match) /regex/i` | `sort by status.name` | `group by status.name` | | `task.status.name` | +| `status.type (is, is not) (TODO, DONE, IN_PROGRESS, CANCELLED, NON_TASK)` | `sort by status.type` | `group by status.type` | | `task.status.type` | +| | | | | `task.status.symbol` | +| | | | | `task.status.nextSymbol` | +| **[[Filters#Filters for Dates in Tasks\|Dates]]** | | | | | | `done (on, before, after, on or before, on or after) `
`done (in, before, after, in or before, in or after) ...`
`... YYYY-MM-DD YYYY-MM-DD`
`... (last, this, next) (week, month, quarter, year)`
`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`
`has done date`
`no done date`
`done date is invalid` | `sort by done` | `group by done` | `hide done date` | `task.done` | | `created (on, before, after, on or before, on or after) `
`created (in, before, after, in or before, in or after) ...`
`... YYYY-MM-DD YYYY-MM-DD`
`... (last, this, next) (week, month, quarter, year)`
`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`
`has created date`
`no created date`
`created date is invalid` | `sort by created` | `group by created` | `hide created date` | `task.created` | | `starts (on, before, after, on or before, on or after) `
`starts (in, before, after, in or before, in or after) ...`
`... YYYY-MM-DD YYYY-MM-DD`
`... (last, this, next) (week, month, quarter, year)`
`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`
`has start date`
`no start date`
`start date is invalid` | `sort by start` | `group by start` | `hide start date` | `task.start` | | `scheduled (on, before, after, on or before, on or after) `
`scheduled (in, before, after, in or before, in or after) ...`
`... YYYY-MM-DD YYYY-MM-DD`
`... (last, this, next) (week, month, quarter, year)`
`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`
`has scheduled date`
`no scheduled date`
`scheduled date is invalid` | `sort by scheduled` | `group by scheduled` | `hide scheduled date` | `task.scheduled` | | `due (on, before, after, on or before, on or after) `
`due (in, before, after, in or before, in or after) ...`
`... YYYY-MM-DD YYYY-MM-DD`
`... (last, this, next) (week, month, quarter, year)`
`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`
`has due date`
`no due date`
`due date is invalid` | `sort by due` | `group by due` | `hide due date` | `task.due` | | `happens (on, before, after, on or before, on or after) `
`happens (in, before, after, in or before, in or after) ...`
`... YYYY-MM-DD YYYY-MM-DD`
`... (last, this, next) (week, month, quarter, year)`
`... (YYYY-Www,YYYY-mm, YYYY-Qq, YYYY)`
`has happens date`
`no happens date` | `sort by happens` | `group by happens` | | `task.happens` | -| **[[Filters#Recurrence\|Recurrence]]** | | | | | -| `is recurring`
`is not recurring` | `sort by recurring` | `group by recurring` | | `task.isRecurring` | -| `recurrence (includes, does not include) `
`recurrence (regex matches, regex does not match) /regex/i` | | `group by recurrence` | `hide recurrence rule` | `task.recurrenceRule` | -| **[[Filters#Priority\|Priority]]** and **[[Urgency\|urgency]]** | | | | | -| `priority is (above, below, not)? (lowest, low, none, medium, high, highest)` | `sort by priority` | `group by priority` | `hide priority` | `task.priorityName`
`task.priorityNumber` | -| | `sort by urgency` | `group by urgency` | `show urgency` | `task.urgency` | -| **[[Filters#Filters for File Properties\|File properties]]** | | | | | -| `path (includes, does not include) `
`path (regex matches, regex does not match) /regex/i` | `sort by path` | `group by path` | | `task.file.path` | -| `root (includes, does not include) `
`root (regex matches, regex does not match) /regex/i` | | `group by root` | | `task.file.root` | -| `folder (includes, does not include) `
`folder (regex matches, regex does not match) /regex/i` | | `group by folder` | | `task.file.folder` | -| `filename (includes, does not include) `
`filename (regex matches, regex does not match) /regex/i` | `sort by filename` | `group by filename` | | `task.file.filename` | -| `heading (includes, does not include) `
`heading (regex matches, regex does not match) /regex/i` | `sort by heading` | `group by heading` | | `task.hasHeading`
`task.heading` | -| | | `group by backlink` | `hide backlink` | | -| **[[Filters#Description\|Description]]**, **[[Filters#Tags\|Tags]]** and other odds and ends | | | | | -| `description (includes, does not include) `
`description (regex matches, regex does not match) /regex/i` | `sort by description` | | | `task.description`
`task.descriptionWithoutTags` | -| `has tags`
`no tags`
`tag (includes, does not include) `
`tags (include, do not include) `
`tag (regex matches, regex does not match) /regex/i`
`tags (regex matches, regex does not match) /regex/i` | `sort by tag`
`sort by tag ` | `group by tags` | `hide tags` | `task.tags` | -| | | | | `task.originalMarkdown` | -| **[[About Scripting\|Scripting]]** | | | | | -| `filter by function` | | `group by function` | | | -| **[[Combining Filters]]** | | | | | -| `(filter 1) AND (filter 2)` | | | | | -| `(filter 1) OR (filter 2)` | | | | | -| `NOT (filter 1)` | | | | | -| `(filter 1) XOR (filter 2)` | | | | | -| `(filter 1) AND NOT (filter 2)` | | | | | -| `(filter 1) OR NOT (filter 2)` | | | | | -| `(filter 1) AND ((filter 2) OR (filter 3))` | | | | | -| **Other Filter Options** | | | | | -| `exclude sub-items` | | | | | -| `limit to tasks`
`limit ` | | | | | -| `limit groups to tasks`
`limit groups ` | | | | | -| **Other Layout Options** | | | | | -| `hide edit button` | | | | | -| `hide task count` | | | | | -| `short mode` | | | | | -| **Other Instructions** | | | | | -| `ignore global query` | | | | | -| `explain` | | | | | -| `# comment` | | | | | +| **[[Filters#Recurrence\|Recurrence]]** | | | | | +| `is recurring`
`is not recurring` | `sort by recurring` | `group by recurring` | | `task.isRecurring` | +| `recurrence (includes, does not include) `
`recurrence (regex matches, regex does not match) /regex/i` | | `group by recurrence` | `hide recurrence rule` | `task.recurrenceRule` | +| **[[Filters#Priority\|Priority]]** and **[[Urgency\|urgency]]** | | | | | +| `priority is (above, below, not)? (lowest, low, none, medium, high, highest)` | `sort by priority` | `group by priority` | `hide priority` | `task.priorityName`
`task.priorityNumber` | +| | `sort by urgency` | `group by urgency` | `show urgency` | `task.urgency` | +| **[[Filters#Filters for File Properties\|File properties]]** | | | | | +| `path (includes, does not include) `
`path (regex matches, regex does not match) /regex/i`
`path includes {{query.file.path}}` | `sort by path` | `group by path` | | `task.file.path` | +| `root (includes, does not include) `
`root (regex matches, regex does not match) /regex/i`
`root includes {{query.file.root}}` | | `group by root` | | `task.file.root` | +| `folder (includes, does not include) `
`folder (regex matches, regex does not match) /regex/i`
`folder includes {{query.file.folder}}` | | `group by folder` | | `task.file.folder` | +| `filename (includes, does not include) `
`filename (regex matches, regex does not match) /regex/i`
`filename includes {{query.file.filename}}` | `sort by filename` | `group by filename` | | `task.file.filename` | +| `heading (includes, does not include) `
`heading (regex matches, regex does not match) /regex/i` | `sort by heading` | `group by heading` | | `task.hasHeading`
`task.heading` | +| | | `group by backlink` | `hide backlink` | | +| **[[Filters#Description\|Description]]**, **[[Filters#Tags\|Tags]]** and other odds and ends | | | | | +| `description (includes, does not include) `
`description (regex matches, regex does not match) /regex/i` | `sort by description` | | | `task.description`
`task.descriptionWithoutTags` | +| `has tags`
`no tags`
`tag (includes, does not include) `
`tags (include, do not include) `
`tag (regex matches, regex does not match) /regex/i`
`tags (regex matches, regex does not match) /regex/i` | `sort by tag`
`sort by tag ` | `group by tags` | `hide tags` | `task.tags` | +| | | | | `task.originalMarkdown` | +| **[[About Scripting\|Scripting]]** | | | | | +| `filter by function` | | `group by function` | | | +| **[[Combining Filters]]** | | | | | +| `(filter 1) AND (filter 2)` | | | | | +| `(filter 1) OR (filter 2)` | | | | | +| `NOT (filter 1)` | | | | | +| `(filter 1) XOR (filter 2)` | | | | | +| `(filter 1) AND NOT (filter 2)` | | | | | +| `(filter 1) OR NOT (filter 2)` | | | | | +| `(filter 1) AND ((filter 2) OR (filter 3))` | | | | | +| **Other Filter Options** | | | | | +| `exclude sub-items` | | | | | +| `limit to tasks`
`limit ` | | | | | +| `limit groups to tasks`
`limit groups ` | | | | | +| **Other Layout Options** | | | | | +| `hide edit button` | | | | | +| `hide task count` | | | | | +| `short mode` | | | | | +| **Other Instructions** | | | | | +| `ignore global query` | | | | | +| `explain` | | | | | +| `# comment` | | | | | diff --git a/docs/Scripting/About Scripting.md b/docs/Scripting/About Scripting.md index d0417569b3..2f0dac927d 100644 --- a/docs/Scripting/About Scripting.md +++ b/docs/Scripting/About Scripting.md @@ -15,6 +15,10 @@ We are using the word 'scripting' in a very loose sense here: - For now, it refers only to writing JavaScript expressions in Tasks query blocks. - It is intended to evolve in to something broader over time. +## Templating capabilities + +- [[Placeholders]] - use placeholder text in native Tasks queries, such as `{{query.file.path}}` to refer to some properties of the file containing the query. + ## Scripting capabilities - [[Custom Filters]] - write short JavaScript expressions to create task search filters. @@ -26,4 +30,5 @@ We are using the word 'scripting' in a very loose sense here: - [[Task Properties]] - all the available task properties, such as `task.description`, `task.file.path`. - Note: The properties are also listed in [[Quick Reference]]. +- [[Query Properties]] - all the available task properties, such as `query.file.path`, `query.file.path` - available for use via [[Placeholders]]. - [[Expressions]] - some background about how JavaScript expressions work, for use in Tasks code blocks. diff --git a/docs/Scripting/Custom Filters.md b/docs/Scripting/Custom Filters.md index 4311c928b8..3a5f8417ae 100644 --- a/docs/Scripting/Custom Filters.md +++ b/docs/Scripting/Custom Filters.md @@ -38,6 +38,15 @@ The Reference section [[Task Properties]] shows all the task properties availabl 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. + +> [!released] +> Query properties and placeholders were introduced in Tasks X.Y.Z. + ### Expressions The instructions look like this: @@ -136,7 +145,12 @@ For users who are comfortable with JavaScript, these more complicated examples m - Find tasks in files in any file in the given folder **only**, and not any sub-folders. - The equality test, `===`, requires that the trailing slash (`/`) be included. - ```filter by function task.file.folder.includes("Work/Projects/")``` - - Find tasks in files in any folder **and any sub-folders**. + - Find tasks in files in a specific folder **and any sub-folders**. +- ```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}}'``` + - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**). - ```filter by function task.file.folder.includes("Work/Projects")``` - By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as: - `Work/Projects 2023/` diff --git a/docs/Scripting/Custom Grouping.md b/docs/Scripting/Custom Grouping.md index f84f1a87e1..93cd00585a 100644 --- a/docs/Scripting/Custom Grouping.md +++ b/docs/Scripting/Custom Grouping.md @@ -37,6 +37,15 @@ The Reference section [[Task Properties]] shows all the task properties availabl 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 grouping. + +Any placeholders in custom groups must be surrounded by quotes. + +> [!released] +> Query properties and placeholders were introduced in Tasks X.Y.Z. + ### Expressions The instructions look like this: diff --git a/docs/Scripting/Placeholders.md b/docs/Scripting/Placeholders.md new file mode 100644 index 0000000000..838e8ad7ea --- /dev/null +++ b/docs/Scripting/Placeholders.md @@ -0,0 +1,124 @@ +--- +publish: true +--- + +# Placeholders + +#feature/scripting + +> [!released] +> Placeholders were introduced in Tasks X.Y.Z. + +## Summary + +- Tasks provides a placeholder facility to enable filters to access the location of the query file. +- Any known property inside a pair of `{{` and `}}` strings is expanded to a value obtained from the query file's path. +- For example,: + - `{{query.file.path}}` might get expanded to + - `some/sample/actions on my hobby.md` - for any Tasks queries inside that file. +- The available values for use in placeholders are listed in [[Query Properties]]. + +## Checking template variables + +The [[Explaining Queries|explain]] instruction shows how any placeholders in the query are interpreted. This can be used to understand how placeholders are expanded generally. + +For example, when the following query with [[Query Properties]] in [[Placeholders|placeholders]] is placed in a tasks query block in the file `some/sample/file path.md`: + + +```text +explain +path includes {{query.file.path}} +root includes {{query.file.root}} +folder includes {{query.file.folder}} +filename includes {{query.file.filename}} +``` + + +the results begin with the following, which demonstrates how each value inside `{{...}}` was expanded: + + +```text +Explanation of this Tasks code block query: + +path includes some/sample/file path.md + +root includes some/ + +folder includes some/sample/ + +filename includes file path.md +``` + + +## Error checking: invalid variables + +If there are any unknown properties in the placeholders, a clear message is written. + +For example, the following shows that the names of query properties are case-sensitive: + + +```text +# query.file.fileName is invalid, because of the capital N. +# query.file.filename is the correct property name. +filename includes {{query.file.fileName}} +``` + + +... generates this output: + +```text +Tasks query: There was an error expanding one or more placeholders. + +The error message was: + Unknown property: query.file.fileName + +The problem is in: + filename includes {{query.file.fileName}} +``` + +%% --------------------------------------------------------------------------- +IF THIS TEXT CHANGES, IT MEANS THE HARD-CODED OUTPUT ABOVE NEEDS TO BE UPDATED: + + +```text +Explanation of this Tasks code block query: + +Query has an error: +There was an error expanding one or more placeholders. + +The error message was: + Unknown property: query.file.fileName + +The problem is in: + filename includes {{query.file.fileName}} +``` + +--------------------------------------------------------------------------- %% + +## Things to be aware of + +- The symbols are case-sensitive: + - `query.file.fileName` is not recognised +- When placeholders are used in custom filters and groups, they must be surrounded by quotes. + - For example: `'{{query.file.folder}}'` + +## Known Limitations + +- It complains about any unrecognised placeholders in comments, even though comments are then ignored. +- Explanations: + - `explain` instructions only show the expanded text. + - It would be nice to also show the original variable name, and then the expanded text. +- Use in regular expressions is allowed + - but due to [[Regular Expressions#Special characters|characters with special meanings]] in regular expressions, it is not recommended to use them. +- When you rename a file containing a tasks query block with variable names in, the query block is not automatically updated: + - the workaround is to close and re-open the file containing the query. + +## Missing Features + +- Searching by today's date or time +- Getting date strings from file names + +## Technical Details + +- The templating library used is [mustache.js](https://www.npmjs.com/package/mustache). +- Error-checking to detect use of unknown variables is implemented via [mustache-validator](https://www.npmjs.com/package/mustache-validator). diff --git a/docs/Scripting/Query Properties.md b/docs/Scripting/Query Properties.md new file mode 100644 index 0000000000..3532eff90e --- /dev/null +++ b/docs/Scripting/Query Properties.md @@ -0,0 +1,41 @@ +--- +publish: true +--- + +# Query Properties + +#feature/scripting + +> [!released] +> Query Properties were introduced in Tasks X.Y.Z. + +## Introduction + +In a growing number of locations, Tasks allows programmatic/scripting access to properties of the file containing the search query: + +- [[Placeholders]] + +This page documents all the available pieces of information in Queries that you can access. + +> [!warning] +> +> - These properties can currently only be used in [[Placeholders]]. +> - Placeholders can be in [[Custom Filters]] and [[Custom Grouping]], but must be surrounded by quotes. For example: `'{{query.file.folder}}'`. +> - In a future release, we will allow expressions such as `query.file.folder` to be used directly in custom filters and groups. + +## Values for Query File Properties + + + +| Field | Type | Example | +| ----- | ----- | ----- | +| `query.file.path` | `string` | `'root/sub-folder/file containing query.md'` | +| `query.file.root` | `string` | `'root/'` | +| `query.file.folder` | `string` | `'root/sub-folder/'` | +| `query.file.filename` | `string` | `'file containing query.md'` | + + + +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]]. diff --git a/package.json b/package.json index 037290a06a..8bb6287982 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@testing-library/svelte": "^3.2.2", "@tsconfig/svelte": "^3.0.0", "@types/jest": "^29.5.2", + "@types/mustache": "^4.2.2", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.59.8", "approvals": "^6.2.1", @@ -59,6 +60,8 @@ "boon-js": "^2.0.3", "chrono-node": "2.3.9", "eventemitter2": "^6.4.5", + "mustache": "^4.2.0", + "mustache-validator": "^0.2.0", "rrule": "^2.7.1" } } diff --git a/resources/sample_vaults/Tasks-Demo/Filters/Search for tasks in file or folder containing the Query.md b/resources/sample_vaults/Tasks-Demo/Filters/Search for tasks in file or folder containing the Query.md new file mode 100644 index 0000000000..0c13c5727e --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Filters/Search for tasks in file or folder containing the Query.md @@ -0,0 +1,20 @@ +# Search for tasks in file or folder containing the Query + +See [How to get all tasks in the current file](https://publish.obsidian.md/tasks/How+To/How+to+get+tasks+in+current+file). + +## Sample Tasks + +- [ ] #task Task 1 +- [ ] #task Task 2 +- [ ] #task Task 3 +- [ ] #task Task 4 + +## Search + +```tasks +explain +path includes {{query.file.path}} +root includes {{query.file.root}} +folder includes {{query.file.folder}} +filename includes {{query.file.filename}} +``` diff --git a/src/Query/Query.ts b/src/Query/Query.ts index 3e74786430..8d2ac45f39 100644 --- a/src/Query/Query.ts +++ b/src/Query/Query.ts @@ -1,3 +1,5 @@ +import { expandPlaceholders } from '../Scripting/ExpandPlaceholders'; +import { makeQueryContext } from '../Scripting/QueryContext'; import { LayoutOptions } from '../TaskLayout'; import type { Task } from '../Task'; import type { IQuery } from '../IQuery'; @@ -12,7 +14,9 @@ import type { Filter } from './Filter/Filter'; import { QueryResult } from './QueryResult'; export class Query implements IQuery { - public source: string; + /** Note: source is the raw source, before expanding any placeholders */ + public readonly source: string; + public readonly filePath: string | undefined; private _limit: number | undefined = undefined; private _taskGroupLimit: number | undefined = undefined; @@ -33,12 +37,20 @@ export class Query implements IQuery { private readonly commentRegexp = /^#.*/; - constructor({ source }: { source: string }) { + constructor({ source }: { source: string }, path: string | undefined = undefined) { this.source = source; + this.filePath = path; + source .split('\n') - .map((line: string) => line.trim()) - .forEach((line: string) => { + .map((rawLine: string) => rawLine.trim()) + .forEach((rawLine: string) => { + const line = this.expandPlaceholders(rawLine, path); + if (this.error !== undefined) { + // There was an error expanding placeholders. + return; + } + switch (true) { case line === '': break; @@ -72,6 +84,37 @@ export class Query implements IQuery { }); } + private expandPlaceholders(source: string, path: string | undefined) { + if (source.includes('{{') && source.includes('}}')) { + if (this.filePath === undefined) { + this._error = `The query looks like it contains a placeholder, with "{{" and "}}" +but no file path has been supplied, so cannot expand placeholder values. +The query is: +${source}`; + return source; + } + } + + // TODO Do not complain about any placeholder errors in comment lines + // TODO Show the original and expanded text in explanations + // TODO Give user error info if they try and put a string in a regex search + let expandedSource: string = source; + if (path) { + const queryContext = makeQueryContext(path); + try { + expandedSource = expandPlaceholders(source, queryContext); + } catch (error) { + if (error instanceof Error) { + this._error = error.message; + } else { + this._error = 'Internal error. expandPlaceholders() threw something other than Error.'; + } + return source; + } + } + return expandedSource; + } + /** * * Appends {@link q2} to this query. @@ -95,7 +138,7 @@ export class Query implements IQuery { public append(q2: Query): Query { if (this.source === '') return q2; if (q2.source === '') return this; - return new Query({ source: `${this.source}\n${q2.source}` }); + return new Query({ source: `${this.source}\n${q2.source}` }, this.filePath); } /** @@ -107,6 +150,12 @@ export class Query implements IQuery { public explainQuery(): string { let result = ''; + if (this.error !== undefined) { + result += 'Query has an error:\n'; + result += this.error + '\n'; + return result; + } + const numberOfFilters = this.filters.length; if (numberOfFilters === 0) { result += 'No filters supplied. All tasks will match the query.'; diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts index dfcc6551d0..b430491de0 100644 --- a/src/QueryRenderer.ts +++ b/src/QueryRenderer.ts @@ -89,12 +89,12 @@ class QueryRenderChild extends MarkdownRenderChild { // added later. switch (this.containerEl.className) { case 'block-language-tasks': - this.query = getQueryForQueryRenderer(this.source); + this.query = getQueryForQueryRenderer(this.source, this.filePath); this.queryType = 'tasks'; break; default: - this.query = getQueryForQueryRenderer(this.source); + this.query = getQueryForQueryRenderer(this.source, this.filePath); this.queryType = 'tasks'; break; } @@ -135,7 +135,7 @@ class QueryRenderChild extends MarkdownRenderChild { const millisecondsToMidnight = midnight.getTime() - now.getTime(); this.queryReloadTimeout = setTimeout(() => { - this.query = getQueryForQueryRenderer(this.source); + this.query = getQueryForQueryRenderer(this.source, this.filePath); // Process the current cache state: this.events.triggerRequestCacheUpdate(this.render.bind(this)); this.reloadQueryAtMidnight(); @@ -198,7 +198,7 @@ class QueryRenderChild extends MarkdownRenderChild { // Use the 'explain' instruction to enable this private createExplanation(content: HTMLDivElement) { - const explanationAsString = explainResults(this.source); + const explanationAsString = explainResults(this.source, this.filePath); const explanationsBlock = content.createEl('pre'); explanationsBlock.addClasses(['plugin-tasks-query-explanation']); diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts new file mode 100644 index 0000000000..e93806e615 --- /dev/null +++ b/src/Scripting/ExpandPlaceholders.ts @@ -0,0 +1,45 @@ +import Mustache from 'mustache'; +import proxyData from 'mustache-validator'; + +// https://github.com/janl/mustache.js + +/** + * Expand any placeholder strings - {{....}} - in the given template, and return the result. + * + * The template implementation is currently provided by: [mustache.js](https://github.com/janl/mustache.js). + * + * @param template - A template string, typically with placeholders such as {{query.task.folder}} + * @param view - The property values + * + * @throws Error + * + * By using mustache-validator's proxyData, we ensure that any accesses of property names that are + * not in the view, we ensure that errors are detected immediately. + * The first unknown placeholder is included in Error.message. + */ +export function expandPlaceholders(template: string, view: any): string { + // Turn off HTML escaping of things like '/' in file paths: + // https://github.com/janl/mustache.js#variables + Mustache.escape = function (text) { + return text; + }; + + try { + return Mustache.render(template, proxyData(view)); + } catch (error) { + let message = ''; + if (error instanceof Error) { + message = `There was an error expanding one or more placeholders. + +The error message was: + ${error.message.replace(/ > /g, '.').replace('Missing Mustache data property', 'Unknown property')}`; + } else { + message = 'Unknown error expanding placeholders.'; + } + message += ` + +The problem is in: + ${template}`; + throw Error(message); + } +} diff --git a/src/Scripting/QueryContext.ts b/src/Scripting/QueryContext.ts new file mode 100644 index 0000000000..d0ee986706 --- /dev/null +++ b/src/Scripting/QueryContext.ts @@ -0,0 +1,30 @@ +import { TasksFile } from './TasksFile'; + +/** + * This interface is part of the implementation of placeholders. + * Use {@link makeQueryContext} to make a QueryContext. + * + * QueryContext is a 'view' to pass in to {@link expandPlaceholders}. + * + * It provides the following: + * `queryContext.query.file` - where query.file is a {@link TasksFile} object. + * So it supplies `query.file.path`, `query.file.folder`. + */ +export interface QueryContext { + query: { + file: TasksFile; + }; +} + +/** + * Create a {@link QueryContext} to represent a query in note at the give path. + * @param path + */ +export function makeQueryContext(path: string): QueryContext { + const tasksFile = new TasksFile(path); + return { + query: { + file: tasksFile, + }, + }; +} diff --git a/src/lib/QueryRendererHelper.ts b/src/lib/QueryRendererHelper.ts index dc746c9e5e..021a48557b 100644 --- a/src/lib/QueryRendererHelper.ts +++ b/src/lib/QueryRendererHelper.ts @@ -20,19 +20,20 @@ import { Query } from '../Query/Query'; * * Explains the query described by {@link source} * * @param {string} source The source of the task block to explain + * @param {string} path The location of the task block, if known * @returns {string} */ -export function explainResults(source: string): string { +export function explainResults(source: string, path: string | undefined = undefined): string { let result = ''; if (!GlobalFilter.isEmpty()) { result += `Only tasks containing the global filter '${GlobalFilter.get()}'.\n\n`; } - const tasksBlockQuery = new Query({ source }); + const tasksBlockQuery = new Query({ source }, path); if (!tasksBlockQuery.ignoreGlobalQuery) { - const globalQuery: IQuery = new Query(getGlobalQuerySource()); + const globalQuery: IQuery = new Query(getGlobalQuerySource(), path); if (globalQuery.source.trim() !== '') { result += `Explanation of the global query:\n\n${globalQuery.explainQuery()}\n`; @@ -50,11 +51,12 @@ export function explainResults(source: string): string { * This query is the result of joining the global query with the query in the task block * * @param {string} source The query source from the task block + * @param {string | undefined} path The path to the file containing the query, if available. * @returns {Query} The query to execute */ -export function getQueryForQueryRenderer(source: string): Query { - const globalQuery = new Query(getGlobalQuerySource()); - const tasksBlockQuery = new Query({ source }); +export function getQueryForQueryRenderer(source: string, path: string | undefined): Query { + const globalQuery = new Query(getGlobalQuerySource(), path); + const tasksBlockQuery = new Query({ source }, path); if (tasksBlockQuery.ignoreGlobalQuery) { return tasksBlockQuery; diff --git a/tests/Query.test.ts b/tests/Query.test.ts index 8d2c6afd6f..59313373d1 100644 --- a/tests/Query.test.ts +++ b/tests/Query.test.ts @@ -506,6 +506,62 @@ Problem line: "${source}"`); Problem line: "${source}"`); }); }); + + describe('parsing placeholders', () => { + it('should expand placeholder values in filters, but not source', () => { + // Arrange + const rawQuery = 'path includes {{query.file.path}}'; + const path = 'a/b/path with space.md'; + + // Act + const query = new Query({ source: rawQuery }, path); + + // Assert + expect(query.source).toEqual(rawQuery); // Interesting that query.source still has the placeholder text + expect(query.filters.length).toEqual(1); + expect(query.filters[0].instruction).toEqual('path includes a/b/path with space.md'); + }); + + it('should report error if placeholders used without query location', () => { + // Arrange + const input = 'path includes {{query.file.path}}'; + + // Act + const query = new Query({ source: input }); + + // Assert + expect(query).not.toBeValid(); + expect(query.error).toEqual( + 'The query looks like it contains a placeholder, with "{{" and "}}"\n' + + 'but no file path has been supplied, so cannot expand placeholder values.\n' + + 'The query is:\n' + + 'path includes {{query.file.path}}', + ); + expect(query.filters.length).toEqual(0); + }); + + it('should report error if non-existent placeholder used', () => { + // Arrange + const input = 'path includes {{query.file.noSuchProperty}}'; + const path = 'a/b/path with space.md'; + + // Act + const query = new Query({ source: input }, path); + + // Assert + expect(query).not.toBeValid(); + expect(query.error).toEqual( + 'There was an error expanding one or more placeholders.\n' + + '\n' + + 'The error message was:\n' + + ' Unknown property: query.file.noSuchProperty\n' + + '\n' + + 'The problem is in:\n' + + ' path includes {{query.file.noSuchProperty}}', + ); + expect(query.filters.length).toEqual(0); + }); + }); }); describe('Query', () => { @@ -1031,6 +1087,17 @@ due 2012-01-23 => expect(query.explainQuery()).toEqual(expectedDisplayText); }); + it('should include any error message in the explanation', () => { + const input = 'i am a nonsense query'; + const query = new Query({ source: input }); + + const expectedDisplayText = `Query has an error: +do not understand query +Problem line: "i am a nonsense query" +`; + expect(query.explainQuery()).toEqual(expectedDisplayText); + }); + it('should explain limit 5', () => { const input = 'limit 5'; const query = new Query({ source: input }); diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text new file mode 100644 index 0000000000..6ebedc10da --- /dev/null +++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.explanation.text @@ -0,0 +1,9 @@ +Explanation of this Tasks code block query: + +path includes some/sample/file path.md + +root includes some/ + +folder includes some/sample/ + +filename includes file path.md diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.query.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.query.text new file mode 100644 index 0000000000..e3f3cb837b --- /dev/null +++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders.approved.query.text @@ -0,0 +1,6 @@ + +explain +path includes {{query.file.path}} +root includes {{query.file.root}} +folder includes {{query.file.folder}} +filename includes {{query.file.filename}} diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text new file mode 100644 index 0000000000..e582014b12 --- /dev/null +++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.explanation.text @@ -0,0 +1,10 @@ +Explanation of this Tasks code block query: + +Query has an error: +There was an error expanding one or more placeholders. + +The error message was: + Unknown property: query.file.fileName + +The problem is in: + filename includes {{query.file.fileName}} diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text new file mode 100644 index 0000000000..0041a99683 --- /dev/null +++ b/tests/Query/Explain/DocsSamplesForExplain.test.explain_placeholders_error.approved.query.text @@ -0,0 +1,4 @@ + +# query.file.fileName is invalid, because of the capital N. +# query.file.filename is the correct property name. +filename includes {{query.file.fileName}} diff --git a/tests/Query/Explain/DocsSamplesForExplain.test.ts b/tests/Query/Explain/DocsSamplesForExplain.test.ts index b360b6079f..56bbe8571f 100644 --- a/tests/Query/Explain/DocsSamplesForExplain.test.ts +++ b/tests/Query/Explain/DocsSamplesForExplain.test.ts @@ -90,4 +90,30 @@ explain`; checkExplainPresentAndVerify(blockQuery); verifyTaskBlockExplanation(blockQuery); }); + + it('placeholders', () => { + // Arrange + const instructions: string = ` +explain +path includes {{query.file.path}} +root includes {{query.file.root}} +folder includes {{query.file.folder}} +filename includes {{query.file.filename}}`; + + // Act, Assert + checkExplainPresentAndVerify(instructions); + verifyTaskBlockExplanation(instructions); + }); + + it('placeholders error', () => { + // Arrange + const instructions: string = ` +# query.file.fileName is invalid, because of the capital N. +# query.file.filename is the correct property name. +filename includes {{query.file.fileName}}`; + + // Act, Assert + verifyQuery(instructions); // This does not have an explain, so does not call checkExplainPresentAndVerify() + verifyTaskBlockExplanation(instructions); + }); }); diff --git a/tests/Query/Filter/BacklinkField.test.ts b/tests/Query/Filter/BacklinkField.test.ts index f5abb5c955..dc89ae554c 100644 --- a/tests/Query/Filter/BacklinkField.test.ts +++ b/tests/Query/Filter/BacklinkField.test.ts @@ -76,6 +76,7 @@ describe('grouping by backlink', () => { '\\_c\\_', '\\_c\\_ > heading _italic text_', 'a\\_b\\_c', + 'b', 'c', 'c > heading', 'Unknown Location', diff --git a/tests/Query/Filter/FilenameField.test.ts b/tests/Query/Filter/FilenameField.test.ts index d29a59975b..783239db2f 100644 --- a/tests/Query/Filter/FilenameField.test.ts +++ b/tests/Query/Filter/FilenameField.test.ts @@ -126,6 +126,6 @@ describe('grouping by filename', () => { const grouper = new FilenameField().createNormalGrouper(); // Assert - expect({ grouper, tasks }).groupHeadingsToBe(['[[_c_]]', '[[a_b_c]]', '[[c]]', 'Unknown Location']); + expect({ grouper, tasks }).groupHeadingsToBe(['[[_c_]]', '[[a_b_c]]', '[[b]]', '[[c]]', 'Unknown Location']); }); }); diff --git a/tests/Query/Filter/FolderField.test.ts b/tests/Query/Filter/FolderField.test.ts index 3d1265165e..8f1685617d 100644 --- a/tests/Query/Filter/FolderField.test.ts +++ b/tests/Query/Filter/FolderField.test.ts @@ -55,6 +55,6 @@ describe('grouping by folder', () => { const grouper = new FolderField().createNormalGrouper(); // Assert - expect({ grouper, tasks }).groupHeadingsToBe(['/', 'a/b/', 'a/d/', 'e/d/']); + expect({ grouper, tasks }).groupHeadingsToBe(['/', 'a/', 'a/b/', 'a/d/', 'e/d/']); }); }); diff --git a/tests/Query/Filter/PathField.test.ts b/tests/Query/Filter/PathField.test.ts index 7725f8821d..f53513dc93 100644 --- a/tests/Query/Filter/PathField.test.ts +++ b/tests/Query/Filter/PathField.test.ts @@ -157,6 +157,7 @@ describe('grouping by path', () => { // Assert expect({ grouper, tasks }).groupHeadingsToBe([ // Why there is no path for empty path? + 'a/b', 'a/b/\\_c\\_', 'a/b/c', 'a/d/c', diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts new file mode 100644 index 0000000000..d0573fd176 --- /dev/null +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -0,0 +1,67 @@ +import { expandPlaceholders } from '../../src/Scripting/ExpandPlaceholders'; +import { makeQueryContext } from '../../src/Scripting/QueryContext'; + +describe('ExpandTemplate', () => { + const path = 'a/b/path with space.md'; + + it('hard-coded call', () => { + const view = { + title: 'Joe', + calc: () => 2 + 4, + }; + + const output = expandPlaceholders('{{ title }} spends {{ calc }}', view); + expect(output).toMatchInlineSnapshot('"Joe spends 6"'); + }); + + it('fake query - with file path', () => { + const rawString = `path includes {{query.file.path}} +filename includes {{query.file.filename}}`; + + const queryContext = makeQueryContext(path); + expect(expandPlaceholders(rawString, queryContext)).toMatchInlineSnapshot(` + "path includes a/b/path with space.md + filename includes path with space.md" + `); + }); + + it('should return the input string if no {{ in line', function () { + const queryContext = makeQueryContext(path); + const line = 'no braces here'; + + const result = expandPlaceholders(line, queryContext); + + // This test revealed that Mustache itself returns the input string if no {{ present. + expect(Object.is(line, result)).toEqual(true); + }); + + it('should throw an error if unknown template field used', () => { + const view = { + title: 'Joe', + }; + + const source = '{{ title }} spends {{ unknownField }}'; + expect(() => expandPlaceholders(source, view)).toThrow(`There was an error expanding one or more placeholders. + +The error message was: + Unknown property: unknownField + +The problem is in: + {{ title }} spends {{ unknownField }}`); + }); + + it('should throw an error if unknown template nested field used', () => { + const queryContext = makeQueryContext('stuff.md'); + const source = '{{ query.file.nonsense }}'; + + expect(() => expandPlaceholders(source, queryContext)).toThrow( + `There was an error expanding one or more placeholders. + +The error message was: + Unknown property: query.file.nonsense + +The problem is in: + {{ query.file.nonsense }}`, + ); + }); +}); diff --git a/tests/Scripting/QueryContext.test.ts b/tests/Scripting/QueryContext.test.ts new file mode 100644 index 0000000000..b46a60688a --- /dev/null +++ b/tests/Scripting/QueryContext.test.ts @@ -0,0 +1,38 @@ +import { TaskBuilder } from '../TestingTools/TaskBuilder'; +import { FilenameField } from '../../src/Query/Filter/FilenameField'; +import { FolderField } from '../../src/Query/Filter/FolderField'; +import { PathField } from '../../src/Query/Filter/PathField'; +import { RootField } from '../../src/Query/Filter/RootField'; +import { makeQueryContext } from '../../src/Scripting/QueryContext'; + +describe('QueryContext', () => { + describe('values should all match their corresponding filters', () => { + const path = 'a/b/c.md'; + const task = new TaskBuilder().path(path).build(); + const queryContext = makeQueryContext(path); + + it('root', () => { + const instruction = `root includes ${queryContext.query.file.root}`; + const filter = new RootField().createFilterOrErrorMessage(instruction); + expect(filter).toMatchTask(task); + }); + + it('path', () => { + const instruction = `path includes ${queryContext.query.file.path}`; + const filter = new PathField().createFilterOrErrorMessage(instruction); + expect(filter).toMatchTask(task); + }); + + it('folder', () => { + const instruction = `folder includes ${queryContext.query.file.folder}`; + const filter = new FolderField().createFilterOrErrorMessage(instruction); + expect(filter).toMatchTask(task); + }); + + it('filename', () => { + const instruction = `filename includes ${queryContext.query.file.filename}`; + const filter = new FilenameField().createFilterOrErrorMessage(instruction); + expect(filter).toMatchTask(task); + }); + }); +}); diff --git a/tests/Scripting/QueryProperties.test.query_file_properties.approved.md b/tests/Scripting/QueryProperties.test.query_file_properties.approved.md new file mode 100644 index 0000000000..fd6709cd93 --- /dev/null +++ b/tests/Scripting/QueryProperties.test.query_file_properties.approved.md @@ -0,0 +1,11 @@ + + +| Field | Type | Example | +| ----- | ----- | ----- | +| `query.file.path` | `string` | `'root/sub-folder/file containing query.md'` | +| `query.file.root` | `string` | `'root/'` | +| `query.file.folder` | `string` | `'root/sub-folder/'` | +| `query.file.filename` | `string` | `'file containing query.md'` | + + + diff --git a/tests/Scripting/QueryProperties.test.ts b/tests/Scripting/QueryProperties.test.ts new file mode 100644 index 0000000000..ed4e4dc883 --- /dev/null +++ b/tests/Scripting/QueryProperties.test.ts @@ -0,0 +1,32 @@ +import { expandPlaceholders } from '../../src/Scripting/ExpandPlaceholders'; +import { makeQueryContext } from '../../src/Scripting/QueryContext'; + +import { MarkdownTable } from '../TestingTools/VerifyMarkdownTable'; +import { addBackticks, determineExpressionType, formatToRepresentType } from './ScriptingTestHelpers'; + +describe('query', () => { + function verifyFieldDataForReferenceDocs(fields: string[]) { + const markdownTable = new MarkdownTable(['Field', 'Type', 'Example']); + const path = 'root/sub-folder/file containing query.md'; + const queryContext = makeQueryContext(path); + for (const field of fields) { + const value1 = expandPlaceholders('{{' + field + '}}', queryContext); + const cells = [ + addBackticks(field), + addBackticks(determineExpressionType(value1)), + addBackticks(formatToRepresentType(value1)), + ]; + markdownTable.addRow(cells); + } + markdownTable.verifyForDocs(); + } + + it('file properties', () => { + verifyFieldDataForReferenceDocs([ + 'query.file.path', + 'query.file.root', + 'query.file.folder', + 'query.file.filename', + ]); + }); +}); 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 7517c02e93..a718898426 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 @@ -4,7 +4,12 @@ - Find tasks in files in any file in the given folder **only**, and not any sub-folders. - The equality test, `===`, requires that the trailing slash (`/`) be included. - ```filter by function task.file.folder.includes("Work/Projects/")``` - - Find tasks in files in any folder **and any sub-folders**. + - Find tasks in files in a specific folder **and any sub-folders**. +- ```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}}'``` + - Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**). - ```filter by function task.file.folder.includes("Work/Projects")``` - By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as: - `Work/Projects 2023/` 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 bbc1b8770f..ea0916b978 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 @@ -11,13 +11,33 @@ The equality test, `===`, requires that the trailing slash (`/`) be included. filter by function task.file.folder.includes("Work/Projects/") -Find tasks in files in any folder **and any sub-folders**. +Find tasks in files in a specific folder **and any sub-folders**. => - [ ] In Work/Projects/general projects stuff.md - [ ] In Work/Projects/Detail/detailed.md ==================================================================================== +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 +- [ ] xyz in a/d/c.md +- [ ] xyz in a/b/c.md +- [ ] xyz in a/b/_c_.md +- [ ] xyz in a/b/_c_.md +==================================================================================== + + +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 +==================================================================================== + + filter by function task.file.folder.includes("Work/Projects") By leaving off the trailing slash (`/`) this would also find tasks in any file inside folders such as: `Work/Projects 2023/` diff --git a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts index ffeb41009d..427e9f260e 100644 --- a/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts +++ b/tests/Scripting/ScriptingReference/CustomFiltering/CustomFilteringExamples.test.ts @@ -232,7 +232,16 @@ describe('file properties', () => { ], [ 'filter by function task.file.folder.includes("Work/Projects/")', - 'Find tasks in files in any folder **and any sub-folders**', + 'Find tasks in files in a specific folder **and any sub-folders**', + ], + [ + "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}}'", + 'Find tasks in files in the folder that contains the query only (**not tasks in any sub-folders**)', ], [ 'filter by function task.file.folder.includes("Work/Projects")', 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 0d43a87b80..f4c0fe0845 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 @@ -7,6 +7,7 @@ Like 'group by filename' but does not link to the file. => _c_.md a_b_c.md +b.md c.md ==================================================================================== @@ -18,6 +19,7 @@ Like 'group by backlink' but links to the heading in the file. [[_c_#heading _italic text_]] [[#heading]] [[a_b_c#a_b_c]] +[[b]] [[c]] [[c#c]] [[c#heading]] diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt index 580946a83d..f3f5f79e09 100644 --- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt +++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.folder_results.approved.txt @@ -6,6 +6,7 @@ group by function task.file.folder Same as 'group by folder'. => / +a/ a/b/ a/d/ e/d/ @@ -21,6 +22,7 @@ Here's how it works: Then the trailing slash is added back, to ensure we do not get an empty string for files in the top level of the vault. => / +a/ b/ d/ ==================================================================================== diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md index 579ee7512d..39bb1a8159 100644 --- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md +++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_docs.approved.md @@ -2,6 +2,11 @@ - ```group by function task.file.path``` - Like 'group by path' but includes the file extension. +- ```group by function task.file.path.replace('{{query.file.folder}}', '')``` + - Group by the task's file path, but remove the query's folder from the group. + - For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths. + - Note that the placeholder text is expanded to a raw string, so needs to be inside quotes. + - This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line. diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt index 3d6273a5de..435747ff61 100644 --- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt +++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.file_properties_task.file.path_results.approved.txt @@ -6,9 +6,25 @@ group by function task.file.path Like 'group by path' but includes the file extension. => a_b_c.md +a/b.md a/b/_c_.md a/b/c.md a/d/c.md e/d/c.md ==================================================================================== + +group by function task.file.path.replace('{{query.file.folder}}', '') +Group by the task's file path, but remove the query's folder from the group. +For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths. +Note that the placeholder text is expanded to a raw string, so needs to be inside quotes. +This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line. +=> +a_b_c.md +b.md +b/_c_.md +b/c.md +d/c.md +e/d/c.md +==================================================================================== + diff --git a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts index 699cbc9803..998e5025e9 100644 --- a/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts +++ b/tests/Scripting/ScriptingReference/CustomGrouping/CustomGroupingExamples.test.ts @@ -191,6 +191,13 @@ describe('file properties', () => { [ // comment to force line break ['group by function task.file.path', "Like 'group by path' but includes the file extension"], + [ + "group by function task.file.path.replace('{{query.file.folder}}', '')", + "Group by the task's file path, but remove the query's folder from the group.", + "For tasks in the query's folder or a sub-folder, this is a nice way of seeing shortened paths.", + 'Note that the placeholder text is expanded to a raw string, so needs to be inside quotes.', + "This is provided to give ideas: it's a bit of a lazy implementation, as it doesn't check that `'{{query.file.folder}}'` is at the start of the line.", + ], ], SampleTasks.withAllRootsPathsHeadings(), ], diff --git a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts index c4bac506e4..bc0cffa631 100644 --- a/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts +++ b/tests/Scripting/ScriptingReference/VerifyFunctionFieldSamples.ts @@ -3,6 +3,8 @@ import { FunctionField } from '../../../src/Query/Filter/FunctionField'; import type { Task } from '../../../src/Task'; import { groupHeadingsForTask } from '../../CustomMatchers/CustomMatchersForGrouping'; import { verifyMarkdownForDocs } from '../../TestingTools/VerifyMarkdownTable'; +import { expandPlaceholders } from '../../../src/Scripting/ExpandPlaceholders'; +import { makeQueryContext } from '../../../src/Scripting/QueryContext'; /** For example, 'task.due' */ type TaskPropertyName = string; @@ -63,7 +65,8 @@ export function verifyFunctionFieldFilterSamplesOnTasks(filters: QueryInstructio const instruction = filter[0]; const comment = filter.slice(1); - const filterOrErrorMessage = new FunctionField().createFilterOrErrorMessage(instruction); + const expandedInstruction = expandPlaceholders(instruction, makeQueryContext('a/b.md')); + const filterOrErrorMessage = new FunctionField().createFilterOrErrorMessage(expandedInstruction); expect(filterOrErrorMessage).toBeValid(); const filterFunction = filterOrErrorMessage.filterFunction!; @@ -104,7 +107,11 @@ export function verifyFunctionFieldGrouperSamplesOnTasks( verifyAll('Results of custom groupers', customGroups, (group) => { const instruction = group[0]; const comment = group.slice(1); - const grouper = new FunctionField().createGrouperFromLine(instruction); + + const expandedInstruction = expandPlaceholders(instruction, makeQueryContext('a/b.md')); + const grouper = new FunctionField().createGrouperFromLine(expandedInstruction); + expect(grouper).not.toBeNull(); + const headings = groupHeadingsForTask(grouper!, tasks); return formatQueryAndResultsForApproving(instruction, comment, headings); }); diff --git a/tests/TestHelpers.ts b/tests/TestHelpers.ts index cd48b4fcf2..ddf8c831a7 100644 --- a/tests/TestHelpers.ts +++ b/tests/TestHelpers.ts @@ -112,6 +112,7 @@ export class SampleTasks { ['', 'heading'], // no heading supplied + ['a/b.md', null], ['a/b/c.md', null], // File and heading, nominal case diff --git a/tests/TestingTools/ApprovalTestHelpers.ts b/tests/TestingTools/ApprovalTestHelpers.ts index 7509284fa3..098af6993e 100644 --- a/tests/TestingTools/ApprovalTestHelpers.ts +++ b/tests/TestingTools/ApprovalTestHelpers.ts @@ -73,7 +73,7 @@ export function verifyQueryExplanation(instructions: string, options?: Options): * @param options */ export function verifyTaskBlockExplanation(instructions: string, options?: Options): void { - const explanation = explainResults(instructions); + const explanation = explainResults(instructions, 'some/sample/file path.md'); options = options || new Options(); options = options.forFile().withFileExtention('explanation.text'); diff --git a/tests/lib/QueryRendererHelper.test.ts b/tests/lib/QueryRendererHelper.test.ts index 81a57df65a..96056ad8c2 100644 --- a/tests/lib/QueryRendererHelper.test.ts +++ b/tests/lib/QueryRendererHelper.test.ts @@ -102,16 +102,30 @@ describe('query used for QueryRenderer', () => { }); it('should be the result of combining the global query and the actual query', () => { + // Arrange const querySource = 'description includes world'; const globalQuerySource = 'description includes hello'; updateSettings({ globalQuery: globalQuerySource }); - expect(getQueryForQueryRenderer(querySource).source).toEqual(`${globalQuerySource}\n${querySource}`); + const filePath = 'a/b/c.md'; + + // Act + const query = getQueryForQueryRenderer(querySource, filePath); + + // Assert + expect(query.source).toEqual(`${globalQuerySource}\n${querySource}`); + expect(query.filePath).toEqual(filePath); }); it('should ignore the global query if "ignore global query" is set', () => { + // Arrange updateSettings({ globalQuery: 'path includes from_global_query' }); - expect(getQueryForQueryRenderer('description includes from_block_query\nignore global query').source).toEqual( - 'description includes from_block_query\nignore global query', - ); + const filePath = 'a/b/c.md'; + + // Act + const query = getQueryForQueryRenderer('description includes from_block_query\nignore global query', filePath); + + // Assert + expect(query.source).toEqual('description includes from_block_query\nignore global query'); + expect(query.filePath).toEqual(filePath); }); }); diff --git a/yarn.lock b/yarn.lock index 06c0c7cb1a..107d5277d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1240,6 +1240,11 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/mustache@^4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@types/mustache/-/mustache-4.2.2.tgz#825bf5c214c3ab84d0b23fef2c8eb898f3ff8717" + integrity sha512-MUSpfpW0yZbTgjekDbH0shMYBUD+X/uJJJMm9LXN1d5yjl5lCY1vN/eWKD6D1tOtjA6206K0zcIPnUaFMurdNA== + "@types/node@*": version "18.0.3" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.3.tgz#463fc47f13ec0688a33aec75d078a0541a447199" @@ -4454,6 +4459,16 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +mustache-validator@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/mustache-validator/-/mustache-validator-0.2.0.tgz#b9ef770263fe560429a7f90cb5866331f4274cb1" + integrity sha512-L4RIboVYTXHFHNWv5Sac3Ru63SUMgDRSY1G7YapeasgMf7IqpYIO/h8f+/5GJJIwcCcfyQXDFffD+VO6/F5f+Q== + +mustache@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + natives@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/natives/-/natives-1.1.6.tgz#a603b4a498ab77173612b9ea1acdec4d980f00bb"