Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
b3a13e7
refactor: - Move an expression inside try/catch block
claremacrae Jan 25, 2025
14b264f
refactor: Remove on old reference to 'group'
claremacrae Jan 25, 2025
c66dcfe
test: - Failing test for placeholder lines with 2 function calls
claremacrae Jan 25, 2025
dbf5b82
test: - Save an experiment in finding functions in placeholders
claremacrae Jan 26, 2025
af40a21
jsdoc: Add more info to QueryContext.ts
claremacrae Jan 27, 2025
db70d88
refactor: . Specify return type of constructArguments
claremacrae Jan 27, 2025
0ebf077
test: . Inline unnecessary variable
claremacrae Jan 27, 2025
c5571a9
refactor: . Simplify code
claremacrae Jan 27, 2025
ca1c249
refactor: . Rename params to parameterNames for clarity
claremacrae Jan 27, 2025
fb35922
refactor: . Rename args to parameterValues for clarity
claremacrae Jan 27, 2025
55e4888
refactor: - Introduce type ExpressionParameter
claremacrae Jan 27, 2025
3c8f0a0
jsdoc: Document ExpressionParameter
claremacrae Jan 27, 2025
2382564
refactor: Name the values in ExpressionParameter tuple
claremacrae Jan 27, 2025
980103d
test: - Experiment with using Expression on placeholder views
claremacrae Jan 27, 2025
de74a49
test: - Experiment with using Expression on placeholder views
claremacrae Jan 27, 2025
ea9ec56
test: - Experiment with using Expression on placeholder views on nest…
claremacrae Jan 27, 2025
7825403
refactor: - Proof-of-concept re-implementing evaluateAnyFunctionCalls()
claremacrae Jan 27, 2025
dfd7275
refactor: - Use result of new implementation of evaluateAnyFunctionCa…
claremacrae Jan 27, 2025
2db9aec
refactor: - Move new implementation up above old
claremacrae Jan 27, 2025
2741d00
test: - Remove unreached code
claremacrae Jan 27, 2025
c96b195
refactor: - Remove console.log() call- put text in exception instead.
claremacrae Jan 27, 2025
a84ccac
test: - Add some more tests in ExpandPlaceholders.test.ts
claremacrae Jan 27, 2025
7e9a4d3
test: - Remove proof-of-concept tests that are no longer needed.
claremacrae Jan 27, 2025
34aee87
test: . Inline some variables that are no longer needed
claremacrae Jan 27, 2025
0f81b1a
test: - Fix a test which was only failing because of an error in it
claremacrae Jan 27, 2025
02a751f
test: - Convert a snapshot test to simple toEqual()
claremacrae Jan 27, 2025
2be20b9
refactor: - Move evaluateAnyFunctionCalls() call inside try/catch block
claremacrae Jan 27, 2025
ba8254c
refactor: - evaluateAnyFunctionCalls() defers to Mustache on error
claremacrae Jan 27, 2025
0f94d42
feat: Allow more complex expressions in placeholders
claremacrae Jan 27, 2025
e05bb93
vault: Add Placeholder examples to capture in tests and docs.md
claremacrae Jan 27, 2025
0e7d5cf
refactor: . Rename FUNCTION_REGEX to PLACEHOLDER_REGEX
claremacrae Jan 27, 2025
d946f73
tests: - Remove some experimental tests
claremacrae Jan 27, 2025
bd7fb50
tests: - Rename test of code that is now supported
claremacrae Jan 27, 2025
1448c53
tests: - Update a test for the new placeholders code
claremacrae Jan 27, 2025
5303886
vault: Wrap a long query line for readability
claremacrae Jan 27, 2025
b2b2e07
vault: Try aligning field names in placeholders
claremacrae Jan 27, 2025
976dc9f
vault: Try aligning more code in placeholders
claremacrae Jan 27, 2025
1028315
vault: Add demo property to toggle short mode
claremacrae Jan 27, 2025
389fba2
vault: Add TQ prefix- (for "Tasks Query") to property names
claremacrae Jan 27, 2025
1fe4c6a
vault: Fix white-space inconsistency from manual edits in types.json
claremacrae Jan 27, 2025
5e2fae1
docs: Add example of generating instructions from properties
claremacrae Jan 27, 2025
374d473
vault: Add demo of "TQ-group-by" list property to control grouping
claremacrae Jan 27, 2025
51963a5
vault: Add demo of "TQ-sort-by" list property to control sorting
claremacrae Jan 27, 2025
308ab5b
vault: Add some explanatory comments
claremacrae Jan 27, 2025
96a9837
vault: Add demo of "TQ-extra-instructions" string property to add ext…
claremacrae Jan 27, 2025
f74b253
vault: Decided to go back to one-line placeholders
claremacrae Jan 27, 2025
3df7d04
vault: Remove repetition of property name
claremacrae Jan 27, 2025
7ccd4ce
vault: Extract variable prop for remaining property-based instructions
claremacrae Jan 27, 2025
8cf0f96
vault: Reuse variable prop
claremacrae Jan 27, 2025
0b7f470
vault: Align return statements
claremacrae Jan 27, 2025
02971f9
vault: Further improve alignment
claremacrae Jan 27, 2025
64f30e6
vault: Group together related instructions
claremacrae Jan 27, 2025
021c01e
vault: Remove accidental space in property name
claremacrae Jan 27, 2025
3f0a293
docs: Improve example for making instructions from properties
claremacrae Jan 27, 2025
51d979f
docs: Update Changelog.md
claremacrae Jan 28, 2025
346c90b
docs: Add query.file.hasProperty() & query.file.property() to Quick Ref
claremacrae Jan 28, 2025
7c2ca44
docs: Update column alignment in Quick Reference.md
claremacrae Jan 28, 2025
3909f72
test: - Remove comment that is no longer true
claremacrae Jan 28, 2025
869db6c
docs: Record richer placeholders features
claremacrae Jan 28, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/Getting Started/Obsidian Properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,14 @@ It can be used in your query in two ways:
description includes {{query.file.property('search-text')}}
```

1. An entire instruction controlled by front-matter value:

```javascript
{{const prop = 'TQ-explain'; return query.file.hasProperty(prop) ? ( query.file.property(prop) ? 'explain' : '') : '';}}

