Skip to content

Commit 5a9060a

Browse files
authored
Merge pull request #3292 from obsidian-tasks-group/feat-placeholder-expressions
feat: Placeholders can now contain expressions
2 parents cf8bdf9 + 869db6c commit 5a9060a

File tree

10 files changed

+339
-192
lines changed

10 files changed

+339
-192
lines changed

docs/Getting Started/Obsidian Properties.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,14 @@ It can be used in your query in two ways:
208208
description includes {{query.file.property('search-text')}}
209209
```
210210

211+
1. An entire instruction controlled by front-matter value:
212+
213+
```javascript
214+
{{const prop = 'TQ-explain'; return query.file.hasProperty(prop) ? ( query.file.property(prop) ? 'explain' : '') : '';}}
215+
216+
{{const prop = 'TQ-show-tree'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' tree' || ''}}
217+
```
218+
211219
1. Scripting, which allows creation of a custom filter, which works when the search term is empty
212220

213221
```javascript

docs/Quick Reference.md

Lines changed: 67 additions & 67 deletions
Large diffs are not rendered by default.

docs/What is New/Changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ _In recent [Tasks releases](https://github.com/obsidian-tasks-group/obsidian-tas
1212

1313
## 7.x releases
1414

15+
- X.Y.Z:
16+
- Add `query.file.hasProperty()` and `query.file.property()` in custom filters
17+
- Add `{{query.file.hasProperty()}}` and `{{query.file.property()}}` in placeholders - see [[Obsidian Properties#Using Query Properties in Placeholders|Using Query Properties in Placeholders]].
18+
- Placeholders can now call functions and contain expressions.
19+
- Add Chinese translation of [[Settings]], [[Editing a Status]] and [[Check your Statuses]]
1520
- 7.14.0:
1621
- Add [[Editing Dates#Date-picker on task dates|date picker]] to Reading mode and Tasks query search results.
1722
- 7.13.0:

resources/sample_vaults/Tasks-Demo/.obsidian/types.json

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,29 @@
1515
"sample_link_property": "text",
1616
"creation date": "datetime",
1717
"project": "text",
18-
"sample_text_multiline_property": "text"
18+
"sample_text_multiline_property": "text",
19+
"TQ-explain": "checkbox",
20+
"TQ-short-mode": "checkbox",
21+
"TQ-show-tree": "checkbox",
22+
"TQ-show-tags": "checkbox",
23+
"TQ-show-id": "checkbox",
24+
"TQ-show-depends-on": "checkbox",
25+
"TQ-show-priority": "checkbox",
26+
"TQ-show-recurrence-rule": "checkbox",
27+
"TQ-show-on-completion": "checkbox",
28+
"TQ-show-created-date": "checkbox",
29+
"TQ-show-start-date": "checkbox",
30+
"TQ-show-scheduled-date": "checkbox",
31+
"TQ-show-due-date": "checkbox",
32+
"TQ-show-cancelled-date": "checkbox",
33+
"TQ-show-done-date": "checkbox",
34+
"TQ-show-urgency": "checkbox",
35+
"TQ-show-backlink": "checkbox",
36+
"TQ-show-edit-button": "checkbox",
37+
"TQ-show-postpone-button": "checkbox",
38+
"TQ-show-task-count": "checkbox",
39+
"TQ-sort-by": "multitext",
40+
"TQ-group-by": "multitext",
41+
"TQ-extra-instructions": "text"
1942
}
2043
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
---
2+
TQ-explain: false
3+
TQ-short-mode: false
4+
TQ-show-tree: true
5+
TQ-show-tags: true
6+
TQ-show-id: true
7+
TQ-show-depends-on: true
8+
TQ-show-priority: true
9+
TQ-show-recurrence-rule: true
10+
TQ-show-on-completion: true
11+
TQ-show-created-date: true
12+
TQ-show-start-date: true
13+
TQ-show-scheduled-date: true
14+
TQ-show-due-date: true
15+
TQ-show-cancelled-date: true
16+
TQ-show-done-date: true
17+
TQ-show-urgency: true
18+
TQ-show-backlink: true
19+
TQ-show-edit-button: true
20+
TQ-show-postpone-button: true
21+
TQ-show-task-count: true
22+
TQ-sort-by:
23+
- description
24+
TQ-group-by:
25+
- status.type
26+
- happens reverse
27+
- function task.tags.sort().join(' ')
28+
TQ-extra-instructions: |-
29+
# press shift-return to add new lines
30+
# not done
31+
# sort by done date
32+
---
33+
# Placeholder examples to capture in tests and docs
34+
35+
- [ ] #task Parent task #todo #health 🆔 abcdef ⛔ 123456,abc123 🔼 🔁 every day when done 🏁 delete ➕ 2023-07-01 🛫 2023-07-02 ⏳ 2023-07-03 📅 2023-07-04 ❌ 2023-07-06 ✅ 2023-07-05 ^dcf64c
36+
- [ ] #task Child task
37+
38+
## Can now have logical operators inside placeholders
39+
40+
So we can easily control the query interactively now, via Obsidian's File Properties panel!!!
41+
42+
This search is a proof-of-concept. The `TQ-` prefix was chosen to stand for `Task Query`. It is not yet decided whether recognition of these properties will be built in to Tasks in future.
43+
44+
To try this out:
45+
46+
1. Switch to Reading or Live Preview modes.
47+
2. Run the `Files: Show file explorer` command.
48+
3. Modify the query via only editing file properties.
49+
50+
For bonus points, you can copy the placeholder instructions to your Tasks global search, and then you can use these instructions to adjust *all* the searches in your vault that do not use `ignore global query`.
51+
52+
```tasks
53+
# We ignore the global query just to shorten the `explain` output.
54+
ignore global query
55+
path includes {{query.file.path}}
56+
57+
# Instructions are listed in the order that items are displayed in Tasks search results
58+
# I would like to use the prefix 'tasks-query-' on the names, but it makes the names
59+
# too wide to be readable in the File Properties panel.
60+
61+
{{const prop = 'TQ-explain'; return query.file.hasProperty(prop) ? ( query.file.property(prop) ? 'explain' : '') : '';}}
62+
{{const prop = 'TQ-short-mode'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'short mode' : 'full mode') || ''}}
63+
64+
{{const prop = 'TQ-show-tree'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' tree' || ''}}
65+
66+
{{const prop = 'TQ-show-tags'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' tags' || ''}}
67+
{{const prop = 'TQ-show-id'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' id' || ''}}
68+
{{const prop = 'TQ-show-depends-on'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' depends on' || ''}}
69+
{{const prop = 'TQ-show-priority'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' priority' || ''}}
70+
{{const prop = 'TQ-show-recurrence-rule'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' recurrence rule' || ''}}
71+
{{const prop = 'TQ-show-on-completion'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' on completion' || ''}}
72+
73+
{{const prop = 'TQ-show-created-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' created date' || ''}}
74+
{{const prop = 'TQ-show-start-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' start date' || ''}}
75+
{{const prop = 'TQ-show-scheduled-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' scheduled date' || ''}}
76+
{{const prop = 'TQ-show-due-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' due date' || ''}}
77+
{{const prop = 'TQ-show-cancelled-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' cancelled date' || ''}}
78+
{{const prop = 'TQ-show-done-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' done date' || ''}}
79+
80+
{{const prop = 'TQ-show-urgency' ; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' urgency' || ''}}
81+
{{const prop = 'TQ-show-backlink'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' backlink' || ''}}
82+
{{const prop = 'TQ-show-edit-button'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' edit button' || ''}}
83+
{{const prop = 'TQ-show-postpone-button'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' postpone button' || ''}}
84+
{{const prop = 'TQ-show-task-count'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' task count' || ''}}
85+
86+
{{const prop = 'TQ-sort-by'; return query.file.hasProperty(prop) && query.file.property(prop).map((g) => 'sort by ' + g).join('\n') || ''}}
87+
{{const prop = 'TQ-group-by'; return query.file.hasProperty(prop) && query.file.property(prop).map((g) => 'group by ' + g).join('\n') || ''}}
88+
89+
{{const prop = 'TQ-extra-instructions'; return query.file.hasProperty(prop) ? query.file.property(prop) || '' : '';}}
90+
```
91+
92+
## Can now call functions inside placeholders
93+
94+
```tasks
95+
ignore global query
96+
explain
97+
path includes {{query.file.path.toUpperCase()}}
98+
```
99+
100+
## Expands to null, but should be an error
101+
102+
```tasks
103+
ignore global query
104+
explain
105+
path includes {{query.file.path}}
106+
{{query.file.property('stuff')}}
107+
```
108+
109+
## Expands to false, but should be an error
110+
111+
```tasks
112+
ignore global query
113+
explain
114+
path includes {{query.file.path}}
115+
{{query.file.hasProperty('stuff')}}
116+
```
Lines changed: 28 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Mustache from 'mustache';
22
import proxyData from 'mustache-validator';
3+
import { type ExpressionParameter, evaluateExpression, parseExpression } from './Expression';
34

45
// https://github.com/janl/mustache.js
56

@@ -26,11 +27,11 @@ export function expandPlaceholders(template: string, view: any): string {
2627
return text;
2728
};
2829

29-
// Preprocess the template to evaluate any placeholders that involve function calls
30-
const evaluatedTemplate = evaluateAnyFunctionCalls(template, view);
31-
32-
// Render the preprocessed template
3330
try {
31+
// Preprocess the template to evaluate any placeholders that involve function calls
32+
const evaluatedTemplate = evaluateAnyFunctionCalls(template, view);
33+
34+
// Render the preprocessed template
3435
return Mustache.render(evaluatedTemplate, proxyData(view));
3536
} catch (error) {
3637
let message = '';
@@ -50,98 +51,37 @@ The problem is in:
5051
}
5152
}
5253

53-
const ARGUMENTS_REGEX = new RegExp(
54+
// Regex to detect placeholders
55+
const PLACEHOLDER_REGEX = new RegExp(
5456
[
55-
// Match single-quoted arguments
56-
"'((?:\\\\'|[^'])*)'",
57+
// Match the opening double braces `{{`
58+
'\\{\\{',
5759

58-
// Match double-quoted arguments
59-
'"((?:\\\\"|[^"])*)"',
60+
// Lazily capture everything inside (.*?), ensuring it stops at the first `}}`
61+
'(.*?)',
6062

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
63+
// Match the closing double braces `}}`
64+
'\\}\\}',
65+
].join(''), // Combine the parts into a single string
66+
'g', // Global flag to find all matches
10367
);
10468

10569
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;
70+
return template.replace(PLACEHOLDER_REGEX, (_match, reconstructed) => {
71+
const paramsArgs: ExpressionParameter[] = createExpressionParameters(view);
72+
const functionOrError = parseExpression(paramsArgs, reconstructed);
73+
if (functionOrError.isValid()) {
74+
const result = evaluateExpression(functionOrError.queryComponent!, paramsArgs);
75+
if (result !== undefined) {
76+
return result;
13077
}
131-
obj = obj[part]; // Move to the next level of the object
13278
}
133-
// At the end of the loop, obj contains the resolved value or undefined if any part of the path was invalid
13479

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}`);
80+
// Fall back on returning the raw string, including {{ and }} - and get Mustache to report the error.
81+
return _match;
14682
});
14783
}
84+
85+
function createExpressionParameters(view: any): ExpressionParameter[] {
86+
return Object.entries(view) as ExpressionParameter[];
87+
}

src/Scripting/Expression.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import { errorMessageForException } from '../lib/ExceptionTools';
33

44
export class FunctionOrError extends QueryComponentOrError<Function> {}
55

6+
/**
7+
* The name and value of a parameter, as a Tuple, for passing in to {@link parseExpression} and related functions.
8+
*/
9+
export type ExpressionParameter = [name: string, value: any];
10+
611
/**
712
* Parse a JavaScript expression, and return either a Function or an error message in a string.
813
* @param paramsArgs
@@ -11,16 +16,16 @@ export class FunctionOrError extends QueryComponentOrError<Function> {}
1116
* @see evaluateExpression
1217
* @see evaluateExpressionOrCatch
1318
*/
14-
export function parseExpression(paramsArgs: [string, any][], arg: string): FunctionOrError {
15-
const params = paramsArgs.map(([p]) => p);
19+
export function parseExpression(paramsArgs: ExpressionParameter[], arg: string): FunctionOrError {
1620
try {
21+
const parameterNames = paramsArgs.map(([name]) => name);
1722
const input = arg.includes('return') ? arg : `return ${arg}`;
18-
const expression: '' | null | Function = arg && new Function(...params, input);
23+
const expression: '' | null | Function = arg && new Function(...parameterNames, input);
1924
if (expression instanceof Function) {
2025
return FunctionOrError.fromObject(arg, expression);
2126
}
2227
// I have not managed to write a test that reaches here:
23-
return FunctionOrError.fromError(arg, 'Error parsing group function');
28+
return FunctionOrError.fromError(arg, `Problem parsing expression "${arg}"`);
2429
} catch (e) {
2530
return FunctionOrError.fromError(arg, errorMessageForException(`Failed parsing expression "${arg}"`, e));
2631
}
@@ -34,9 +39,9 @@ export function parseExpression(paramsArgs: [string, any][], arg: string): Funct
3439
* @see parseExpression
3540
* @see evaluateExpressionOrCatch
3641
*/
37-
export function evaluateExpression(expression: Function, paramsArgs: [string, any][]) {
38-
const args = paramsArgs.map(([_, a]) => a);
39-
return expression(...args);
42+
export function evaluateExpression(expression: Function, paramsArgs: ExpressionParameter[]) {
43+
const parameterValues = paramsArgs.map(([_, value]) => value);
44+
return expression(...parameterValues);
4045
}
4146

4247
/**
@@ -48,7 +53,7 @@ export function evaluateExpression(expression: Function, paramsArgs: [string, an
4853
* @see parseExpression
4954
* @see evaluateExpression
5055
*/
51-
export function evaluateExpressionOrCatch(expression: Function, paramsArgs: [string, any][], arg: string) {
56+
export function evaluateExpressionOrCatch(expression: Function, paramsArgs: ExpressionParameter[], arg: string) {
5257
try {
5358
return evaluateExpression(expression, paramsArgs);
5459
} catch (e) {

0 commit comments

Comments
 (0)