Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e95bf23
docs: Interim commit documenting query.file.hasProperty() and query.f…
claremacrae Jan 20, 2025
9a01d1d
refactor: . Extract method makeQueryFromSourceAndTasksFile()
claremacrae Jan 20, 2025
c5deb55
refactor: . Make tasksFile no longer readonly - preparing to update p…
claremacrae Jan 20, 2025
a00553b
refactor: . Add setter and getter for QueryResultsRenderer.tasksFile
claremacrae Jan 20, 2025
bfa7364
test: . Extract makeQueryResultsRenderer()
claremacrae Jan 20, 2025
0249bfb
test: - add failing test showing work needed on QueryResultsRenderer
claremacrae Jan 20, 2025
282960c
fix: If QueryResultsRenderer file is changed, update any placeholders
claremacrae Jan 20, 2025
a8ee6e9
fix: Reload queries if query frontmatter is edited, e.g. metadata cha…
claremacrae Jan 20, 2025
5fc9fba
fix: Update placeholders when query file is renamed.
claremacrae Jan 20, 2025
0ebcb8f
refactor: ! Remove debug output in QueryRenderer
claremacrae Jan 20, 2025
1c2d54b
refactor: ! Remove pointless check
claremacrae Jan 20, 2025
16db357
refactor: . Remove console output
claremacrae Jan 20, 2025
2cacdaf
refactor: . Extract handleMetadataOrFilePathChange()
claremacrae Jan 20, 2025
9b13a1e
refactor: . Reuse handleMetadataOrFilePathChange()
claremacrae Jan 20, 2025
c52de8b
refactor: . Inline some variables that were only used once
claremacrae Jan 20, 2025
77e78cc
fix: Only re-read the query if its path or frontmatter has changed
claremacrae Jan 20, 2025
972e8f6
comment: Remove some TODOs and tidy up comments
claremacrae Jan 20, 2025
6545caa
tests: Add examples to QueryProperties.test.query_file_properties.app…
claremacrae Jan 20, 2025
d0a3997
docs: Add query.file.hasProperty() & query.file.property() to Query P…
claremacrae Jan 20, 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
59 changes: 51 additions & 8 deletions docs/Getting Started/Obsidian Properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,57 @@ group by function \
return value ? window.moment(value).format('YYYY MMMM') : 'no date'
```

## Using Query Properties in Placeholders

> [!released]
> Use of Obsidian properties in placeholders was introduced in Tasks X.Y.Z.

- It is now possible to use properties in the query file:
- `query.file.hasProperty()` works.
- `query.file.property()` works.

Imagine this text at the top of the note containing the query:

```yaml
---
search-text: exercise
---
```

It can be used in your query in two ways:

1. A search term from front-matter embedded via placeholder:

```javascript
description includes {{query.file.property('search-text')}}
```

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

```javascript
filter by function \
if (!query.file.hasProperty('search-text')) return true; \
const propertyLower = query.file.property('search-text').toLowerCase(); \
if (propertyLower === '') return true; \
return task.description.toLowerCase().includes(propertyLower);
```

> [!warning] Using properties with no value
> Currently when a property in a placeholder is not set:
>
> - in text instructions, the string used is currently `null`, which is not likely to be the intent
> - in numeric instructions, the value used is `null` which gives an error

> [!Info]
> In a future release, we will likely allow Tasks to silently ignore built filters created from properties that have no value.

> [!Info]
> In a future release, we will likely introduce standard property names for instructions that will automatically be included inside Tasks queries.
> Perhaps:
>
> - tasks-search-explain: true/false
> - tasks-search-limit: number

## How does Tasks interpret Obsidian Properties?

Consider a file with the following example properties (or "Frontmatter"):
Expand Down Expand Up @@ -237,11 +288,3 @@ The following table shows how most of those properties are interpreted in Tasks
| `task.file.property('tags')` | `string[]` | `['#tag-from-file-properties']` |

<!-- placeholder to force blank line after included text --><!-- endInclude -->

## Limitations

- It is not yet possible to use properties in the query file:
- `query.file.hasProperty()` does not yet work.
- `query.file.property()` does not yet work.

We are tracking this in [issue #3083](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/3083).
6 changes: 6 additions & 0 deletions docs/Scripting/Query Properties.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ This page documents all the available pieces of information in Queries that you
| `query.file.folder` | `string` | `'root/sub-folder/'` |
| `query.file.filename` | `string` | `'file containing query.md'` |
| `query.file.filenameWithoutExtension` | `string` | `'file containing query'` |
| `query.file.hasProperty('task_instruction')` | `boolean` | `true` |
| `query.file.hasProperty('non_existent_property')` | `boolean` | `false` |
| `query.file.property('task_instruction')` | `string` | `'group by filename'` |
| `query.file.property('non_existent_property')` | `null` | `null` |

<!-- placeholder to force blank line after included text --><!-- endInclude -->

Expand All @@ -42,6 +46,8 @@ This page documents all the available pieces of information in Queries that you
1. The presence of `.md` filename extensions is chosen to match the existing conventions in the Tasks filter instructions [[Filters#File Path|path]] and [[Filters#File Name|filename]].
1. `query.file.pathWithoutExtension` was added in Tasks 4.8.0.
1. `query.file.filenameWithoutExtension` was added in Tasks 4.8.0.
1. `query.file.hasProperty()` was added in Tasks X.Y.Z.
1. `query.file.property()` was added in Tasks X.Y.Z.

## Values for Query Search Properties

Expand Down
41 changes: 39 additions & 2 deletions src/Renderer/QueryRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type MarkdownPostProcessorContext,
MarkdownRenderChild,
MarkdownRenderer,
type TAbstractFile,
TFile,
} from 'obsidian';
import { App, Keymap } from 'obsidian';
Expand Down Expand Up @@ -39,8 +40,6 @@ 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 listen out for edits the properties, so if a property is edited,
// the user needs to close and re-open the file.
// - Multi-line properties are supported, but they cannot contain
// continuation lines.
const app = this.app;
Expand Down Expand Up @@ -116,6 +115,44 @@ class QueryRenderChild extends MarkdownRenderChild {
this.renderEventRef = this.events.onCacheUpdate(this.render.bind(this));

this.reloadQueryAtMidnight();

this.registerEvent(
this.app.metadataCache.on('changed', (sourceFile, _data, fileCache) => {
const filePath = sourceFile.path;
if (filePath !== this.queryResultsRenderer.filePath) {
// We get notified of edits to all files, and are only interested in the
// file where our query is.
return;
}

this.handleMetadataOrFilePathChange(filePath, fileCache);
}),
);

this.registerEvent(
this.app.vault.on('rename', (tFile: TAbstractFile, _oldPath: string) => {
let fileCache: CachedMetadata | null = null;
if (tFile && tFile instanceof TFile) {
fileCache = this.app.metadataCache.getFileCache(tFile);
}
this.handleMetadataOrFilePathChange(tFile.path, fileCache);
}),
);
}

private handleMetadataOrFilePathChange(filePath: string, fileCache: CachedMetadata | null) {
const oldTasksFile = this.queryResultsRenderer.tasksFile;
const newTasksFile = new TasksFile(filePath, fileCache ?? {});

// Has anything changed which might change the query results?
const differentPath = oldTasksFile.path !== newTasksFile.path;
const differentFrontmatter = !oldTasksFile.rawFrontmatterIdenticalTo(newTasksFile);
const queryNeedsReloading = differentPath || differentFrontmatter;

if (queryNeedsReloading) {
this.queryResultsRenderer.setTasksFile(newTasksFile);
this.events.triggerRequestCacheUpdate(this.render.bind(this));
}
}

onunload() {
Expand Down
27 changes: 23 additions & 4 deletions src/Renderer/QueryResultsRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ export class QueryResultsRenderer {
public readonly source: string;

// The path of the file that contains the instruction block, and cached data from that file.
public readonly tasksFile: TasksFile;
// This can be updated when the query file's frontmatter is modified.
// It is up to the caller to determine when to do this though.
private _tasksFile: TasksFile;

public query: IQuery;
protected queryType: string; // whilst there is only one query type, there is no point logging this value
Expand All @@ -62,7 +64,7 @@ export class QueryResultsRenderer {
textRenderer: TextRenderer = TaskLineRenderer.obsidianMarkdownRenderer,
) {
this.source = source;
this.tasksFile = tasksFile;
this._tasksFile = tasksFile;
this.renderMarkdown = renderMarkdown;
this.obsidianComponent = obsidianComponent;
this.textRenderer = textRenderer;
Expand All @@ -72,17 +74,34 @@ export class QueryResultsRenderer {
// added later.
switch (className) {
case 'block-language-tasks':
this.query = getQueryForQueryRenderer(this.source, GlobalQuery.getInstance(), this.tasksFile);
this.query = this.makeQueryFromSourceAndTasksFile();
this.queryType = 'tasks';
break;

default:
this.query = getQueryForQueryRenderer(this.source, GlobalQuery.getInstance(), this.tasksFile);
this.query = this.makeQueryFromSourceAndTasksFile();
this.queryType = 'tasks';
break;
}
}

private makeQueryFromSourceAndTasksFile() {
return getQueryForQueryRenderer(this.source, GlobalQuery.getInstance(), this.tasksFile);
}

public get tasksFile(): TasksFile {
return this._tasksFile;
}

/**
* Reload the query with new file information, such as to update query placeholders.
* @param newFile
*/
public setTasksFile(newFile: TasksFile) {
this._tasksFile = newFile;
this.query = this.makeQueryFromSourceAndTasksFile();
}

public get filePath(): string | undefined {
return this.tasksFile?.path ?? undefined;
}
Expand Down
35 changes: 27 additions & 8 deletions tests/Renderer/QueryResultsRenderer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,20 @@ afterEach(() => {
jest.useRealTimers();
});

function makeQueryResultsRenderer(source: string, tasksFile: TasksFile) {
return new QueryResultsRenderer(
'block-language-tasks',
source,
tasksFile,
() => Promise.resolve(),
null,
mockHTMLRenderer,
);
}

describe('QueryResultsRenderer tests', () => {
async function verifyRenderedTasksHTML(allTasks: Task[], source: string = '') {
const renderer = new QueryResultsRenderer(
'block-language-tasks',
source,
new TasksFile('query.md'),
() => Promise.resolve(),
null,
mockHTMLRenderer,
);
const renderer = makeQueryResultsRenderer(source, new TasksFile('query.md'));
const queryRendererParameters = {
allTasks,
allMarkdownFiles: [],
Expand Down Expand Up @@ -89,3 +93,18 @@ ${toMarkdown(allTasks)}
await verifyRenderedTasksHTML(allTasks, showTree + 'description includes grandchild');
});
});

describe('QueryResultsRenderer - responding to file edits', () => {
it('should update the query its file path is changed', () => {
// Arrange
const source = 'path includes {{query.file.path}}';
const renderer = makeQueryResultsRenderer(source, new TasksFile('oldPath.md'));
expect(renderer.query.explainQuery()).toContain('path includes oldPath.md');

// Act
renderer.setTasksFile(new TasksFile('newPath.md'));

// Assert
expect(renderer.query.explainQuery()).toContain('path includes newPath.md');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
| `query.file.folder` | `string` | `'root/sub-folder/'` |
| `query.file.filename` | `string` | `'file containing query.md'` |
| `query.file.filenameWithoutExtension` | `string` | `'file containing query'` |
| `query.file.hasProperty('task_instruction')` | `boolean` | `true` |
| `query.file.hasProperty('non_existent_property')` | `boolean` | `false` |
| `query.file.property('task_instruction')` | `string` | `'group by filename'` |
| `query.file.property('non_existent_property')` | `null` | `null` |


<!-- placeholder to force blank line after included text -->
9 changes: 8 additions & 1 deletion tests/Scripting/QueryProperties.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import { MarkdownTable } from '../../src/lib/MarkdownTable';
import { parseAndEvaluateExpression } from '../../src/Scripting/TaskExpression';
import { TaskBuilder } from '../TestingTools/TaskBuilder';
import { TasksFile } from '../../src/Scripting/TasksFile';
import { getTasksFileFromMockData } from '../TestingTools/MockDataHelpers';
import query_using_properties from '../Obsidian/__test_data__/query_using_properties.json';
import { addBackticks, determineExpressionType, formatToRepresentType } from './ScriptingTestHelpers';

describe('query', () => {
function verifyFieldDataForReferenceDocs(fields: string[]) {
const markdownTable = new MarkdownTable(['Field', 'Type', 'Example']);
const tasksFile = new TasksFile('root/sub-folder/file containing query.md');
const cachedMetadata = getTasksFileFromMockData(query_using_properties).cachedMetadata;
const tasksFile = new TasksFile('root/sub-folder/file containing query.md', cachedMetadata);
const task = new TaskBuilder()
.description('... an array with all the Tasks-tracked tasks in the vault ...')
.build();
Expand All @@ -35,6 +38,10 @@ describe('query', () => {
'query.file.folder',
'query.file.filename',
'query.file.filenameWithoutExtension',
"query.file.hasProperty('task_instruction')",
"query.file.hasProperty('non_existent_property')",
"query.file.property('task_instruction')",
"query.file.property('non_existent_property')",
]);
});

Expand Down
Loading