{{const prop = 'TQ-show-tree'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' tree' || ''}}
```

1. Scripting, which allows creation of a custom filter, which works when the search term is empty

```javascript
Expand Down
134 changes: 67 additions & 67 deletions docs/Quick Reference.md

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions docs/What is New/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ _In recent [Tasks releases](https://github.com/obsidian-tasks-group/obsidian-tas

## 7.x releases

- X.Y.Z:
- Add `query.file.hasProperty()` and `query.file.property()` in custom filters
- Add `{{query.file.hasProperty()}}` and `{{query.file.property()}}` in placeholders - see [[Obsidian Properties#Using Query Properties in Placeholders|Using Query Properties in Placeholders]].
- Placeholders can now call functions and contain expressions.
- Add Chinese translation of [[Settings]], [[Editing a Status]] and [[Check your Statuses]]
- 7.14.0:
- Add [[Editing Dates#Date-picker on task dates|date picker]] to Reading mode and Tasks query search results.
- 7.13.0:
Expand Down
25 changes: 24 additions & 1 deletion resources/sample_vaults/Tasks-Demo/.obsidian/types.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@
"sample_link_property": "text",
"creation date": "datetime",
"project": "text",
"sample_text_multiline_property": "text"
"sample_text_multiline_property": "text",
"TQ-explain": "checkbox",
"TQ-short-mode": "checkbox",
"TQ-show-tree": "checkbox",
"TQ-show-tags": "checkbox",
"TQ-show-id": "checkbox",
"TQ-show-depends-on": "checkbox",
"TQ-show-priority": "checkbox",
"TQ-show-recurrence-rule": "checkbox",
"TQ-show-on-completion": "checkbox",
"TQ-show-created-date": "checkbox",
"TQ-show-start-date": "checkbox",
"TQ-show-scheduled-date": "checkbox",
"TQ-show-due-date": "checkbox",
"TQ-show-cancelled-date": "checkbox",
"TQ-show-done-date": "checkbox",
"TQ-show-urgency": "checkbox",
"TQ-show-backlink": "checkbox",
"TQ-show-edit-button": "checkbox",
"TQ-show-postpone-button": "checkbox",
"TQ-show-task-count": "checkbox",
"TQ-sort-by": "multitext",
"TQ-group-by": "multitext",
"TQ-extra-instructions": "text"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
---
TQ-explain: false
TQ-short-mode: false
TQ-show-tree: true
TQ-show-tags: true
TQ-show-id: true
TQ-show-depends-on: true
TQ-show-priority: true
TQ-show-recurrence-rule: true
TQ-show-on-completion: true
TQ-show-created-date: true
TQ-show-start-date: true
TQ-show-scheduled-date: true
TQ-show-due-date: true
TQ-show-cancelled-date: true
TQ-show-done-date: true
TQ-show-urgency: true
TQ-show-backlink: true
TQ-show-edit-button: true
TQ-show-postpone-button: true
TQ-show-task-count: true
TQ-sort-by:
- description
TQ-group-by:
- status.type
- happens reverse
- function task.tags.sort().join(' ')
TQ-extra-instructions: |-
# press shift-return to add new lines
# not done
# sort by done date
---
# Placeholder examples to capture in tests and docs

- [ ] #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
- [ ] #task Child task

## Can now have logical operators inside placeholders

So we can easily control the query interactively now, via Obsidian's File Properties panel!!!

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.

To try this out:

1. Switch to Reading or Live Preview modes.
2. Run the `Files: Show file explorer` command.
3. Modify the query via only editing file properties.

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`.

```tasks
# We ignore the global query just to shorten the `explain` output.
ignore global query
path includes {{query.file.path}}

# Instructions are listed in the order that items are displayed in Tasks search results
# I would like to use the prefix 'tasks-query-' on the names, but it makes the names
# too wide to be readable in the File Properties panel.

{{const prop = 'TQ-explain'; return query.file.hasProperty(prop) ? ( query.file.property(prop) ? 'explain' : '') : '';}}
{{const prop = 'TQ-short-mode'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'short mode' : 'full mode') || ''}}

{{const prop = 'TQ-show-tree'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' tree' || ''}}

{{const prop = 'TQ-show-tags'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' tags' || ''}}
{{const prop = 'TQ-show-id'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' id' || ''}}
{{const prop = 'TQ-show-depends-on'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' depends on' || ''}}
{{const prop = 'TQ-show-priority'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' priority' || ''}}
{{const prop = 'TQ-show-recurrence-rule'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' recurrence rule' || ''}}
{{const prop = 'TQ-show-on-completion'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' on completion' || ''}}

{{const prop = 'TQ-show-created-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' created date' || ''}}
{{const prop = 'TQ-show-start-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' start date' || ''}}
{{const prop = 'TQ-show-scheduled-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' scheduled date' || ''}}
{{const prop = 'TQ-show-due-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' due date' || ''}}
{{const prop = 'TQ-show-cancelled-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' cancelled date' || ''}}
{{const prop = 'TQ-show-done-date'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' done date' || ''}}

{{const prop = 'TQ-show-urgency' ; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' urgency' || ''}}
{{const prop = 'TQ-show-backlink'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' backlink' || ''}}
{{const prop = 'TQ-show-edit-button'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' edit button' || ''}}
{{const prop = 'TQ-show-postpone-button'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' postpone button' || ''}}
{{const prop = 'TQ-show-task-count'; return query.file.hasProperty(prop) && ( query.file.property(prop) ? 'show' : 'hide') + ' task count' || ''}}

{{const prop = 'TQ-sort-by'; return query.file.hasProperty(prop) && query.file.property(prop).map((g) => 'sort by ' + g).join('\n') || ''}}
{{const prop = 'TQ-group-by'; return query.file.hasProperty(prop) && query.file.property(prop).map((g) => 'group by ' + g).join('\n') || ''}}

{{const prop = 'TQ-extra-instructions'; return query.file.hasProperty(prop) ? query.file.property(prop) || '' : '';}}
```

## Can now call functions inside placeholders

```tasks
ignore global query
explain
path includes {{query.file.path.toUpperCase()}}
```

## Expands to null, but should be an error

```tasks
ignore global query
explain
path includes {{query.file.path}}
{{query.file.property('stuff')}}
```

## Expands to false, but should be an error

```tasks
ignore global query
explain
path includes {{query.file.path}}
{{query.file.hasProperty('stuff')}}
```
116 changes: 28 additions & 88 deletions src/Scripting/ExpandPlaceholders.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Mustache from 'mustache';
import proxyData from 'mustache-validator';
import { type ExpressionParameter, evaluateExpression, parseExpression } from './Expression';

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

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

// Preprocess the template to evaluate any placeholders that involve function calls
const evaluatedTemplate = evaluateAnyFunctionCalls(template, view);

// Render the preprocessed template
try {
// Preprocess the template to evaluate any placeholders that involve function calls
const evaluatedTemplate = evaluateAnyFunctionCalls(template, view);

// Render the preprocessed template
return Mustache.render(evaluatedTemplate, proxyData(view));
} catch (error) {
let message = '';
Expand All @@ -50,98 +51,37 @@ The problem is in:
}
}

const ARGUMENTS_REGEX = new RegExp(
// Regex to detect placeholders
const PLACEHOLDER_REGEX = new RegExp(
[
// Match single-quoted arguments
"'((?:\\\\'|[^'])*)'",
// Match the opening double braces `{{`
'\\{\\{',

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

// 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 = ARGUMENTS_REGEX.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;
}

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

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.
//
// 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) {
// Stop traversal if obj is null or undefined
obj = undefined;
break;
return template.replace(PLACEHOLDER_REGEX, (_match, reconstructed) => {
const paramsArgs: ExpressionParameter[] = createExpressionParameters(view);
const functionOrError = parseExpression(paramsArgs, reconstructed);
if (functionOrError.isValid()) {
const result = evaluateExpression(functionOrError.queryComponent!, paramsArgs);
if (result !== undefined) {
return result;
}
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') {
// 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}`);
// Fall back on returning the raw string, including {{ and }} - and get Mustache to report the error.
return _match;
});
}

function createExpressionParameters(view: any): ExpressionParameter[] {
return Object.entries(view) as ExpressionParameter[];
}
21 changes: 13 additions & 8 deletions src/Scripting/Expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { errorMessageForException } from '../lib/ExceptionTools';

export class FunctionOrError extends QueryComponentOrError<Function> {}

/**
* The name and value of a parameter, as a Tuple, for passing in to {@link parseExpression} and related functions.
*/
export type ExpressionParameter = [name: string, value: any];

/**
* Parse a JavaScript expression, and return either a Function or an error message in a string.
* @param paramsArgs
Expand All @@ -11,16 +16,16 @@ export class FunctionOrError extends QueryComponentOrError<Function> {}
* @see evaluateExpression
* @see evaluateExpressionOrCatch
*/
export function parseExpression(paramsArgs: [string, any][], arg: string): FunctionOrError {
const params = paramsArgs.map(([p]) => p);
export function parseExpression(paramsArgs: ExpressionParameter[], arg: string): FunctionOrError {
try {
const parameterNames = paramsArgs.map(([name]) => name);
const input = arg.includes('return') ? arg : `return ${arg}`;
const expression: '' | null | Function = arg && new Function(...params, input);
const expression: '' | null | Function = arg && new Function(...parameterNames, input);
if (expression instanceof Function) {
return FunctionOrError.fromObject(arg, expression);
}
// I have not managed to write a test that reaches here:
return FunctionOrError.fromError(arg, 'Error parsing group function');
return FunctionOrError.fromError(arg, `Problem parsing expression "${arg}"`);
} catch (e) {
return FunctionOrError.fromError(arg, errorMessageForException(`Failed parsing expression "${arg}"`, e));
}
Expand All @@ -34,9 +39,9 @@ export function parseExpression(paramsArgs: [string, any][], arg: string): Funct
* @see parseExpression
* @see evaluateExpressionOrCatch
*/
export function evaluateExpression(expression: Function, paramsArgs: [string, any][]) {
const args = paramsArgs.map(([_, a]) => a);
return expression(...args);
export function evaluateExpression(expression: Function, paramsArgs: ExpressionParameter[]) {
const parameterValues = paramsArgs.map(([_, value]) => value);
return expression(...parameterValues);
}

/**
Expand All @@ -48,7 +53,7 @@ export function evaluateExpression(expression: Function, paramsArgs: [string, an
* @see parseExpression
* @see evaluateExpression
*/
export function evaluateExpressionOrCatch(expression: Function, paramsArgs: [string, any][], arg: string) {
export function evaluateExpressionOrCatch(expression: Function, paramsArgs: ExpressionParameter[], arg: string) {
try {
return evaluateExpression(expression, paramsArgs);
} catch (e) {
Expand Down
Loading