Skip to content

Commit

Permalink
Merge pull request #3242 from obsidian-tasks-group/query-accessing-pr…
Browse files Browse the repository at this point in the history
…operties

feat: partial support for query.file.property() and query.file.hasProperty()
  • Loading branch information
claremacrae authored Dec 20, 2024
2 parents b293a37 + 751b9a2 commit c684472
Show file tree
Hide file tree
Showing 7 changed files with 748 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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.property('task_instruction')}}
limit 10
```

## Use a multi-line property: task_instructions

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.property('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
```
29 changes: 26 additions & 3 deletions src/Renderer/QueryRenderer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 listen out for edits the properties, so if a property is edited,
// the user needs to close and re-open the file.
// - 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);
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();
Expand Down
106 changes: 104 additions & 2 deletions src/Scripting/ExpandPlaceholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import proxyData from 'mustache-validator';
* 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
Expand All @@ -24,8 +26,12 @@ 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 {
return Mustache.render(template, proxyData(view));
return Mustache.render(evaluatedTemplate, proxyData(view));
} catch (error) {
let message = '';
if (error instanceof Error) {
Expand All @@ -43,3 +49,99 @@ The problem is in:
throw Error(message);
}
}

const ARGUMENTS_REGEX = 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
);

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
);

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;
}
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}`);
});
}
2 changes: 2 additions & 0 deletions tests/Obsidian/AllCacheSampleData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +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_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';
Expand Down Expand Up @@ -119,6 +120,7 @@ export function allCacheSampleData() {
no_yaml,
non_tasks,
one_task,
query_using_properties,
yaml_1_alias,
yaml_2_aliases,
yaml_all_property_types_empty,
Expand Down
Loading

0 comments on commit c684472

Please sign in to comment.