Skip to content

Commit c684472

Browse files
authored
Merge pull request #3242 from obsidian-tasks-group/query-accessing-properties
feat: partial support for query.file.property() and query.file.hasProperty()
2 parents b293a37 + 751b9a2 commit c684472

File tree

7 files changed

+748
-5
lines changed

7 files changed

+748
-5
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
root_dirs_to_search:
3+
- Formats/
4+
- Filters/
5+
task_instruction: group by filename
6+
task_instructions: |
7+
group by root
8+
group by folder
9+
group by filename
10+
---
11+
12+
# query_using_properties
13+
14+
- [ ] #task Task in 'query_using_properties'
15+
16+
## Use a one-line property: task_instruction
17+
18+
Read a Tasks instruction from a property in this file, and embed it in to any number of queries in the file:
19+
20+
```tasks
21+
explain
22+
ignore global query
23+
{{query.file.property('task_instruction')}}
24+
limit 10
25+
```
26+
27+
## Use a multi-line property: task_instructions
28+
29+
This fails as the `task_instructions` contains multiple lines , and placeholders are applied after the query is split at line-endings...
30+
31+
```tasks
32+
ignore global query
33+
folder includes Test Data
34+
explain
35+
{{query.file.property('task_instructions')}}
36+
```
37+
38+
## Use a list property in a custom filter: root_dirs_to_search
39+
40+
```tasks
41+
ignore global query
42+
explain
43+
44+
filter by function \
45+
if (!query.file.hasProperty('root_dirs_to_search')) { \
46+
throw Error('Please set the "root_dirs_to_search" list property, with each value ending in a backslash...'); \
47+
} \
48+
const roots = query.file.property('root_dirs_to_search'); \
49+
return roots.includes(task.file.root);
50+
51+
limit groups 5
52+
group by root
53+
```

