From 9281ceaeb0e8dca82882a56a45fcca9b3e2be52b Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Mon, 9 Dec 2024 23:17:03 +0000 Subject: [PATCH 01/44] test: - Add some test files with queries that use frontmatter properties The mechanism for creating the json files is documented in https://publish.obsidian.md/tasks-contributing/Testing/Using+Obsidian+API+in+tests --- .../query_embed_multiline_property.md | 18 ++ ...property_as_instruction_via_placeholder.md | 13 ++ .../query_list_property_in_custom_filter.md | 24 +++ tests/Obsidian/AllCacheSampleData.ts | 6 + .../query_embed_multiline_property.json | 157 ++++++++++++++++++ ...operty_as_instruction_via_placeholder.json | 142 ++++++++++++++++ .../query_list_property_in_custom_filter.json | 145 ++++++++++++++++ 7 files changed, 505 insertions(+) create mode 100644 resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md create mode 100644 resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md create mode 100644 resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md create mode 100644 tests/Obsidian/__test_data__/query_embed_multiline_property.json create mode 100644 tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json create mode 100644 tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md new file mode 100644 index 0000000000..a66a7d7f9b --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md @@ -0,0 +1,18 @@ +--- +task_instructions: | + group by root + group by folder + group by filename +--- +# query_embed_multiline_property + +- [ ] #task Task in 'query_embed_multiline_property' + +Once query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings... + +```tasks +ignore global query +folder includes Test Data +explain +{{query.file.frontmatter.task_instructions}} +``` diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md new file mode 100644 index 0000000000..50b4df5837 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md @@ -0,0 +1,13 @@ +--- +task_instruction: group by filename +--- +# query_embed_property_as_instruction_via_placeholder + +- [ ] #task Task in 'query_embed_property_as_instruction_via_placeholder' + +```tasks +explain +ignore global query +{{query.file.frontmatter.task_instruction}} +limit 10 +``` diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md new file mode 100644 index 0000000000..e2bcf4621e --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md @@ -0,0 +1,24 @@ +--- +root_dirs_to_search: + - Formats/ + - Filters/ +--- + +# query_list_property_in_custom_filter + +- [ ] #task Task in 'query_list_property_in_custom_filter' + +```tasks +ignore global query +explain + +filter by function \ + if (!query.file.hasProperty('root_dirs_to_search')) { \ + throw Error('Please set the "root_dirs_to_search" list property, with each value ending in a backslash...'); \ + } \ + const roots = query.file.property('root_dirs_to_search'); \ + return roots.includes(task.file.root); + +limit groups 5 +group by root +``` diff --git a/tests/Obsidian/AllCacheSampleData.ts b/tests/Obsidian/AllCacheSampleData.ts index 3f9f7590ef..72448e4442 100644 --- a/tests/Obsidian/AllCacheSampleData.ts +++ b/tests/Obsidian/AllCacheSampleData.ts @@ -50,6 +50,9 @@ import no_heading from './__test_data__/no_heading.json'; import no_yaml from './__test_data__/no_yaml.json'; import non_tasks from './__test_data__/non_tasks.json'; import one_task from './__test_data__/one_task.json'; +import query_embed_multiline_property from './__test_data__/query_embed_multiline_property.json'; +import query_embed_property_as_instruction_via_placeholder from './__test_data__/query_embed_property_as_instruction_via_placeholder.json'; +import query_list_property_in_custom_filter from './__test_data__/query_list_property_in_custom_filter.json'; import yaml_1_alias from './__test_data__/yaml_1_alias.json'; import yaml_2_aliases from './__test_data__/yaml_2_aliases.json'; import yaml_all_property_types_empty from './__test_data__/yaml_all_property_types_empty.json'; @@ -119,6 +122,9 @@ export function allCacheSampleData() { no_yaml, non_tasks, one_task, + query_embed_multiline_property, + query_embed_property_as_instruction_via_placeholder, + query_list_property_in_custom_filter, yaml_1_alias, yaml_2_aliases, yaml_all_property_types_empty, diff --git a/tests/Obsidian/__test_data__/query_embed_multiline_property.json b/tests/Obsidian/__test_data__/query_embed_multiline_property.json new file mode 100644 index 0000000000..a8f2513faa --- /dev/null +++ b/tests/Obsidian/__test_data__/query_embed_multiline_property.json @@ -0,0 +1,157 @@ +{ + "cachedMetadata": { + "frontmatter": { + "task_instructions": "group by root\ngroup by folder\ngroup by filename\n" + }, + "frontmatterLinks": [], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 5, + "offset": 82 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "query_embed_multiline_property", + "level": 1, + "position": { + "end": { + "col": 32, + "line": 6, + "offset": 115 + }, + "start": { + "col": 0, + "line": 6, + "offset": 83 + } + } + } + ], + "listItems": [ + { + "parent": -8, + "position": { + "end": { + "col": 52, + "line": 8, + "offset": 169 + }, + "start": { + "col": 0, + "line": 8, + "offset": 117 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 5, + "offset": 82 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 32, + "line": 6, + "offset": 115 + }, + "start": { + "col": 0, + "line": 6, + "offset": 83 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 52, + "line": 8, + "offset": 169 + }, + "start": { + "col": 0, + "line": 8, + "offset": 117 + } + }, + "type": "list" + }, + { + "position": { + "end": { + "col": 130, + "line": 10, + "offset": 301 + }, + "start": { + "col": 0, + "line": 10, + "offset": 171 + } + }, + "type": "paragraph" + }, + { + "position": { + "end": { + "col": 3, + "line": 17, + "offset": 414 + }, + "start": { + "col": 0, + "line": 12, + "offset": 303 + } + }, + "type": "code" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 8, + "offset": 128 + }, + "start": { + "col": 6, + "line": 8, + "offset": 123 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\ntask_instructions: |\n group by root\n group by folder\n group by filename\n---\n# query_embed_multiline_property\n\n- [ ] #task Task in 'query_embed_multiline_property'\n\nOnce query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings...\n\n```tasks\nignore global query\nfolder includes Test Data\nexplain\n{{query.file.frontmatter.task_instructions}}\n```\n", + "filePath": "Test Data/query_embed_multiline_property.md", + "getAllTags": [ + "#task" + ], + "obsidianApiVersion": "1.7.7", + "parseFrontMatterTags": null +} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json b/tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json new file mode 100644 index 0000000000..677acafc07 --- /dev/null +++ b/tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json @@ -0,0 +1,142 @@ +{ + "cachedMetadata": { + "frontmatter": { + "task_instruction": "group by filename" + }, + "frontmatterLinks": [], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 2, + "offset": 43 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "query_embed_property_as_instruction_via_placeholder", + "level": 1, + "position": { + "end": { + "col": 53, + "line": 3, + "offset": 97 + }, + "start": { + "col": 0, + "line": 3, + "offset": 44 + } + } + } + ], + "listItems": [ + { + "parent": -5, + "position": { + "end": { + "col": 73, + "line": 5, + "offset": 172 + }, + "start": { + "col": 0, + "line": 5, + "offset": 99 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 2, + "offset": 43 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 53, + "line": 3, + "offset": 97 + }, + "start": { + "col": 0, + "line": 3, + "offset": 44 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 73, + "line": 5, + "offset": 172 + }, + "start": { + "col": 0, + "line": 5, + "offset": 99 + } + }, + "type": "list" + }, + { + "position": { + "end": { + "col": 3, + "line": 12, + "offset": 267 + }, + "start": { + "col": 0, + "line": 7, + "offset": 174 + } + }, + "type": "code" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 5, + "offset": 110 + }, + "start": { + "col": 6, + "line": 5, + "offset": 105 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\ntask_instruction: group by filename\n---\n# query_embed_property_as_instruction_via_placeholder\n\n- [ ] #task Task in 'query_embed_property_as_instruction_via_placeholder'\n\n```tasks\nexplain\nignore global query\n{{query.file.frontmatter.task_instruction}}\nlimit 10\n```\n", + "filePath": "Test Data/query_embed_property_as_instruction_via_placeholder.md", + "getAllTags": [ + "#task" + ], + "obsidianApiVersion": "1.7.7", + "parseFrontMatterTags": null +} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json b/tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json new file mode 100644 index 0000000000..67faaf1841 --- /dev/null +++ b/tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json @@ -0,0 +1,145 @@ +{ + "cachedMetadata": { + "frontmatter": { + "root_dirs_to_search": [ + "Formats/", + "Filters/" + ] + }, + "frontmatterLinks": [], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "query_list_property_in_custom_filter", + "level": 1, + "position": { + "end": { + "col": 38, + "line": 6, + "offset": 94 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + } + } + ], + "listItems": [ + { + "parent": -8, + "position": { + "end": { + "col": 58, + "line": 8, + "offset": 154 + }, + "start": { + "col": 0, + "line": 8, + "offset": 96 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 4, + "offset": 54 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 38, + "line": 6, + "offset": 94 + }, + "start": { + "col": 0, + "line": 6, + "offset": 56 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 58, + "line": 8, + "offset": 154 + }, + "start": { + "col": 0, + "line": 8, + "offset": 96 + } + }, + "type": "list" + }, + { + "position": { + "end": { + "col": 3, + "line": 23, + "offset": 542 + }, + "start": { + "col": 0, + "line": 10, + "offset": 156 + } + }, + "type": "code" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 8, + "offset": 107 + }, + "start": { + "col": 6, + "line": 8, + "offset": 102 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\nroot_dirs_to_search:\n - Formats/\n - Filters/\n---\n\n# query_list_property_in_custom_filter\n\n- [ ] #task Task in 'query_list_property_in_custom_filter'\n\n```tasks\nignore global query\nexplain\n\nfilter by function \\\n if (!query.file.hasProperty('root_dirs_to_search')) { \\\n throw Error('Please set the \"root_dirs_to_search\" list property, with each value ending in a backslash...'); \\\n } \\\n const roots = query.file.property('root_dirs_to_search'); \\\n return roots.includes(task.file.root);\n\nlimit groups 5\ngroup by root\n```\n", + "filePath": "Test Data/query_list_property_in_custom_filter.md", + "getAllTags": [ + "#task" + ], + "obsidianApiVersion": "1.7.7", + "parseFrontMatterTags": null +} \ No newline at end of file From c8da67d44fa8516c89911a415a9e3ec2bf89a5c4 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 14:34:08 +0000 Subject: [PATCH 02/44] test: - Add test showing use of query.file properties in placeholders --- tests/Query/Query.test.ts | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 7c28af63b7..dc83775b57 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -22,6 +22,8 @@ import { shouldSupportFiltering } from '../TestingTools/FilterTestHelpers'; import { TaskBuilder } from '../TestingTools/TaskBuilder'; import { Priority } from '../../src/Task/Priority'; import { TaskLayoutComponent } from '../../src/Layout/TaskLayoutOptions'; +import query_embed_property_as_instruction_via_placeholder from '../Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json'; +import { getTasksFileFromMockData } from '../TestingTools/MockDataHelpers'; window.moment = moment; @@ -768,6 +770,64 @@ Problem statement: expect(query.filters.length).toEqual(0); }); }); + + describe('properties in the query file', () => { + describe('via placeholders - used with query.file.property() - documented', () => { + it('cannot currently use query.file.property() via placeholder', () => { + // Arrange + const file = getTasksFileFromMockData(query_embed_property_as_instruction_via_placeholder); + expect(file.property('task_instruction')).toEqual('group by filename'); + + // Act + const source = "{{query.file.property('task_instruction')}}"; + const query = new Query(source, file); + + // Unfortunately, this placeholder FAILS because the mustache.js templating library + // does not support function calls. + // Which is a shame, because TasksFile.property() and TasksFile.hasProperty() have some nice logic + // for various special cases. + // TODO Consider switching to a different templating library that supports function calls. + + // Assert + expect(query.error).not.toBeUndefined(); + expect(query.error).toMatchInlineSnapshot(` + "There was an error expanding one or more placeholders. + + The error message was: + Unknown property: query.file.property('task_instruction') + + The problem is in: + {{query.file.property('task_instruction')}}" + `); + }); + }); + + describe('via placeholders - used with query.file.frontmatter() - UNDOCUMENTED', () => { + it('should access query.file.frontmatter via placeholder', () => { + // Arrange + const file = getTasksFileFromMockData(query_embed_property_as_instruction_via_placeholder); + expect(file.frontmatter.task_instruction).toEqual('group by filename'); + + // Act + const source = '{{query.file.frontmatter.task_instruction}}'; + const query = new Query(source, file); + + // This use of query.file.frontmatter does work in placeholders, as it is raw data. + // So far, I have not documented use of query.file.frontmatter (and task.file.frontmatter) + // because there are a lot of special cases to understand in the handling of raw frontmatter data: + // it is too error-prone for the average user... + + // Assert + expect(query.error).toBeUndefined(); + expect(query.grouping.length).toEqual(1); + expect(query.grouping[0].instruction).toEqual('group by filename'); + }); + }); + + // TODO resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md + + // TODO resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md + }); }); describe('Query', () => { From 6342c9e6da232358ba2c6a578c33886a0c5ef76c Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 22:16:09 +0000 Subject: [PATCH 03/44] test: - Unify the 3 query properties test files in to one This will make it a bit simpler to write tests, as one TasksFile can be used in several tests. --- .../query_embed_multiline_property.md | 18 -- ...property_as_instruction_via_placeholder.md | 13 - .../query_list_property_in_custom_filter.md | 24 -- .../Test Data/query_using_properties.md | 53 ++++ tests/Obsidian/AllCacheSampleData.ts | 8 +- .../query_embed_multiline_property.json | 157 --------- ...operty_as_instruction_via_placeholder.json | 142 --------- .../query_list_property_in_custom_filter.json | 145 --------- .../__test_data__/query_using_properties.json | 300 ++++++++++++++++++ tests/Query/Query.test.ts | 6 +- 10 files changed, 358 insertions(+), 508 deletions(-) delete mode 100644 resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md delete mode 100644 resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md delete mode 100644 resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md create mode 100644 resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md delete mode 100644 tests/Obsidian/__test_data__/query_embed_multiline_property.json delete mode 100644 tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json delete mode 100644 tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json create mode 100644 tests/Obsidian/__test_data__/query_using_properties.json diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md deleted file mode 100644 index a66a7d7f9b..0000000000 --- a/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -task_instructions: | - group by root - group by folder - group by filename ---- -# query_embed_multiline_property - -- [ ] #task Task in 'query_embed_multiline_property' - -Once query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings... - -```tasks -ignore global query -folder includes Test Data -explain -{{query.file.frontmatter.task_instructions}} -``` diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md deleted file mode 100644 index 50b4df5837..0000000000 --- a/resources/sample_vaults/Tasks-Demo/Test Data/query_embed_property_as_instruction_via_placeholder.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -task_instruction: group by filename ---- -# query_embed_property_as_instruction_via_placeholder - -- [ ] #task Task in 'query_embed_property_as_instruction_via_placeholder' - -```tasks -explain -ignore global query -{{query.file.frontmatter.task_instruction}} -limit 10 -``` diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md deleted file mode 100644 index e2bcf4621e..0000000000 --- a/resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -root_dirs_to_search: - - Formats/ - - Filters/ ---- - -# query_list_property_in_custom_filter - -- [ ] #task Task in 'query_list_property_in_custom_filter' - -```tasks -ignore global query -explain - -filter by function \ - if (!query.file.hasProperty('root_dirs_to_search')) { \ - throw Error('Please set the "root_dirs_to_search" list property, with each value ending in a backslash...'); \ - } \ - const roots = query.file.property('root_dirs_to_search'); \ - return roots.includes(task.file.root); - -limit groups 5 -group by root -``` diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md new file mode 100644 index 0000000000..dbff109606 --- /dev/null +++ b/resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md @@ -0,0 +1,53 @@ +--- +root_dirs_to_search: + - Formats/ + - Filters/ +task_instruction: group by filename +task_instructions: | + group by root + group by folder + group by filename +--- + +# query_using_properties + +- [ ] #task Task in 'query_using_properties' + +## Use a one-line property: task_instruction + +Read a Tasks instruction from a property in this file, and embed it in to any number of queries in the file: + +```tasks +explain +ignore global query +{{query.file.frontmatter.task_instruction}} +limit 10 +``` + +## Use a multi-line property: task_instructions + +Once query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings... + +```tasks +ignore global query +folder includes Test Data +explain +{{query.file.frontmatter.task_instructions}} +``` + +## Use a list property in a custom filter: root_dirs_to_search + +```tasks +ignore global query +explain + +filter by function \ + if (!query.file.hasProperty('root_dirs_to_search')) { \ + throw Error('Please set the "root_dirs_to_search" list property, with each value ending in a backslash...'); \ + } \ + const roots = query.file.property('root_dirs_to_search'); \ + return roots.includes(task.file.root); + +limit groups 5 +group by root +``` diff --git a/tests/Obsidian/AllCacheSampleData.ts b/tests/Obsidian/AllCacheSampleData.ts index 72448e4442..e33c69f105 100644 --- a/tests/Obsidian/AllCacheSampleData.ts +++ b/tests/Obsidian/AllCacheSampleData.ts @@ -50,9 +50,7 @@ import no_heading from './__test_data__/no_heading.json'; import no_yaml from './__test_data__/no_yaml.json'; import non_tasks from './__test_data__/non_tasks.json'; import one_task from './__test_data__/one_task.json'; -import query_embed_multiline_property from './__test_data__/query_embed_multiline_property.json'; -import query_embed_property_as_instruction_via_placeholder from './__test_data__/query_embed_property_as_instruction_via_placeholder.json'; -import query_list_property_in_custom_filter from './__test_data__/query_list_property_in_custom_filter.json'; +import query_using_properties from './__test_data__/query_using_properties.json'; import yaml_1_alias from './__test_data__/yaml_1_alias.json'; import yaml_2_aliases from './__test_data__/yaml_2_aliases.json'; import yaml_all_property_types_empty from './__test_data__/yaml_all_property_types_empty.json'; @@ -122,9 +120,7 @@ export function allCacheSampleData() { no_yaml, non_tasks, one_task, - query_embed_multiline_property, - query_embed_property_as_instruction_via_placeholder, - query_list_property_in_custom_filter, + query_using_properties, yaml_1_alias, yaml_2_aliases, yaml_all_property_types_empty, diff --git a/tests/Obsidian/__test_data__/query_embed_multiline_property.json b/tests/Obsidian/__test_data__/query_embed_multiline_property.json deleted file mode 100644 index a8f2513faa..0000000000 --- a/tests/Obsidian/__test_data__/query_embed_multiline_property.json +++ /dev/null @@ -1,157 +0,0 @@ -{ - "cachedMetadata": { - "frontmatter": { - "task_instructions": "group by root\ngroup by folder\ngroup by filename\n" - }, - "frontmatterLinks": [], - "frontmatterPosition": { - "end": { - "col": 3, - "line": 5, - "offset": 82 - }, - "start": { - "col": 0, - "line": 0, - "offset": 0 - } - }, - "headings": [ - { - "heading": "query_embed_multiline_property", - "level": 1, - "position": { - "end": { - "col": 32, - "line": 6, - "offset": 115 - }, - "start": { - "col": 0, - "line": 6, - "offset": 83 - } - } - } - ], - "listItems": [ - { - "parent": -8, - "position": { - "end": { - "col": 52, - "line": 8, - "offset": 169 - }, - "start": { - "col": 0, - "line": 8, - "offset": 117 - } - }, - "task": " " - } - ], - "sections": [ - { - "position": { - "end": { - "col": 3, - "line": 5, - "offset": 82 - }, - "start": { - "col": 0, - "line": 0, - "offset": 0 - } - }, - "type": "yaml" - }, - { - "position": { - "end": { - "col": 32, - "line": 6, - "offset": 115 - }, - "start": { - "col": 0, - "line": 6, - "offset": 83 - } - }, - "type": "heading" - }, - { - "position": { - "end": { - "col": 52, - "line": 8, - "offset": 169 - }, - "start": { - "col": 0, - "line": 8, - "offset": 117 - } - }, - "type": "list" - }, - { - "position": { - "end": { - "col": 130, - "line": 10, - "offset": 301 - }, - "start": { - "col": 0, - "line": 10, - "offset": 171 - } - }, - "type": "paragraph" - }, - { - "position": { - "end": { - "col": 3, - "line": 17, - "offset": 414 - }, - "start": { - "col": 0, - "line": 12, - "offset": 303 - } - }, - "type": "code" - } - ], - "tags": [ - { - "position": { - "end": { - "col": 11, - "line": 8, - "offset": 128 - }, - "start": { - "col": 6, - "line": 8, - "offset": 123 - } - }, - "tag": "#task" - } - ] - }, - "fileContents": "---\ntask_instructions: |\n group by root\n group by folder\n group by filename\n---\n# query_embed_multiline_property\n\n- [ ] #task Task in 'query_embed_multiline_property'\n\nOnce query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings...\n\n```tasks\nignore global query\nfolder includes Test Data\nexplain\n{{query.file.frontmatter.task_instructions}}\n```\n", - "filePath": "Test Data/query_embed_multiline_property.md", - "getAllTags": [ - "#task" - ], - "obsidianApiVersion": "1.7.7", - "parseFrontMatterTags": null -} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json b/tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json deleted file mode 100644 index 677acafc07..0000000000 --- a/tests/Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json +++ /dev/null @@ -1,142 +0,0 @@ -{ - "cachedMetadata": { - "frontmatter": { - "task_instruction": "group by filename" - }, - "frontmatterLinks": [], - "frontmatterPosition": { - "end": { - "col": 3, - "line": 2, - "offset": 43 - }, - "start": { - "col": 0, - "line": 0, - "offset": 0 - } - }, - "headings": [ - { - "heading": "query_embed_property_as_instruction_via_placeholder", - "level": 1, - "position": { - "end": { - "col": 53, - "line": 3, - "offset": 97 - }, - "start": { - "col": 0, - "line": 3, - "offset": 44 - } - } - } - ], - "listItems": [ - { - "parent": -5, - "position": { - "end": { - "col": 73, - "line": 5, - "offset": 172 - }, - "start": { - "col": 0, - "line": 5, - "offset": 99 - } - }, - "task": " " - } - ], - "sections": [ - { - "position": { - "end": { - "col": 3, - "line": 2, - "offset": 43 - }, - "start": { - "col": 0, - "line": 0, - "offset": 0 - } - }, - "type": "yaml" - }, - { - "position": { - "end": { - "col": 53, - "line": 3, - "offset": 97 - }, - "start": { - "col": 0, - "line": 3, - "offset": 44 - } - }, - "type": "heading" - }, - { - "position": { - "end": { - "col": 73, - "line": 5, - "offset": 172 - }, - "start": { - "col": 0, - "line": 5, - "offset": 99 - } - }, - "type": "list" - }, - { - "position": { - "end": { - "col": 3, - "line": 12, - "offset": 267 - }, - "start": { - "col": 0, - "line": 7, - "offset": 174 - } - }, - "type": "code" - } - ], - "tags": [ - { - "position": { - "end": { - "col": 11, - "line": 5, - "offset": 110 - }, - "start": { - "col": 6, - "line": 5, - "offset": 105 - } - }, - "tag": "#task" - } - ] - }, - "fileContents": "---\ntask_instruction: group by filename\n---\n# query_embed_property_as_instruction_via_placeholder\n\n- [ ] #task Task in 'query_embed_property_as_instruction_via_placeholder'\n\n```tasks\nexplain\nignore global query\n{{query.file.frontmatter.task_instruction}}\nlimit 10\n```\n", - "filePath": "Test Data/query_embed_property_as_instruction_via_placeholder.md", - "getAllTags": [ - "#task" - ], - "obsidianApiVersion": "1.7.7", - "parseFrontMatterTags": null -} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json b/tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json deleted file mode 100644 index 67faaf1841..0000000000 --- a/tests/Obsidian/__test_data__/query_list_property_in_custom_filter.json +++ /dev/null @@ -1,145 +0,0 @@ -{ - "cachedMetadata": { - "frontmatter": { - "root_dirs_to_search": [ - "Formats/", - "Filters/" - ] - }, - "frontmatterLinks": [], - "frontmatterPosition": { - "end": { - "col": 3, - "line": 4, - "offset": 54 - }, - "start": { - "col": 0, - "line": 0, - "offset": 0 - } - }, - "headings": [ - { - "heading": "query_list_property_in_custom_filter", - "level": 1, - "position": { - "end": { - "col": 38, - "line": 6, - "offset": 94 - }, - "start": { - "col": 0, - "line": 6, - "offset": 56 - } - } - } - ], - "listItems": [ - { - "parent": -8, - "position": { - "end": { - "col": 58, - "line": 8, - "offset": 154 - }, - "start": { - "col": 0, - "line": 8, - "offset": 96 - } - }, - "task": " " - } - ], - "sections": [ - { - "position": { - "end": { - "col": 3, - "line": 4, - "offset": 54 - }, - "start": { - "col": 0, - "line": 0, - "offset": 0 - } - }, - "type": "yaml" - }, - { - "position": { - "end": { - "col": 38, - "line": 6, - "offset": 94 - }, - "start": { - "col": 0, - "line": 6, - "offset": 56 - } - }, - "type": "heading" - }, - { - "position": { - "end": { - "col": 58, - "line": 8, - "offset": 154 - }, - "start": { - "col": 0, - "line": 8, - "offset": 96 - } - }, - "type": "list" - }, - { - "position": { - "end": { - "col": 3, - "line": 23, - "offset": 542 - }, - "start": { - "col": 0, - "line": 10, - "offset": 156 - } - }, - "type": "code" - } - ], - "tags": [ - { - "position": { - "end": { - "col": 11, - "line": 8, - "offset": 107 - }, - "start": { - "col": 6, - "line": 8, - "offset": 102 - } - }, - "tag": "#task" - } - ] - }, - "fileContents": "---\nroot_dirs_to_search:\n - Formats/\n - Filters/\n---\n\n# query_list_property_in_custom_filter\n\n- [ ] #task Task in 'query_list_property_in_custom_filter'\n\n```tasks\nignore global query\nexplain\n\nfilter by function \\\n if (!query.file.hasProperty('root_dirs_to_search')) { \\\n throw Error('Please set the \"root_dirs_to_search\" list property, with each value ending in a backslash...'); \\\n } \\\n const roots = query.file.property('root_dirs_to_search'); \\\n return roots.includes(task.file.root);\n\nlimit groups 5\ngroup by root\n```\n", - "filePath": "Test Data/query_list_property_in_custom_filter.md", - "getAllTags": [ - "#task" - ], - "obsidianApiVersion": "1.7.7", - "parseFrontMatterTags": null -} \ No newline at end of file diff --git a/tests/Obsidian/__test_data__/query_using_properties.json b/tests/Obsidian/__test_data__/query_using_properties.json new file mode 100644 index 0000000000..cdbbf0c286 --- /dev/null +++ b/tests/Obsidian/__test_data__/query_using_properties.json @@ -0,0 +1,300 @@ +{ + "cachedMetadata": { + "frontmatter": { + "root_dirs_to_search": [ + "Formats/", + "Filters/" + ], + "task_instruction": "group by filename", + "task_instructions": "group by root\ngroup by folder\ngroup by filename\n" + }, + "frontmatterLinks": [], + "frontmatterPosition": { + "end": { + "col": 3, + "line": 9, + "offset": 165 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "headings": [ + { + "heading": "query_using_properties", + "level": 1, + "position": { + "end": { + "col": 24, + "line": 11, + "offset": 191 + }, + "start": { + "col": 0, + "line": 11, + "offset": 167 + } + } + }, + { + "heading": "Use a one-line property: task_instruction", + "level": 2, + "position": { + "end": { + "col": 44, + "line": 15, + "offset": 283 + }, + "start": { + "col": 0, + "line": 15, + "offset": 239 + } + } + }, + { + "heading": "Use a multi-line property: task_instructions", + "level": 2, + "position": { + "end": { + "col": 47, + "line": 26, + "offset": 537 + }, + "start": { + "col": 0, + "line": 26, + "offset": 490 + } + } + }, + { + "heading": "Use a list property in a custom filter: root_dirs_to_search", + "level": 2, + "position": { + "end": { + "col": 62, + "line": 37, + "offset": 846 + }, + "start": { + "col": 0, + "line": 37, + "offset": 784 + } + } + } + ], + "listItems": [ + { + "parent": -13, + "position": { + "end": { + "col": 44, + "line": 13, + "offset": 237 + }, + "start": { + "col": 0, + "line": 13, + "offset": 193 + } + }, + "task": " " + } + ], + "sections": [ + { + "position": { + "end": { + "col": 3, + "line": 9, + "offset": 165 + }, + "start": { + "col": 0, + "line": 0, + "offset": 0 + } + }, + "type": "yaml" + }, + { + "position": { + "end": { + "col": 24, + "line": 11, + "offset": 191 + }, + "start": { + "col": 0, + "line": 11, + "offset": 167 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 44, + "line": 13, + "offset": 237 + }, + "start": { + "col": 0, + "line": 13, + "offset": 193 + } + }, + "type": "list" + }, + { + "position": { + "end": { + "col": 44, + "line": 15, + "offset": 283 + }, + "start": { + "col": 0, + "line": 15, + "offset": 239 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 108, + "line": 17, + "offset": 393 + }, + "start": { + "col": 0, + "line": 17, + "offset": 285 + } + }, + "type": "paragraph" + }, + { + "position": { + "end": { + "col": 3, + "line": 24, + "offset": 488 + }, + "start": { + "col": 0, + "line": 19, + "offset": 395 + } + }, + "type": "code" + }, + { + "position": { + "end": { + "col": 47, + "line": 26, + "offset": 537 + }, + "start": { + "col": 0, + "line": 26, + "offset": 490 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 130, + "line": 28, + "offset": 669 + }, + "start": { + "col": 0, + "line": 28, + "offset": 539 + } + }, + "type": "paragraph" + }, + { + "position": { + "end": { + "col": 3, + "line": 35, + "offset": 782 + }, + "start": { + "col": 0, + "line": 30, + "offset": 671 + } + }, + "type": "code" + }, + { + "position": { + "end": { + "col": 62, + "line": 37, + "offset": 846 + }, + "start": { + "col": 0, + "line": 37, + "offset": 784 + } + }, + "type": "heading" + }, + { + "position": { + "end": { + "col": 3, + "line": 52, + "offset": 1234 + }, + "start": { + "col": 0, + "line": 39, + "offset": 848 + } + }, + "type": "code" + } + ], + "tags": [ + { + "position": { + "end": { + "col": 11, + "line": 13, + "offset": 204 + }, + "start": { + "col": 6, + "line": 13, + "offset": 199 + } + }, + "tag": "#task" + } + ] + }, + "fileContents": "---\nroot_dirs_to_search:\n - Formats/\n - Filters/\ntask_instruction: group by filename\ntask_instructions: |\n group by root\n group by folder\n group by filename\n---\n\n# query_using_properties\n\n- [ ] #task Task in 'query_using_properties'\n\n## Use a one-line property: task_instruction\n\nRead a Tasks instruction from a property in this file, and embed it in to any number of queries in the file:\n\n```tasks\nexplain\nignore global query\n{{query.file.frontmatter.task_instruction}}\nlimit 10\n```\n\n## Use a multi-line property: task_instructions\n\nOnce query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings...\n\n```tasks\nignore global query\nfolder includes Test Data\nexplain\n{{query.file.frontmatter.task_instructions}}\n```\n\n## Use a list property in a custom filter: root_dirs_to_search\n\n```tasks\nignore global query\nexplain\n\nfilter by function \\\n if (!query.file.hasProperty('root_dirs_to_search')) { \\\n throw Error('Please set the \"root_dirs_to_search\" list property, with each value ending in a backslash...'); \\\n } \\\n const roots = query.file.property('root_dirs_to_search'); \\\n return roots.includes(task.file.root);\n\nlimit groups 5\ngroup by root\n```\n", + "filePath": "Test Data/query_using_properties.md", + "getAllTags": [ + "#task" + ], + "obsidianApiVersion": "1.7.7", + "parseFrontMatterTags": null +} \ No newline at end of file diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index dc83775b57..48ebbbe257 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -22,7 +22,7 @@ import { shouldSupportFiltering } from '../TestingTools/FilterTestHelpers'; import { TaskBuilder } from '../TestingTools/TaskBuilder'; import { Priority } from '../../src/Task/Priority'; import { TaskLayoutComponent } from '../../src/Layout/TaskLayoutOptions'; -import query_embed_property_as_instruction_via_placeholder from '../Obsidian/__test_data__/query_embed_property_as_instruction_via_placeholder.json'; +import query_using_properties from '../Obsidian/__test_data__/query_using_properties.json'; import { getTasksFileFromMockData } from '../TestingTools/MockDataHelpers'; window.moment = moment; @@ -775,7 +775,7 @@ Problem statement: describe('via placeholders - used with query.file.property() - documented', () => { it('cannot currently use query.file.property() via placeholder', () => { // Arrange - const file = getTasksFileFromMockData(query_embed_property_as_instruction_via_placeholder); + const file = getTasksFileFromMockData(query_using_properties); expect(file.property('task_instruction')).toEqual('group by filename'); // Act @@ -805,7 +805,7 @@ Problem statement: describe('via placeholders - used with query.file.frontmatter() - UNDOCUMENTED', () => { it('should access query.file.frontmatter via placeholder', () => { // Arrange - const file = getTasksFileFromMockData(query_embed_property_as_instruction_via_placeholder); + const file = getTasksFileFromMockData(query_using_properties); expect(file.frontmatter.task_instruction).toEqual('group by filename'); // Act From 1fcf94f6cd09e5aa173d466f14a4adbb303a32ca Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 22:18:57 +0000 Subject: [PATCH 04/44] test: . Update some comments --- tests/Query/Query.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 48ebbbe257..f4a73b96ea 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -787,6 +787,8 @@ Problem statement: // Which is a shame, because TasksFile.property() and TasksFile.hasProperty() have some nice logic // for various special cases. // TODO Consider switching to a different templating library that supports function calls. + // Or see if the Tasks placeholder code can detect function calls, and replace them somehow + // with the result of the function call. // Assert expect(query.error).not.toBeUndefined(); From 02d2bf11c0bf1d4bf390b795805e6021679d1c22 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 22:21:31 +0000 Subject: [PATCH 05/44] test: - Only construct the test TasksFile once, as tests now share same metadata --- tests/Query/Query.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index f4a73b96ea..51a2d2c434 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -772,10 +772,11 @@ Problem statement: }); describe('properties in the query file', () => { + const file = getTasksFileFromMockData(query_using_properties); + describe('via placeholders - used with query.file.property() - documented', () => { it('cannot currently use query.file.property() via placeholder', () => { // Arrange - const file = getTasksFileFromMockData(query_using_properties); expect(file.property('task_instruction')).toEqual('group by filename'); // Act @@ -807,7 +808,6 @@ Problem statement: describe('via placeholders - used with query.file.frontmatter() - UNDOCUMENTED', () => { it('should access query.file.frontmatter via placeholder', () => { // Arrange - const file = getTasksFileFromMockData(query_using_properties); expect(file.frontmatter.task_instruction).toEqual('group by filename'); // Act From f94bf16b4f23a363e917364ca8ec51ae2b57c3f0 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 22:23:47 +0000 Subject: [PATCH 06/44] test: - The Arrange sections are no longer needed --- tests/Query/Query.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 51a2d2c434..9d088343b2 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -776,9 +776,6 @@ Problem statement: describe('via placeholders - used with query.file.property() - documented', () => { it('cannot currently use query.file.property() via placeholder', () => { - // Arrange - expect(file.property('task_instruction')).toEqual('group by filename'); - // Act const source = "{{query.file.property('task_instruction')}}"; const query = new Query(source, file); @@ -792,6 +789,8 @@ Problem statement: // with the result of the function call. // Assert + expect(file.property('task_instruction')).toEqual('group by filename'); + expect(query.error).not.toBeUndefined(); expect(query.error).toMatchInlineSnapshot(` "There was an error expanding one or more placeholders. @@ -807,9 +806,6 @@ Problem statement: describe('via placeholders - used with query.file.frontmatter() - UNDOCUMENTED', () => { it('should access query.file.frontmatter via placeholder', () => { - // Arrange - expect(file.frontmatter.task_instruction).toEqual('group by filename'); - // Act const source = '{{query.file.frontmatter.task_instruction}}'; const query = new Query(source, file); @@ -820,6 +816,8 @@ Problem statement: // it is too error-prone for the average user... // Assert + expect(file.frontmatter.task_instruction).toEqual('group by filename'); + expect(query.error).toBeUndefined(); expect(query.grouping.length).toEqual(1); expect(query.grouping[0].instruction).toEqual('group by filename'); From f8ecd184930b8d13e6e644e6634f8f46502fa92a Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 22:33:50 +0000 Subject: [PATCH 07/44] test: - Add test showing failure if using multi-line placeholders --- tests/Query/Query.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 9d088343b2..c03fdde369 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -822,6 +822,34 @@ Problem statement: expect(query.grouping.length).toEqual(1); expect(query.grouping[0].instruction).toEqual('group by filename'); }); + + it('should access multi-line property with query.file.frontmatter via placeholder', () => { + // Act + const source = '{{query.file.frontmatter.task_instructions}}'; + const query = new Query(source, file); + + // This fails because the placeholder is replaced by multiple lines. + // And currently, the parsing of lines in Query assumes that placeholders + // do not increase the number of lines in the query. + + // Assert + expect(file.frontmatter.task_instructions).toEqual(`group by root +group by folder +group by filename +`); + + expect(query.error).not.toBeUndefined(); + expect(query.error).toMatchInlineSnapshot(` + "do not understand query + Problem statement: + {{query.file.frontmatter.task_instructions}} => + group by root + group by folder + group by filename + + " + `); + }); }); // TODO resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md From 582c36b61a113b706780352d3d720d1f10bcacdd Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 22:52:50 +0000 Subject: [PATCH 08/44] test: - Test use of a list property in a custom filter --- tests/Query/Query.test.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index c03fdde369..cb464dbf61 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -852,9 +852,36 @@ group by filename }); }); - // TODO resources/sample_vaults/Tasks-Demo/Test Data/query_list_property_in_custom_filter.md + describe('via "filter by function"', () => { + it('should use a list property in a custom filter', () => { + // Act + const source = ` +filter by function \\ + if (!query.file.hasProperty('root_dirs_to_search')) { \\ + throw Error('Please set the "root_dirs_to_search" list property, with each value ending in a backslash...'); \\ + } \\ + const roots = query.file.property('root_dirs_to_search'); \\ + return roots.includes(task.file.root); +`; + const query = new Query(source, file); - // TODO resources/sample_vaults/Tasks-Demo/Test Data/query_embed_multiline_property.md + // Assert + expect(file.frontmatter.root_dirs_to_search).toEqual(['Formats/', 'Filters/']); + + expect(query.error).toBeUndefined(); + expect(query.filters.length).toEqual(1); + + function checkNumberOfMatches(expectedNumberOfMatches: number, path: string) { + const queryResult = query.applyQueryToTasks([new TaskBuilder().path(path).build()]); + expect(queryResult.totalTasksCount).toEqual(expectedNumberOfMatches); + } + + checkNumberOfMatches(1, 'Formats/Some Sample file.md'); + checkNumberOfMatches(1, 'Filters/Another file.md'); + checkNumberOfMatches(0, 'filters/Another file.md'); // it is case-sensitive + checkNumberOfMatches(0, 'Somewhere/Place/Else.md'); + }); + }); }); }); From 3875239a9c2824902ffb6b7b942f7602d16e3cf0 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 22:53:58 +0000 Subject: [PATCH 09/44] test: - Clarify a test name --- tests/Query/Query.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index cb464dbf61..4b92f2006b 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -823,7 +823,7 @@ Problem statement: expect(query.grouping[0].instruction).toEqual('group by filename'); }); - it('should access multi-line property with query.file.frontmatter via placeholder', () => { + it('should not access multi-line property with query.file.frontmatter via placeholder', () => { // Act const source = '{{query.file.frontmatter.task_instructions}}'; const query = new Query(source, file); From bf75648b5df74ba67328810fce65eeb727ede2ef Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Tue, 10 Dec 2024 23:08:26 +0000 Subject: [PATCH 10/44] feat: Initial implementation of query.file having access to properties See #3083. There are a lot of caveats and limitations right now. See the comments in the code and the tests. --- src/Renderer/QueryRenderer.ts | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/Renderer/QueryRenderer.ts b/src/Renderer/QueryRenderer.ts index 756aa4dbe3..bf2da5fbef 100644 --- a/src/Renderer/QueryRenderer.ts +++ b/src/Renderer/QueryRenderer.ts @@ -1,4 +1,11 @@ -import { type EventRef, type MarkdownPostProcessorContext, MarkdownRenderChild, MarkdownRenderer } from 'obsidian'; +import { + type CachedMetadata, + type EventRef, + type MarkdownPostProcessorContext, + MarkdownRenderChild, + MarkdownRenderer, + TFile, +} from 'obsidian'; import { App, Keymap } from 'obsidian'; import { GlobalQuery } from '../Config/GlobalQuery'; import { getQueryForQueryRenderer } from '../Query/QueryRendererHelper'; @@ -29,13 +36,29 @@ export class QueryRenderer { public addQueryRenderChild = this._addQueryRenderChild.bind(this); private async _addQueryRenderChild(source: string, element: HTMLElement, context: MarkdownPostProcessorContext) { + // Issues with this first implementation of accessing properties in query files: + // - If the file was created in the last second or two, any CachedMetadata is probably + // not yet available, so empty. + // - It does not list out for edits the properties, so if a property is edited, + // the user needs to close and re-open the file. + // - See lso a bunch of limitations listed in comments added to Query.test.ts tests of + // query.file to access properties in query instructions. + const app = this.app; + const filePath = context.sourcePath; + const tFile = app.vault.getAbstractFileByPath(filePath); + let fileCache: CachedMetadata | null = null; + if (tFile && tFile instanceof TFile) { + fileCache = app.metadataCache.getFileCache(tFile); + } + const tasksFile = new TasksFile(filePath, fileCache ?? {}); + const queryRenderChild = new QueryRenderChild({ - app: this.app, + app: app, plugin: this.plugin, events: this.events, container: element, source, - tasksFile: new TasksFile(context.sourcePath), + tasksFile, }); context.addChild(queryRenderChild); queryRenderChild.load(); From 99cafa96f7240b9600eda6c020e259f73c40de36 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 21:38:17 +0000 Subject: [PATCH 11/44] feat: - Allow functions to query.file.property('task_instruction') to be used in placeholders --- src/Scripting/ExpandPlaceholders.ts | 19 ++++++++++++++++++- tests/Query/Query.test.ts | 23 ++++------------------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index e93806e615..4d33fa6196 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -24,8 +24,25 @@ export function expandPlaceholders(template: string, view: any): string { return text; }; + // Regex to detect function calls in placeholders + const functionRegex = /{{\s*([\w.]+)\(([^)]*)\)\s*}}/g; + + const evaluatedTemplate = template.replace(functionRegex, (_match, functionPath, args) => { + const pathParts = functionPath.split('.'); + const functionName = pathParts.pop(); + const obj = pathParts.reduce((acc: any, part: any) => acc && acc[part], view); + + if (obj && typeof obj[functionName] === 'function') { + const argValues = args.split(',').map((arg: any) => arg.trim().replace(/^['"]|['"]$/g, '')); + return obj[functionName](...argValues); + } + + throw new Error(`Unknown property or invalid function: ${functionPath}`); + }); + + // Render the preprocessed template try { - return Mustache.render(template, proxyData(view)); + return Mustache.render(evaluatedTemplate, proxyData(view)); } catch (error) { let message = ''; if (error instanceof Error) { diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 4b92f2006b..92613bbd8a 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -775,32 +775,17 @@ Problem statement: const file = getTasksFileFromMockData(query_using_properties); describe('via placeholders - used with query.file.property() - documented', () => { - it('cannot currently use query.file.property() via placeholder', () => { + it('should query.file.property() via placeholder', () => { // Act const source = "{{query.file.property('task_instruction')}}"; const query = new Query(source, file); - // Unfortunately, this placeholder FAILS because the mustache.js templating library - // does not support function calls. - // Which is a shame, because TasksFile.property() and TasksFile.hasProperty() have some nice logic - // for various special cases. - // TODO Consider switching to a different templating library that supports function calls. - // Or see if the Tasks placeholder code can detect function calls, and replace them somehow - // with the result of the function call. - // Assert expect(file.property('task_instruction')).toEqual('group by filename'); - expect(query.error).not.toBeUndefined(); - expect(query.error).toMatchInlineSnapshot(` - "There was an error expanding one or more placeholders. - - The error message was: - Unknown property: query.file.property('task_instruction') - - The problem is in: - {{query.file.property('task_instruction')}}" - `); + expect(query.error).toBeUndefined(); + expect(query.grouping.length).toEqual(1); + expect(query.grouping[0].instruction).toEqual('group by filename'); }); }); From 050e27aca2676fd6733d80cfbfc6645be1b384e2 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 21:39:14 +0000 Subject: [PATCH 12/44] refactor: . Use optional chaining --- src/Scripting/ExpandPlaceholders.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 4d33fa6196..6f6f1818cc 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -30,7 +30,7 @@ export function expandPlaceholders(template: string, view: any): string { const evaluatedTemplate = template.replace(functionRegex, (_match, functionPath, args) => { const pathParts = functionPath.split('.'); const functionName = pathParts.pop(); - const obj = pathParts.reduce((acc: any, part: any) => acc && acc[part], view); + const obj = pathParts.reduce((acc: any, part: any) => acc?.[part], view); if (obj && typeof obj[functionName] === 'function') { const argValues = args.split(',').map((arg: any) => arg.trim().replace(/^['"]|['"]$/g, '')); From 0bee397918970ad479c72e99d6f4e6779633bba4 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 21:48:08 +0000 Subject: [PATCH 13/44] comment: Add explanatory comments --- src/Scripting/ExpandPlaceholders.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 6f6f1818cc..8c5e694a05 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -27,16 +27,24 @@ export function expandPlaceholders(template: string, view: any): string { // Regex to detect function calls in placeholders const functionRegex = /{{\s*([\w.]+)\(([^)]*)\)\s*}}/g; + // Preprocess the template to evaluate any placeholders that involve function calls const evaluatedTemplate = template.replace(functionRegex, (_match, functionPath, args) => { + // Split the function path (e.g., "query.file.property") into parts const pathParts = functionPath.split('.'); + // Extract the function name (last part of the path) const functionName = pathParts.pop(); + // Traverse the view object to find the object containing the function const obj = pathParts.reduce((acc: any, part: any) => acc?.[part], view); + // Check if the function exists on the resolved object if (obj && typeof obj[functionName] === 'function') { + // Parse the arguments from the placeholder, stripping quotes and trimming whitespace const argValues = args.split(',').map((arg: any) => arg.trim().replace(/^['"]|['"]$/g, '')); + // Call the function with the parsed arguments and return the result return obj[functionName](...argValues); } + // Throw an error if the function does not exist or is invalid throw new Error(`Unknown property or invalid function: ${functionPath}`); }); From f1f4f996b61c5e1b90ad947591577a0be97c8eaa Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 21:57:58 +0000 Subject: [PATCH 14/44] refactor: Fix SonarLint warning about regex: "Group parts of the regex together to make the intended operator precedence explicit." --- src/Scripting/ExpandPlaceholders.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 8c5e694a05..4218e3d0c3 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -39,7 +39,10 @@ export function expandPlaceholders(template: string, view: any): string { // Check if the function exists on the resolved object if (obj && typeof obj[functionName] === 'function') { // Parse the arguments from the placeholder, stripping quotes and trimming whitespace - const argValues = args.split(',').map((arg: any) => arg.trim().replace(/^['"]|['"]$/g, '')); + // ^['"]: Removes quotes from the start of the string. + // ['"]$: Removes quotes from the end of the string. + const argValues = args.split(',').map((arg: any) => arg.trim().replace(/^['"]/, '').replace(/['"]$/, '')); + // Call the function with the parsed arguments and return the result return obj[functionName](...argValues); } From 265a1c814dd1ea69c0469484749f2fe04ed7c696 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 22:08:45 +0000 Subject: [PATCH 15/44] test: Start adding tests of function calls inside placeholders --- tests/Scripting/ExpandPlaceholders.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index 5be723ecd5..6a1316dc71 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -66,3 +66,17 @@ The problem is in: ); }); }); + +describe('ExpandTemplate with functions', () => { + it('Simple property access', () => { + const output = expandPlaceholders('Hello, {{name}}!', { name: 'World' }); + expect(output).toEqual('Hello, World!'); + }); + + it('Valid function call', () => { + const output = expandPlaceholders("Result: {{math.square('4')}}", { + math: { square: (x: string) => parseInt(x) ** 2 }, + }); + expect(output).toEqual('Result: 16'); + }); +}); From 9ef0dbd9b2ac61b8fe32c7bf224d41d69d1fd70c Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 22:10:41 +0000 Subject: [PATCH 16/44] test: Test nested object function access --- tests/Scripting/ExpandPlaceholders.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index 6a1316dc71..1790c10e0e 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -79,4 +79,15 @@ describe('ExpandTemplate with functions', () => { }); expect(output).toEqual('Result: 16'); }); + + it('Nested object function access', () => { + const output = expandPlaceholders("Value: {{data.subData.func('arg')}}", { + data: { + subData: { + func: (x: string) => `Result for ${x}`, + }, + }, + }); + expect(output).toEqual('Value: Result for arg'); + }); }); From e6bc77ef1f5290e981481228104c423a8a15d6d9 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 22:14:04 +0000 Subject: [PATCH 17/44] test: Check mixed quotes in arguments - currently fails --- tests/Scripting/ExpandPlaceholders.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index 1790c10e0e..ee4d7b526e 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -90,4 +90,12 @@ describe('ExpandTemplate with functions', () => { }); expect(output).toEqual('Value: Result for arg'); }); + + it.failing('Mixed quotes in arguments', () => { + const output = expandPlaceholders("Command: {{cmd.run('Hello, \\'world\\'')}}", { + cmd: { run: (x: string) => `Running ${x}` }, + }); + // Received: "Command: Running Hello" + expect(output).toEqual("Command: Running Hello, 'world'"); + }); }); From 4d8752cbc98b93d0f3f6c1373f0cc6d6b7c76ef1 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 12 Dec 2024 22:17:37 +0000 Subject: [PATCH 18/44] test: Test whitespace in arguments --- tests/Scripting/ExpandPlaceholders.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index ee4d7b526e..9804b3442e 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -98,4 +98,11 @@ describe('ExpandTemplate with functions', () => { // Received: "Command: Running Hello" expect(output).toEqual("Command: Running Hello, 'world'"); }); + + it('Whitespace in arguments', () => { + const output = expandPlaceholders("Path: {{file.get(' /my path/ ')}}", { + file: { get: (x: string) => x.trim() }, + }); + expect(output).toEqual('Path: /my path/'); + }); }); From c851a1e9e162b94f75b46107cc204e57f9ef0832 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 08:21:39 +0000 Subject: [PATCH 19/44] test: Add remaining tests --- tests/Scripting/ExpandPlaceholders.test.ts | 87 ++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index 9804b3442e..b05757706f 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -105,4 +105,91 @@ describe('ExpandTemplate with functions', () => { }); expect(output).toEqual('Path: /my path/'); }); + + // Section 4: Error Handling + + it('Non-existent function', () => { + expect(() => { + expandPlaceholders('Call: {{invalid.func()}}', { invalid: {} }); + }).toThrow('Unknown property or invalid function: invalid.func'); + }); + + it('Missing arguments', () => { + const output = expandPlaceholders('Result: {{calc.add()}}', { + calc: { add: () => 'No args' }, + }); + expect(output).toEqual('Result: No args'); + }); + + it('Function that throws an error', () => { + expect(() => { + expandPlaceholders('Test: {{bug.trigger()}}', { + bug: { + trigger: () => { + throw new Error('Something broke'); + }, + }, + }); + }).toThrow('Something broke'); + }); + + // Section 5: Edge Cases + + it('Empty template', () => { + const output = expandPlaceholders('', { key: 'value' }); + expect(output).toEqual(''); + }); + + it('Function with no arguments', () => { + const output = expandPlaceholders('Version: {{sys.getVersion()}}', { + sys: { getVersion: () => '1.0.0' }, + }); + expect(output).toEqual('Version: 1.0.0'); + }); + + it('Template with no placeholders', () => { + const output = expandPlaceholders('Static text.', { anything: 'irrelevant' }); + expect(output).toEqual('Static text.'); + }); + + it('Reserved characters', () => { + const output = expandPlaceholders("Escape: {{text.replace('&')}}", { + text: { replace: (x: string) => x.replace('&', '&') }, + }); + expect(output).toEqual('Escape: &'); + }); + + // Section 6: Multiple Placeholders + + it('Mixed property and function calls', () => { + const output = expandPlaceholders("{{user.name}}: {{math.square('5')}}", { + user: { name: 'Alice' }, + math: { square: (x: string) => parseInt(x) ** 2 }, + }); + expect(output).toEqual('Alice: 25'); + }); + + // Section 7: Unsupported Syntax + + it.failing('Unsupported Mustache syntax', () => { + expect(() => { + expandPlaceholders("Invalid: {{unsupported.func['key']}}", { + unsupported: { func: { key: 'value' } }, + }); + }).toThrow('Unknown property or invalid function: unsupported.func'); + }); + + // Section 8: Security and Performance + + it.failing('Prototype pollution prevention', () => { + expect(() => { + expandPlaceholders('{{__proto__.polluted}}', {}); + }).toThrow('Unknown property or invalid function'); + }); + + it('Large templates', () => { + const largeTemplate = Array(1001).fill('{{value}}').join(' and '); + const output = expandPlaceholders(largeTemplate, { value: 'test' }); + expect(output).toEqual('test and '.repeat(1000).trim() + ' test'); + }); }); From e17677d4d53186b9248e32d3fc40c539de6c7662 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 08:24:55 +0000 Subject: [PATCH 20/44] test: Add comments --- tests/Scripting/ExpandPlaceholders.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index b05757706f..e55a6b0785 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -68,6 +68,8 @@ The problem is in: }); describe('ExpandTemplate with functions', () => { + // 1. Basic Functionality + it('Simple property access', () => { const output = expandPlaceholders('Hello, {{name}}!', { name: 'World' }); expect(output).toEqual('Hello, World!'); @@ -80,6 +82,8 @@ describe('ExpandTemplate with functions', () => { expect(output).toEqual('Result: 16'); }); + // 2. Complex Nested Paths + it('Nested object function access', () => { const output = expandPlaceholders("Value: {{data.subData.func('arg')}}", { data: { @@ -91,6 +95,8 @@ describe('ExpandTemplate with functions', () => { expect(output).toEqual('Value: Result for arg'); }); + // 3. Special Characters in Arguments + it.failing('Mixed quotes in arguments', () => { const output = expandPlaceholders("Command: {{cmd.run('Hello, \\'world\\'')}}", { cmd: { run: (x: string) => `Running ${x}` }, From 10b07c67725b6e2000f4c46f0aab3f15f8758519 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 08:36:42 +0000 Subject: [PATCH 21/44] test: Fix a failing test by correcting the error message --- tests/Scripting/ExpandPlaceholders.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index e55a6b0785..66121c61c2 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -177,12 +177,18 @@ describe('ExpandTemplate with functions', () => { // Section 7: Unsupported Syntax - it.failing('Unsupported Mustache syntax', () => { + it('Unsupported Mustache syntax', () => { expect(() => { expandPlaceholders("Invalid: {{unsupported.func['key']}}", { unsupported: { func: { key: 'value' } }, }); - }).toThrow('Unknown property or invalid function: unsupported.func'); + }).toThrow(`There was an error expanding one or more placeholders. + +The error message was: + Unknown property: unsupported.func['key'] + +The problem is in: + Invalid: {{unsupported.func['key']}}`); }); // Section 8: Security and Performance From 999f0128f9f78911564c51af45542abe09d81eb8 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 08:38:22 +0000 Subject: [PATCH 22/44] test: Fix another failing test by correcting the error message --- tests/Scripting/ExpandPlaceholders.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index 66121c61c2..f8f12fa2f6 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -193,10 +193,16 @@ The problem is in: // Section 8: Security and Performance - it.failing('Prototype pollution prevention', () => { + it('Prototype pollution prevention', () => { expect(() => { expandPlaceholders('{{__proto__.polluted}}', {}); - }).toThrow('Unknown property or invalid function'); + }).toThrow(`There was an error expanding one or more placeholders. + +The error message was: + Unknown property: __proto__.polluted + +The problem is in: + {{__proto__.polluted}}`); }); it('Large templates', () => { From d2bf21eddf6dbc6afb3613b9a69c2eebbfb54be3 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 08:58:35 +0000 Subject: [PATCH 23/44] refactor: . Extract parseArgs() --- src/Scripting/ExpandPlaceholders.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 4218e3d0c3..c2fe2811d1 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -41,7 +41,7 @@ export function expandPlaceholders(template: string, view: any): string { // Parse the arguments from the placeholder, stripping quotes and trimming whitespace // ^['"]: Removes quotes from the start of the string. // ['"]$: Removes quotes from the end of the string. - const argValues = args.split(',').map((arg: any) => arg.trim().replace(/^['"]/, '').replace(/['"]$/, '')); + const argValues = parseArgs(args); // Call the function with the parsed arguments and return the result return obj[functionName](...argValues); @@ -71,3 +71,7 @@ The problem is in: throw Error(message); } } + +function parseArgs(args: any) { + return args.split(',').map((arg: any) => arg.trim().replace(/^['"]/, '').replace(/['"]$/, '')); +} From ad838539cd0d29bc22127998abdfbd03bddaf809 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:05:20 +0000 Subject: [PATCH 24/44] fix: Handle multiple commas inside placeholders --- src/Scripting/ExpandPlaceholders.ts | 21 +++++++++++++++++++-- tests/Scripting/ExpandPlaceholders.test.ts | 3 +-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index c2fe2811d1..d75fd29c88 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -72,6 +72,23 @@ The problem is in: } } -function parseArgs(args: any) { - return args.split(',').map((arg: any) => arg.trim().replace(/^['"]/, '').replace(/['"]$/, '')); +function parseArgs(args: string): string[] { + const argRegex = /'((?:\\'|[^'])*)'|"((?:\\"|[^"])*)"|([^,]+)/g; + const parsedArgs: string[] = []; + let match; + + while ((match = argRegex.exec(args)) !== null) { + if (match[1] !== undefined) { + // Single-quoted argument + parsedArgs.push(match[1].replace(/\\'/g, "'")); + } else if (match[2] !== undefined) { + // Double-quoted argument + parsedArgs.push(match[2].replace(/\\"/g, '"')); + } else if (match[3] !== undefined) { + // Unquoted argument + parsedArgs.push(match[3].trim()); + } + } + + return parsedArgs; } diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index f8f12fa2f6..c2cd1b2a9c 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -97,11 +97,10 @@ describe('ExpandTemplate with functions', () => { // 3. Special Characters in Arguments - it.failing('Mixed quotes in arguments', () => { + it('Mixed quotes in arguments', () => { const output = expandPlaceholders("Command: {{cmd.run('Hello, \\'world\\'')}}", { cmd: { run: (x: string) => `Running ${x}` }, }); - // Received: "Command: Running Hello" expect(output).toEqual("Command: Running Hello, 'world'"); }); From e80726f4fbbbc71fed0aa02b4b66a538e30cef10 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:09:37 +0000 Subject: [PATCH 25/44] refactor: - Break up a regex to add comments --- src/Scripting/ExpandPlaceholders.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index d75fd29c88..a7059d80b1 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -73,7 +73,20 @@ The problem is in: } function parseArgs(args: string): string[] { - const argRegex = /'((?:\\'|[^'])*)'|"((?:\\"|[^"])*)"|([^,]+)/g; + const argRegex = new RegExp( + [ + // Match single-quoted arguments + "'((?:\\\\'|[^'])*)'", + + // Match double-quoted arguments + '"((?:\\\\"|[^"])*)"', + + // Match unquoted arguments (non-commas) + '([^,]+)', + ].join('|'), // Combine all parts with OR (|) + 'g', // Global flag for multiple matches + ); + const parsedArgs: string[] = []; let match; From 6e0bb76d810e2eb346aae491145914c91f28c73d Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:11:08 +0000 Subject: [PATCH 26/44] refactor: - Only construct regex once instead of on every call... --- src/Scripting/ExpandPlaceholders.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index a7059d80b1..8c2fd22516 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -72,25 +72,25 @@ The problem is in: } } -function parseArgs(args: string): string[] { - const argRegex = new RegExp( - [ - // Match single-quoted arguments - "'((?:\\\\'|[^'])*)'", +const ARGUMENTS_REGEX = new RegExp( + [ + // Match single-quoted arguments + "'((?:\\\\'|[^'])*)'", - // Match double-quoted arguments - '"((?:\\\\"|[^"])*)"', + // Match double-quoted arguments + '"((?:\\\\"|[^"])*)"', - // Match unquoted arguments (non-commas) - '([^,]+)', - ].join('|'), // Combine all parts with OR (|) - 'g', // Global flag for multiple matches - ); + // Match unquoted arguments (non-commas) + '([^,]+)', + ].join('|'), // Combine all parts with OR (|) + 'g', // Global flag for multiple matches +); +function parseArgs(args: string): string[] { const parsedArgs: string[] = []; let match; - while ((match = argRegex.exec(args)) !== null) { + while ((match = ARGUMENTS_REGEX.exec(args)) !== null) { if (match[1] !== undefined) { // Single-quoted argument parsedArgs.push(match[1].replace(/\\'/g, "'")); From b55a936d7ba02fa3d174d6f94dea06500f464df1 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:16:42 +0000 Subject: [PATCH 27/44] refactor: - Break up a regex to add comments --- src/Scripting/ExpandPlaceholders.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 8c2fd22516..707933b572 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -25,7 +25,22 @@ export function expandPlaceholders(template: string, view: any): string { }; // Regex to detect function calls in placeholders - const functionRegex = /{{\s*([\w.]+)\(([^)]*)\)\s*}}/g; + const functionRegex = new RegExp( + [ + // Match opening double curly braces with optional whitespace + '{{\\s*', + + // Match and capture the function path (e.g., "object.path.toFunction") + '([\\w.]+)', + + // Match the opening parenthesis and capture arguments inside + '\\(([^)]*)\\)', + + // Match optional whitespace followed by closing double curly braces + '\\s*}}', + ].join(''), // Combine all parts without additional separators + 'g', // Global flag to match all instances in the template + ); // Preprocess the template to evaluate any placeholders that involve function calls const evaluatedTemplate = template.replace(functionRegex, (_match, functionPath, args) => { From a204f8dd23833111223fcc54f356b4220db04692 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:18:42 +0000 Subject: [PATCH 28/44] refactor: - Only construct regex once instead of on every call... --- src/Scripting/ExpandPlaceholders.ts | 38 ++++++++++++++--------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 707933b572..0de422cf77 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -3,6 +3,24 @@ import proxyData from 'mustache-validator'; // https://github.com/janl/mustache.js +// Regex to detect function calls in placeholders +const FUNCTION_REGEX = new RegExp( + [ + // Match opening double curly braces with optional whitespace + '{{\\s*', + + // Match and capture the function path (e.g., "object.path.toFunction") + '([\\w.]+)', + + // Match the opening parenthesis and capture arguments inside + '\\(([^)]*)\\)', + + // Match optional whitespace followed by closing double curly braces + '\\s*}}', + ].join(''), // Combine all parts without additional separators + 'g', // Global flag to match all instances in the template +); + /** * Expand any placeholder strings - {{....}} - in the given template, and return the result. * @@ -24,26 +42,8 @@ export function expandPlaceholders(template: string, view: any): string { return text; }; - // Regex to detect function calls in placeholders - const functionRegex = new RegExp( - [ - // Match opening double curly braces with optional whitespace - '{{\\s*', - - // Match and capture the function path (e.g., "object.path.toFunction") - '([\\w.]+)', - - // Match the opening parenthesis and capture arguments inside - '\\(([^)]*)\\)', - - // Match optional whitespace followed by closing double curly braces - '\\s*}}', - ].join(''), // Combine all parts without additional separators - 'g', // Global flag to match all instances in the template - ); - // Preprocess the template to evaluate any placeholders that involve function calls - const evaluatedTemplate = template.replace(functionRegex, (_match, functionPath, args) => { + const evaluatedTemplate = template.replace(FUNCTION_REGEX, (_match, functionPath, args) => { // Split the function path (e.g., "query.file.property") into parts const pathParts = functionPath.split('.'); // Extract the function name (last part of the path) From fc9c8e8be2415ecbe910153fbda323faf3987a99 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:23:27 +0000 Subject: [PATCH 29/44] test: - Remove a test that is no longer needed Now query.file.property() can be used in placeholders, all the uses of query.file.frontmatter need to be removed. --- tests/Query/Query.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 92613bbd8a..819dc83e3a 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -790,24 +790,6 @@ Problem statement: }); describe('via placeholders - used with query.file.frontmatter() - UNDOCUMENTED', () => { - it('should access query.file.frontmatter via placeholder', () => { - // Act - const source = '{{query.file.frontmatter.task_instruction}}'; - const query = new Query(source, file); - - // This use of query.file.frontmatter does work in placeholders, as it is raw data. - // So far, I have not documented use of query.file.frontmatter (and task.file.frontmatter) - // because there are a lot of special cases to understand in the handling of raw frontmatter data: - // it is too error-prone for the average user... - - // Assert - expect(file.frontmatter.task_instruction).toEqual('group by filename'); - - expect(query.error).toBeUndefined(); - expect(query.grouping.length).toEqual(1); - expect(query.grouping[0].instruction).toEqual('group by filename'); - }); - it('should not access multi-line property with query.file.frontmatter via placeholder', () => { // Act const source = '{{query.file.frontmatter.task_instructions}}'; From ee52470dbb0c6b4c50dfd2ab1726970f0c8bf278 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:28:27 +0000 Subject: [PATCH 30/44] test: - Update a test to use documented facility, instead of undocumented... Now that placeholders can contain function calls --- tests/Query/Query.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 819dc83e3a..1d4fd03458 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -787,12 +787,10 @@ Problem statement: expect(query.grouping.length).toEqual(1); expect(query.grouping[0].instruction).toEqual('group by filename'); }); - }); - describe('via placeholders - used with query.file.frontmatter() - UNDOCUMENTED', () => { - it('should not access multi-line property with query.file.frontmatter via placeholder', () => { + it('cannot yet access multi-line property with query.file.property via placeholder', () => { // Act - const source = '{{query.file.frontmatter.task_instructions}}'; + const source = '{{query.file.property("task_instructions")}}'; const query = new Query(source, file); // This fails because the placeholder is replaced by multiple lines. @@ -809,7 +807,7 @@ group by filename expect(query.error).toMatchInlineSnapshot(` "do not understand query Problem statement: - {{query.file.frontmatter.task_instructions}} => + {{query.file.property("task_instructions")}} => group by root group by folder group by filename From dbc2eaace6dfd5dddd93ba584d8a595fc3b5228e Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 09:42:09 +0000 Subject: [PATCH 31/44] test: - Group tests together to convey purpose --- tests/Scripting/ExpandPlaceholders.test.ts | 192 ++++++++++----------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/tests/Scripting/ExpandPlaceholders.test.ts b/tests/Scripting/ExpandPlaceholders.test.ts index c2cd1b2a9c..ca6fadae65 100644 --- a/tests/Scripting/ExpandPlaceholders.test.ts +++ b/tests/Scripting/ExpandPlaceholders.test.ts @@ -68,145 +68,145 @@ The problem is in: }); describe('ExpandTemplate with functions', () => { - // 1. Basic Functionality - - it('Simple property access', () => { - const output = expandPlaceholders('Hello, {{name}}!', { name: 'World' }); - expect(output).toEqual('Hello, World!'); - }); + describe('Basic Functionality', () => { + it('Simple property access', () => { + const output = expandPlaceholders('Hello, {{name}}!', { name: 'World' }); + expect(output).toEqual('Hello, World!'); + }); - it('Valid function call', () => { - const output = expandPlaceholders("Result: {{math.square('4')}}", { - math: { square: (x: string) => parseInt(x) ** 2 }, + it('Valid function call', () => { + const output = expandPlaceholders("Result: {{math.square('4')}}", { + math: { square: (x: string) => parseInt(x) ** 2 }, + }); + expect(output).toEqual('Result: 16'); }); - expect(output).toEqual('Result: 16'); }); - // 2. Complex Nested Paths - - it('Nested object function access', () => { - const output = expandPlaceholders("Value: {{data.subData.func('arg')}}", { - data: { - subData: { - func: (x: string) => `Result for ${x}`, + describe('Complex Nested Paths', () => { + it('Nested object function access', () => { + const output = expandPlaceholders("Value: {{data.subData.func('arg')}}", { + data: { + subData: { + func: (x: string) => `Result for ${x}`, + }, }, - }, + }); + expect(output).toEqual('Value: Result for arg'); }); - expect(output).toEqual('Value: Result for arg'); }); - // 3. Special Characters in Arguments - - it('Mixed quotes in arguments', () => { - const output = expandPlaceholders("Command: {{cmd.run('Hello, \\'world\\'')}}", { - cmd: { run: (x: string) => `Running ${x}` }, + describe('Special Characters in Arguments', () => { + it('Mixed quotes in arguments', () => { + const output = expandPlaceholders("Command: {{cmd.run('Hello, \\'world\\'')}}", { + cmd: { run: (x: string) => `Running ${x}` }, + }); + expect(output).toEqual("Command: Running Hello, 'world'"); }); - expect(output).toEqual("Command: Running Hello, 'world'"); - }); - it('Whitespace in arguments', () => { - const output = expandPlaceholders("Path: {{file.get(' /my path/ ')}}", { - file: { get: (x: string) => x.trim() }, + it('Whitespace in arguments', () => { + const output = expandPlaceholders("Path: {{file.get(' /my path/ ')}}", { + file: { get: (x: string) => x.trim() }, + }); + expect(output).toEqual('Path: /my path/'); }); - expect(output).toEqual('Path: /my path/'); }); - // Section 4: Error Handling - - it('Non-existent function', () => { - expect(() => { - expandPlaceholders('Call: {{invalid.func()}}', { invalid: {} }); - }).toThrow('Unknown property or invalid function: invalid.func'); - }); + describe('Error Handling', () => { + it('Non-existent function', () => { + expect(() => { + expandPlaceholders('Call: {{invalid.func()}}', { invalid: {} }); + }).toThrow('Unknown property or invalid function: invalid.func'); + }); - it('Missing arguments', () => { - const output = expandPlaceholders('Result: {{calc.add()}}', { - calc: { add: () => 'No args' }, + it('Missing arguments', () => { + const output = expandPlaceholders('Result: {{calc.add()}}', { + calc: { add: () => 'No args' }, + }); + expect(output).toEqual('Result: No args'); }); - expect(output).toEqual('Result: No args'); - }); - it('Function that throws an error', () => { - expect(() => { - expandPlaceholders('Test: {{bug.trigger()}}', { - bug: { - trigger: () => { - throw new Error('Something broke'); + it('Function that throws an error', () => { + expect(() => { + expandPlaceholders('Test: {{bug.trigger()}}', { + bug: { + trigger: () => { + throw new Error('Something broke'); + }, }, - }, - }); - }).toThrow('Something broke'); + }); + }).toThrow('Something broke'); + }); }); - // Section 5: Edge Cases - - it('Empty template', () => { - const output = expandPlaceholders('', { key: 'value' }); - expect(output).toEqual(''); - }); + describe('Edge Cases', () => { + it('Empty template', () => { + const output = expandPlaceholders('', { key: 'value' }); + expect(output).toEqual(''); + }); - it('Function with no arguments', () => { - const output = expandPlaceholders('Version: {{sys.getVersion()}}', { - sys: { getVersion: () => '1.0.0' }, + it('Function with no arguments', () => { + const output = expandPlaceholders('Version: {{sys.getVersion()}}', { + sys: { getVersion: () => '1.0.0' }, + }); + expect(output).toEqual('Version: 1.0.0'); }); - expect(output).toEqual('Version: 1.0.0'); - }); - it('Template with no placeholders', () => { - const output = expandPlaceholders('Static text.', { anything: 'irrelevant' }); - expect(output).toEqual('Static text.'); - }); + it('Template with no placeholders', () => { + const output = expandPlaceholders('Static text.', { anything: 'irrelevant' }); + expect(output).toEqual('Static text.'); + }); - it('Reserved characters', () => { - const output = expandPlaceholders("Escape: {{text.replace('&')}}", { - text: { replace: (x: string) => x.replace('&', '&') }, + it('Reserved characters', () => { + const output = expandPlaceholders("Escape: {{text.replace('&')}}", { + text: { replace: (x: string) => x.replace('&', '&') }, + }); + expect(output).toEqual('Escape: &'); }); - expect(output).toEqual('Escape: &'); }); - // Section 6: Multiple Placeholders - - it('Mixed property and function calls', () => { - const output = expandPlaceholders("{{user.name}}: {{math.square('5')}}", { - user: { name: 'Alice' }, - math: { square: (x: string) => parseInt(x) ** 2 }, + describe('Multiple Placeholders', () => { + it('Mixed property and function calls', () => { + const output = expandPlaceholders("{{user.name}}: {{math.square('5')}}", { + user: { name: 'Alice' }, + math: { square: (x: string) => parseInt(x) ** 2 }, + }); + expect(output).toEqual('Alice: 25'); }); - expect(output).toEqual('Alice: 25'); }); - // Section 7: Unsupported Syntax - - it('Unsupported Mustache syntax', () => { - expect(() => { - expandPlaceholders("Invalid: {{unsupported.func['key']}}", { - unsupported: { func: { key: 'value' } }, - }); - }).toThrow(`There was an error expanding one or more placeholders. + describe('Unsupported Syntax', () => { + it('Unsupported Mustache syntax', () => { + expect(() => { + expandPlaceholders("Invalid: {{unsupported.func['key']}}", { + unsupported: { func: { key: 'value' } }, + }); + }).toThrow(`There was an error expanding one or more placeholders. The error message was: Unknown property: unsupported.func['key'] The problem is in: Invalid: {{unsupported.func['key']}}`); + }); }); - // Section 8: Security and Performance - - it('Prototype pollution prevention', () => { - expect(() => { - expandPlaceholders('{{__proto__.polluted}}', {}); - }).toThrow(`There was an error expanding one or more placeholders. + describe('Security and Performance', () => { + it('Prototype pollution prevention', () => { + expect(() => { + expandPlaceholders('{{__proto__.polluted}}', {}); + }).toThrow(`There was an error expanding one or more placeholders. The error message was: Unknown property: __proto__.polluted The problem is in: {{__proto__.polluted}}`); - }); + }); - it('Large templates', () => { - const largeTemplate = Array(1001).fill('{{value}}').join(' and '); - const output = expandPlaceholders(largeTemplate, { value: 'test' }); - expect(output).toEqual('test and '.repeat(1000).trim() + ' test'); + it('Large templates', () => { + const largeTemplate = Array(1001).fill('{{value}}').join(' and '); + const output = expandPlaceholders(largeTemplate, { value: 'test' }); + expect(output).toEqual('test and '.repeat(1000).trim() + ' test'); + }); }); }); From 04dd6a96f002dcba39173c35c9da6fd1cf627920 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 10:09:51 +0000 Subject: [PATCH 32/44] comment: Update comments - fix typos and reflect the current state --- src/Renderer/QueryRenderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Renderer/QueryRenderer.ts b/src/Renderer/QueryRenderer.ts index bf2da5fbef..5b27695da3 100644 --- a/src/Renderer/QueryRenderer.ts +++ b/src/Renderer/QueryRenderer.ts @@ -39,9 +39,9 @@ export class QueryRenderer { // Issues with this first implementation of accessing properties in query files: // - If the file was created in the last second or two, any CachedMetadata is probably // not yet available, so empty. - // - It does not list out for edits the properties, so if a property is edited, + // - It does not listen out for edits the properties, so if a property is edited, // the user needs to close and re-open the file. - // - See lso a bunch of limitations listed in comments added to Query.test.ts tests of + // - See any limitations listed in comments added to Query.test.ts tests of // query.file to access properties in query instructions. const app = this.app; const filePath = context.sourcePath; From d3e348b044672af133ddfd3fb7c6d3bb9b1c4e50 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 10:10:54 +0000 Subject: [PATCH 33/44] test: - Simplify describe block name --- tests/Query/Query.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 1d4fd03458..33825e7094 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -774,7 +774,7 @@ Problem statement: describe('properties in the query file', () => { const file = getTasksFileFromMockData(query_using_properties); - describe('via placeholders - used with query.file.property() - documented', () => { + describe('via placeholders', () => { it('should query.file.property() via placeholder', () => { // Act const source = "{{query.file.property('task_instruction')}}"; From ef9b39126a2b56cd5b496c9a3b943cab05b0b48b Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 10:19:46 +0000 Subject: [PATCH 34/44] comment: Remove some obsolete comments --- src/Scripting/ExpandPlaceholders.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 0de422cf77..69c2f5d14a 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -54,8 +54,6 @@ export function expandPlaceholders(template: string, view: any): string { // Check if the function exists on the resolved object if (obj && typeof obj[functionName] === 'function') { // Parse the arguments from the placeholder, stripping quotes and trimming whitespace - // ^['"]: Removes quotes from the start of the string. - // ['"]$: Removes quotes from the end of the string. const argValues = parseArgs(args); // Call the function with the parsed arguments and return the result From 23e2d940628f190802cdc12ee9decaf9e2f8da6c Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 10:22:33 +0000 Subject: [PATCH 35/44] refactor: . Extract evaluateAnyFunctionCalls() --- src/Scripting/ExpandPlaceholders.ts | 44 ++++++++++++++++------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 69c2f5d14a..9cfc2e80f2 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -43,26 +43,7 @@ export function expandPlaceholders(template: string, view: any): string { }; // Preprocess the template to evaluate any placeholders that involve function calls - const evaluatedTemplate = template.replace(FUNCTION_REGEX, (_match, functionPath, args) => { - // Split the function path (e.g., "query.file.property") into parts - const pathParts = functionPath.split('.'); - // Extract the function name (last part of the path) - const functionName = pathParts.pop(); - // Traverse the view object to find the object containing the function - const obj = pathParts.reduce((acc: any, part: any) => acc?.[part], view); - - // Check if the function exists on the resolved object - if (obj && typeof obj[functionName] === 'function') { - // Parse the arguments from the placeholder, stripping quotes and trimming whitespace - const argValues = parseArgs(args); - - // Call the function with the parsed arguments and return the result - return obj[functionName](...argValues); - } - - // Throw an error if the function does not exist or is invalid - throw new Error(`Unknown property or invalid function: ${functionPath}`); - }); + const evaluatedTemplate = evaluateAnyFunctionCalls(template, view); // Render the preprocessed template try { @@ -118,3 +99,26 @@ function parseArgs(args: string): string[] { return parsedArgs; } + +function evaluateAnyFunctionCalls(template: string, view: any) { + return template.replace(FUNCTION_REGEX, (_match, functionPath, args) => { + // Split the function path (e.g., "query.file.property") into parts + const pathParts = functionPath.split('.'); + // Extract the function name (last part of the path) + const functionName = pathParts.pop(); + // Traverse the view object to find the object containing the function + const obj = pathParts.reduce((acc: any, part: any) => acc?.[part], view); + + // Check if the function exists on the resolved object + if (obj && typeof obj[functionName] === 'function') { + // Parse the arguments from the placeholder, stripping quotes and trimming whitespace + const argValues = parseArgs(args); + + // Call the function with the parsed arguments and return the result + return obj[functionName](...argValues); + } + + // Throw an error if the function does not exist or is invalid + throw new Error(`Unknown property or invalid function: ${functionPath}`); + }); +} From 9ed86c38c4f6693397c6a9df3f91e30cc47aaaed Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 10:45:42 +0000 Subject: [PATCH 36/44] refactor: - Replace reduce() call with explicit loop, to make it very slightly clearer --- src/Scripting/ExpandPlaceholders.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 9cfc2e80f2..359ac5ed0e 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -107,7 +107,16 @@ function evaluateAnyFunctionCalls(template: string, view: any) { // Extract the function name (last part of the path) const functionName = pathParts.pop(); // Traverse the view object to find the object containing the function - const obj = pathParts.reduce((acc: any, part: any) => acc?.[part], view); + let obj = view; // Start at the root of the view object + for (const part of pathParts) { + if (obj == null) { + // Stop traversal if obj is null or undefined + obj = undefined; + break; + } + obj = obj[part]; // Move to the next level of the object + } + // At the end of the loop, obj contains the resolved value or undefined if any part of the path was invalid // Check if the function exists on the resolved object if (obj && typeof obj[functionName] === 'function') { From ada5271cd8db8a6c5f4b2a781bbd95fa03e4d2b7 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 10:46:21 +0000 Subject: [PATCH 37/44] refactor: . Add white space to break up code --- src/Scripting/ExpandPlaceholders.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 359ac5ed0e..e0d6878304 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -104,8 +104,10 @@ function evaluateAnyFunctionCalls(template: string, view: any) { return template.replace(FUNCTION_REGEX, (_match, functionPath, args) => { // Split the function path (e.g., "query.file.property") into parts const pathParts = functionPath.split('.'); + // Extract the function name (last part of the path) const functionName = pathParts.pop(); + // Traverse the view object to find the object containing the function let obj = view; // Start at the root of the view object for (const part of pathParts) { From 99dd008c6600b47108388bf2fe920a713c829336 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 10:53:26 +0000 Subject: [PATCH 38/44] comment: Explain some cryptic code --- src/Scripting/ExpandPlaceholders.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index e0d6878304..3c5dce40db 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -108,7 +108,17 @@ function evaluateAnyFunctionCalls(template: string, view: any) { // Extract the function name (last part of the path) const functionName = pathParts.pop(); - // Traverse the view object to find the object containing the function + // Traverse the view object to find the object containing the function. + // + // This is needed because JavaScript/TypeScript doesn’t provide a direct way + // to access view['query']['file']['property'] based on such a dynamic path. + // + // So we need the loop to "walk" through the view object step by step, + // accessing each level as specified by the pathParts. + // + // Also, if any part of the path is missing (e.g., view.query.file exists, + // but view.query.file.property does not), the loop ensures the traversal + // stops early, and obj becomes undefined instead of throwing an error. let obj = view; // Start at the root of the view object for (const part of pathParts) { if (obj == null) { From afb7bccadf62c9c55e859a40a6c362e2525c9496 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 11:03:02 +0000 Subject: [PATCH 39/44] jsdoc: Document that placeholders can now contain function calls --- src/Scripting/ExpandPlaceholders.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index 3c5dce40db..eb9870374d 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -25,8 +25,10 @@ const FUNCTION_REGEX = new RegExp( * 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). + * This is augmented by also allowing the templates to contain function calls. * - * @param template - A template string, typically with placeholders such as {{query.task.folder}} + * @param template - A template string, typically with placeholders such as {{query.task.folder}} or + * {{query.file.property('task_instruction')}} * @param view - The property values * * @throws Error From 39800d3872f3007095eae0d9d2173a342785fecd Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 11:04:43 +0000 Subject: [PATCH 40/44] refactor: . Move FUNCTION_REGEX constant down to where it is used --- src/Scripting/ExpandPlaceholders.ts | 36 ++++++++++++++--------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/Scripting/ExpandPlaceholders.ts b/src/Scripting/ExpandPlaceholders.ts index eb9870374d..e0424a0b53 100644 --- a/src/Scripting/ExpandPlaceholders.ts +++ b/src/Scripting/ExpandPlaceholders.ts @@ -3,24 +3,6 @@ import proxyData from 'mustache-validator'; // https://github.com/janl/mustache.js -// Regex to detect function calls in placeholders -const FUNCTION_REGEX = new RegExp( - [ - // Match opening double curly braces with optional whitespace - '{{\\s*', - - // Match and capture the function path (e.g., "object.path.toFunction") - '([\\w.]+)', - - // Match the opening parenthesis and capture arguments inside - '\\(([^)]*)\\)', - - // Match optional whitespace followed by closing double curly braces - '\\s*}}', - ].join(''), // Combine all parts without additional separators - 'g', // Global flag to match all instances in the template -); - /** * Expand any placeholder strings - {{....}} - in the given template, and return the result. * @@ -102,6 +84,24 @@ function parseArgs(args: string): string[] { return parsedArgs; } +// Regex to detect function calls in placeholders +const FUNCTION_REGEX = new RegExp( + [ + // Match opening double curly braces with optional whitespace + '{{\\s*', + + // Match and capture the function path (e.g., "object.path.toFunction") + '([\\w.]+)', + + // Match the opening parenthesis and capture arguments inside + '\\(([^)]*)\\)', + + // Match optional whitespace followed by closing double curly braces + '\\s*}}', + ].join(''), // Combine all parts without additional separators + 'g', // Global flag to match all instances in the template +); + function evaluateAnyFunctionCalls(template: string, view: any) { return template.replace(FUNCTION_REGEX, (_match, functionPath, args) => { // Split the function path (e.g., "query.file.property") into parts From 6fdbc771c33cc1b9753a84fafbb88c3a0b826fa5 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 11:07:09 +0000 Subject: [PATCH 41/44] test: - Fix typo in test name --- tests/Query/Query.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index 33825e7094..c6dc4ca1aa 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -775,7 +775,7 @@ Problem statement: const file = getTasksFileFromMockData(query_using_properties); describe('via placeholders', () => { - it('should query.file.property() via placeholder', () => { + it('should use query.file.property() via placeholder', () => { // Act const source = "{{query.file.property('task_instruction')}}"; const query = new Query(source, file); From 06ad6f8abdf519963ac725f3783cf40e7d55ea74 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 13 Dec 2024 11:21:32 +0000 Subject: [PATCH 42/44] vault: Update query_using_properties.md to use query.file.property() --- .../Test Data/query_using_properties.md | 6 ++--- .../__test_data__/query_using_properties.json | 22 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md b/resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md index dbff109606..b71a44c961 100644 --- a/resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md +++ b/resources/sample_vaults/Tasks-Demo/Test Data/query_using_properties.md @@ -20,19 +20,19 @@ Read a Tasks instruction from a property in this file, and embed it in to any nu ```tasks explain ignore global query -{{query.file.frontmatter.task_instruction}} +{{query.file.property('task_instruction')}} limit 10 ``` ## Use a multi-line property: task_instructions -Once query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings... +This fails as the `task_instructions` contains multiple lines , and placeholders are applied after the query is split at line-endings... ```tasks ignore global query folder includes Test Data explain -{{query.file.frontmatter.task_instructions}} +{{query.file.property('task_instructions')}} ``` ## Use a list property in a custom filter: root_dirs_to_search diff --git a/tests/Obsidian/__test_data__/query_using_properties.json b/tests/Obsidian/__test_data__/query_using_properties.json index cdbbf0c286..ce9baf8198 100644 --- a/tests/Obsidian/__test_data__/query_using_properties.json +++ b/tests/Obsidian/__test_data__/query_using_properties.json @@ -77,12 +77,12 @@ "end": { "col": 62, "line": 37, - "offset": 846 + "offset": 852 }, "start": { "col": 0, "line": 37, - "offset": 784 + "offset": 790 } } } @@ -214,9 +214,9 @@ { "position": { "end": { - "col": 130, + "col": 136, "line": 28, - "offset": 669 + "offset": 675 }, "start": { "col": 0, @@ -231,12 +231,12 @@ "end": { "col": 3, "line": 35, - "offset": 782 + "offset": 788 }, "start": { "col": 0, "line": 30, - "offset": 671 + "offset": 677 } }, "type": "code" @@ -246,12 +246,12 @@ "end": { "col": 62, "line": 37, - "offset": 846 + "offset": 852 }, "start": { "col": 0, "line": 37, - "offset": 784 + "offset": 790 } }, "type": "heading" @@ -261,12 +261,12 @@ "end": { "col": 3, "line": 52, - "offset": 1234 + "offset": 1240 }, "start": { "col": 0, "line": 39, - "offset": 848 + "offset": 854 } }, "type": "code" @@ -290,7 +290,7 @@ } ] }, - "fileContents": "---\nroot_dirs_to_search:\n - Formats/\n - Filters/\ntask_instruction: group by filename\ntask_instructions: |\n group by root\n group by folder\n group by filename\n---\n\n# query_using_properties\n\n- [ ] #task Task in 'query_using_properties'\n\n## Use a one-line property: task_instruction\n\nRead a Tasks instruction from a property in this file, and embed it in to any number of queries in the file:\n\n```tasks\nexplain\nignore global query\n{{query.file.frontmatter.task_instruction}}\nlimit 10\n```\n\n## Use a multi-line property: task_instructions\n\nOnce query.file.frontmatter is accessible, this will fail, as placeholders are applied after the query is split at line-endings...\n\n```tasks\nignore global query\nfolder includes Test Data\nexplain\n{{query.file.frontmatter.task_instructions}}\n```\n\n## Use a list property in a custom filter: root_dirs_to_search\n\n```tasks\nignore global query\nexplain\n\nfilter by function \\\n if (!query.file.hasProperty('root_dirs_to_search')) { \\\n throw Error('Please set the \"root_dirs_to_search\" list property, with each value ending in a backslash...'); \\\n } \\\n const roots = query.file.property('root_dirs_to_search'); \\\n return roots.includes(task.file.root);\n\nlimit groups 5\ngroup by root\n```\n", + "fileContents": "---\nroot_dirs_to_search:\n - Formats/\n - Filters/\ntask_instruction: group by filename\ntask_instructions: |\n group by root\n group by folder\n group by filename\n---\n\n# query_using_properties\n\n- [ ] #task Task in 'query_using_properties'\n\n## Use a one-line property: task_instruction\n\nRead a Tasks instruction from a property in this file, and embed it in to any number of queries in the file:\n\n```tasks\nexplain\nignore global query\n{{query.file.property('task_instruction')}}\nlimit 10\n```\n\n## Use a multi-line property: task_instructions\n\nThis fails as the `task_instructions` contains multiple lines , and placeholders are applied after the query is split at line-endings...\n\n```tasks\nignore global query\nfolder includes Test Data\nexplain\n{{query.file.property('task_instructions')}}\n```\n\n## Use a list property in a custom filter: root_dirs_to_search\n\n```tasks\nignore global query\nexplain\n\nfilter by function \\\n if (!query.file.hasProperty('root_dirs_to_search')) { \\\n throw Error('Please set the \"root_dirs_to_search\" list property, with each value ending in a backslash...'); \\\n } \\\n const roots = query.file.property('root_dirs_to_search'); \\\n return roots.includes(task.file.root);\n\nlimit groups 5\ngroup by root\n```\n", "filePath": "Test Data/query_using_properties.md", "getAllTags": [ "#task" From 151204b0ccfb0abb3057d1f77096970bb05940a9 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Thu, 19 Dec 2024 15:03:38 +0000 Subject: [PATCH 43/44] test: - Add tests of 'explain' output of query.file.property use This is mainly to show the error message when using multi-line properties in placeholders. --- tests/Query/Query.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/Query/Query.test.ts b/tests/Query/Query.test.ts index c6dc4ca1aa..02ed13c24d 100644 --- a/tests/Query/Query.test.ts +++ b/tests/Query/Query.test.ts @@ -786,6 +786,16 @@ Problem statement: expect(query.error).toBeUndefined(); expect(query.grouping.length).toEqual(1); expect(query.grouping[0].instruction).toEqual('group by filename'); + + expect(query.explainQuery()).toMatchInlineSnapshot(` + "No filters supplied. All tasks will match the query. + + {{query.file.property('task_instruction')}} => + group by filename + + No sorting instructions supplied. + " + `); }); it('cannot yet access multi-line property with query.file.property via placeholder', () => { @@ -812,6 +822,19 @@ group by filename group by folder group by filename + " + `); + + expect(query.explainQuery()).toMatchInlineSnapshot(` + "Query has an error: + do not understand query + Problem statement: + {{query.file.property("task_instructions")}} => + group by root + group by folder + group by filename + + " `); }); @@ -836,6 +859,22 @@ filter by function \\ expect(query.error).toBeUndefined(); expect(query.filters.length).toEqual(1); + expect(query.explainQuery()).toMatchInlineSnapshot(` + "filter by function \\ + if (!query.file.hasProperty('root_dirs_to_search')) { \\ + throw Error('Please set the "root_dirs_to_search" list property, with each value ending in a backslash...'); \\ + } \\ + const roots = query.file.property('root_dirs_to_search'); \\ + return roots.includes(task.file.root); + => + filter by function if (!query.file.hasProperty('root_dirs_to_search')) { throw Error('Please set the "root_dirs_to_search" list property, with each value ending in a backslash...'); } const roots = query.file.property('root_dirs_to_search'); return roots.includes(task.file.root); + + No grouping instructions supplied. + + No sorting instructions supplied. + " + `); + function checkNumberOfMatches(expectedNumberOfMatches: number, path: string) { const queryResult = query.applyQueryToTasks([new TaskBuilder().path(path).build()]); expect(queryResult.totalTasksCount).toEqual(expectedNumberOfMatches); From 751b9a236c7202359b6147691c283ebef25745d0 Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Fri, 20 Dec 2024 08:14:53 +0000 Subject: [PATCH 44/44] comment: Clarify the limitations when accessing query file placeholders --- src/Renderer/QueryRenderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Renderer/QueryRenderer.ts b/src/Renderer/QueryRenderer.ts index 5b27695da3..972028ef88 100644 --- a/src/Renderer/QueryRenderer.ts +++ b/src/Renderer/QueryRenderer.ts @@ -41,8 +41,8 @@ export class QueryRenderer { // not yet available, so empty. // - It does not listen out for edits the properties, so if a property is edited, // the user needs to close and re-open the file. - // - See any limitations listed in comments added to Query.test.ts tests of - // query.file to access properties in query instructions. + // - Only single-line properties work. Multiple-line properties give an error message + // 'do not understand query'. const app = this.app; const filePath = context.sourcePath; const tFile = app.vault.getAbstractFileByPath(filePath);