src/Renderer/QueryRenderer.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { type EventRef, type MarkdownPostProcessorContext, MarkdownRenderChild, MarkdownRenderer } from 'obsidian';
1+
import {
2+
type CachedMetadata,
3+
type EventRef,
4+
type MarkdownPostProcessorContext,
5+
MarkdownRenderChild,
6+
MarkdownRenderer,
7+
TFile,
8+
} from 'obsidian';
29
import { App, Keymap } from 'obsidian';
310
import { GlobalQuery } from '../Config/GlobalQuery';
411
import { getQueryForQueryRenderer } from '../Query/QueryRendererHelper';
@@ -29,13 +36,29 @@ export class QueryRenderer {
2936
public addQueryRenderChild = this._addQueryRenderChild.bind(this);
3037

3138
private async _addQueryRenderChild(source: string, element: HTMLElement, context: MarkdownPostProcessorContext) {
39+
// Issues with this first implementation of accessing properties in query files:
40+
// - If the file was created in the last second or two, any CachedMetadata is probably
41+
// not yet available, so empty.
42+
// - It does not listen out for edits the properties, so if a property is edited,
43+
// the user needs to close and re-open the file.
44+
// - Only single-line properties work. Multiple-line properties give an error message
45+
// 'do not understand query'.
46+
const app = this.app;
47+
const filePath = context.sourcePath;
48+
const tFile = app.vault.getAbstractFileByPath(filePath);
49+
let fileCache: CachedMetadata | null = null;
50+
if (tFile && tFile instanceof TFile) {
51+
fileCache = app.metadataCache.getFileCache(tFile);
52+
}
53+
const tasksFile = new TasksFile(filePath, fileCache ?? {});
54+
3255
const queryRenderChild = new QueryRenderChild({
33-
app: this.app,
56+
app: app,
3457
plugin: this.plugin,
3558
events: this.events,
3659
container: element,
3760
source,
38-
tasksFile: new TasksFile(context.sourcePath),
61+
tasksFile,
3962
});
4063
context.addChild(queryRenderChild);
4164
queryRenderChild.load();

src/Scripting/ExpandPlaceholders.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import proxyData from 'mustache-validator';
77
* Expand any placeholder strings - {{....}} - in the given template, and return the result.
88
*
99
* The template implementation is currently provided by: [mustache.js](https://github.com/janl/mustache.js).
10+
* This is augmented by also allowing the templates to contain function calls.
1011
*
11-
* @param template - A template string, typically with placeholders such as {{query.task.folder}}
12+
* @param template - A template string, typically with placeholders such as {{query.task.folder}} or
13+
* {{query.file.property('task_instruction')}}
1214
* @param view - The property values
1315
*
1416
* @throws Error
@@ -24,8 +26,12 @@ export function expandPlaceholders(template: string, view: any): string {
2426
return text;
2527
};
2628

29+
// Preprocess the template to evaluate any placeholders that involve function calls
30+
const evaluatedTemplate = evaluateAnyFunctionCalls(template, view);
31+
32+
// Render the preprocessed template
2733
try {
28-
return Mustache.render(template, proxyData(view));
34+
return Mustache.render(evaluatedTemplate, proxyData(view));
2935
} catch (error) {
3036
let message = '';
3137
if (error instanceof Error) {
@@ -43,3 +49,99 @@ The problem is in:
4349
throw Error(message);
4450
}
4551
}
52+
53+
const ARGUMENTS_REGEX = new RegExp(
54+
[
55+
// Match single-quoted arguments
56+
"'((?:\\\\'|[^'])*)'",
57+
58+
// Match double-quoted arguments
59+
'"((?:\\\\"|[^"])*)"',
60+
61+
// Match unquoted arguments (non-commas)
62+
'([^,]+)',
63+
].join('|'), // Combine all parts with OR (|)
64+
'g', // Global flag for multiple matches
65+
);
66+
67+
function parseArgs(args: string): string[] {
68+
const parsedArgs: string[] = [];
69+
let match;
70+
71+
while ((match = ARGUMENTS_REGEX.exec(args)) !== null) {
72+
if (match[1] !== undefined) {
73+
// Single-quoted argument
74+
parsedArgs.push(match[1].replace(/\\'/g, "'"));
75+
} else if (match[2] !== undefined) {
76+
// Double-quoted argument
77+
parsedArgs.push(match[2].replace(/\\"/g, '"'));
78+
} else if (match[3] !== undefined) {
79+
// Unquoted argument
80+
parsedArgs.push(match[3].trim());
81+
}
82+
}
83+
84+
return parsedArgs;
85+
}
86+
87+
// Regex to detect function calls in placeholders
88+
const FUNCTION_REGEX = new RegExp(
89+
[
90+
// Match opening double curly braces with optional whitespace
91+
'{{\\s*',
92+
93+
// Match and capture the function path (e.g., "object.path.toFunction")
94+
'([\\w.]+)',
95+
96+
// Match the opening parenthesis and capture arguments inside
97+
'\\(([^)]*)\\)',
98+
99+
// Match optional whitespace followed by closing double curly braces
100+
'\\s*}}',
101+
].join(''), // Combine all parts without additional separators
102+
'g', // Global flag to match all instances in the template
103+
);
104+
105+
function evaluateAnyFunctionCalls(template: string, view: any) {
106+
return template.replace(FUNCTION_REGEX, (_match, functionPath, args) => {
107+
// Split the function path (e.g., "query.file.property") into parts
108+
const pathParts = functionPath.split('.');
109+
110+
// Extract the function name (last part of the path)
111+
const functionName = pathParts.pop();
112+
113+
// Traverse the view object to find the object containing the function.
114+
//
115+
// This is needed because JavaScript/TypeScript doesn’t provide a direct way
116+
// to access view['query']['file']['property'] based on such a dynamic path.
117+
//
118+
// So we need the loop to "walk" through the view object step by step,
119+
// accessing each level as specified by the pathParts.
120+
//
121+
// Also, if any part of the path is missing (e.g., view.query.file exists,
122+
// but view.query.file.property does not), the loop ensures the traversal
123+
// stops early, and obj becomes undefined instead of throwing an error.
124+
let obj = view; // Start at the root of the view object
125+
for (const part of pathParts) {
126+
if (obj == null) {
127+
// Stop traversal if obj is null or undefined
128+
obj = undefined;
129+
break;
130+
}
131+
obj = obj[part]; // Move to the next level of the object
132+
}
133+
// At the end of the loop, obj contains the resolved value or undefined if any part of the path was invalid
134+
135+
// Check if the function exists on the resolved object
136+
if (obj && typeof obj[functionName] === 'function') {
137+
// Parse the arguments from the placeholder, stripping quotes and trimming whitespace
138+
const argValues = parseArgs(args);
139+
140+
// Call the function with the parsed arguments and return the result
141+
return obj[functionName](...argValues);
142+
}
143+
144+
// Throw an error if the function does not exist or is invalid
145+
throw new Error(`Unknown property or invalid function: ${functionPath}`);
146+
});
147+
}

tests/Obsidian/AllCacheSampleData.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import no_heading from './__test_data__/no_heading.json';
5050
import no_yaml from './__test_data__/no_yaml.json';
5151
import non_tasks from './__test_data__/non_tasks.json';
5252
import one_task from './__test_data__/one_task.json';
53+
import query_using_properties from './__test_data__/query_using_properties.json';
5354
import yaml_1_alias from './__test_data__/yaml_1_alias.json';
5455
import yaml_2_aliases from './__test_data__/yaml_2_aliases.json';
5556
import yaml_all_property_types_empty from './__test_data__/yaml_all_property_types_empty.json';
@@ -119,6 +120,7 @@ export function allCacheSampleData() {
119120
no_yaml,
120121
non_tasks,
121122
one_task,
123+
query_using_properties,
122124
yaml_1_alias,
123125
yaml_2_aliases,
124126
yaml_all_property_types_empty,

0 commit comments

Comments
 (0)