Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
107 changes: 107 additions & 0 deletions docs/Dynamic-Task-Queries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Dynamic Task Query Lanes

This feature allows you to create Kanban lanes that are automatically populated with tasks from queries, similar to the Tasks plugin query blocks.

## How to Use

Add a tasks query block in your lane header to make it dynamic:

```markdown
## Today
```tasks
not done
due today
```

## Another Lane
```tasks
done
due before tomorrow
```

## Manual Tasks
This lane works normally - you can add tasks manually
- [ ] Regular task
- [x] Completed task
```

## Features

### Dynamic Population
- Lanes with query blocks automatically populate with matching tasks
- Tasks are pulled from across your vault based on the query criteria
- Queries use the same syntax as the Tasks plugin

### Manual Override
- You can still add tasks manually to any lane, including query lanes
- Manual tasks appear alongside query results
- Manual tasks are preserved when the board is saved

### Smart Movement
- When you move a task to a query lane, its properties are automatically updated to match the query
- Moving a task to a "due today" lane will add today's date
- Moving to a "done" lane will mark the task as completed
- Moving to a "not done" lane will mark the task as incomplete

### Query Examples

#### Due Date Queries
```markdown
```tasks
not done
due today
```

```tasks
not done
due this week
```
```

#### Status Queries
```markdown
```tasks
done
```

```tasks
not done
```
```

#### Priority Queries
```markdown
```tasks
not done
priority high
```
```

#### Combined Queries
```markdown
```tasks
not done
due today
priority high
```
```

## Technical Details

### Lane Data Structure
- Lanes now support a `query` field in their data
- Query results are marked with `fromQuery: true` to distinguish from manual tasks
- Only manual tasks are serialized back to markdown to avoid duplication

### Refresh Behavior
- Dynamic lanes refresh when file metadata changes
- Manual refresh can be triggered by calling `refreshDynamicLanes()`
- Query results update automatically when underlying tasks change

### Compatibility
- Requires the Tasks plugin to be installed and enabled
- Falls back gracefully if Tasks plugin is not available
- Existing boards continue to work unchanged

> [!WARNING]
> Is scheduled at Tasks plugin roadmap a [future feature to configure the datetime format](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/3372). This may affect how due dates are interpreted in queries. There's a pin at `src/parsers/helpers/taskQuery.ts:119` and I'll address to it in the future commits.
13 changes: 13 additions & 0 deletions src/StateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getDefaultDateFormat, getDefaultTimeFormat } from './components/helpers
import { Board, BoardTemplate, Item } from './components/types';
import { ListFormat } from './parsers/List';
import { BaseFormat, frontmatterKey, shouldRefreshBoard } from './parsers/common';
import { refreshDynamicLanes } from './parsers/helpers/taskQuery';
import { getTaskStatusDone } from './parsers/helpers/inlineMetadata';
import { defaultDateTrigger, defaultMetadataPosition, defaultTimeTrigger } from './settingHelpers';

Expand Down Expand Up @@ -351,6 +352,18 @@ export class StateManager {

onFileMetadataChange() {
this.reparseBoardFromMd();
this.refreshDynamicLanes();
}

refreshDynamicLanes() {
const board = this.state;
const refreshedLanes = refreshDynamicLanes(this, board.children);

if (refreshedLanes !== board.children) {
this.setState(update(board, {
children: { $set: refreshedLanes }
}), false);
}
}

async reparseBoardFromMd() {
Expand Down
10 changes: 8 additions & 2 deletions src/components/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
getTaskStatusPreDone,
toggleTask,
} from 'src/parsers/helpers/inlineMetadata';
import { updateItemForQuery } from 'src/parsers/helpers/taskQuery';

import { SearchContextProps } from './context';
import { Board, DataKey, DateColor, Item, Lane, PageData, TagColor } from './types';
Expand Down Expand Up @@ -42,12 +43,17 @@ export function maybeCompleteForMove(
destinationPath: Path,
item: Item
): { next: Item; replacement?: Item } {
const sourceParent = getEntityFromPath(sourceBoard, sourcePath.slice(0, -1));
const destinationParent = getEntityFromPath(destinationBoard, destinationPath.slice(0, -1));
const sourceParent = getEntityFromPath(sourceBoard, sourcePath.slice(0, -1)) as Lane;
const destinationParent = getEntityFromPath(destinationBoard, destinationPath.slice(0, -1)) as Lane;

const oldShouldComplete = sourceParent?.data?.shouldMarkItemsComplete;
const newShouldComplete = destinationParent?.data?.shouldMarkItemsComplete;

// Check if moving to a query lane and update item for query if needed
if (destinationParent?.data?.query) {
item = updateItemForQuery(item, destinationParent.data.query);
}

// If neither the old or new lane set it complete, leave it alone
if (!oldShouldComplete && !newShouldComplete) return { next: item };

Expand Down
7 changes: 7 additions & 0 deletions src/components/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface LaneData {
shouldMarkItemsComplete?: boolean;
title: string;
maxItems?: number;
query?: string;
dom?: HTMLDivElement;
forceEditMode?: boolean;
sorted?: LaneSort | string;
Expand Down Expand Up @@ -87,6 +88,12 @@ export interface ItemData {
titleSearchRaw: string;
metadata: ItemMetadata;
forceEditMode?: boolean;
fromQuery?: boolean;
querySource?: {
path: string;
line: number;
blockId?: string;
};
}

export interface ErrorReport {
Expand Down
16 changes: 14 additions & 2 deletions src/parsers/formats/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -407,15 +407,27 @@ function itemToMd(item: Item) {
function laneToMd(lane: Lane) {
const lines: string[] = [];

lines.push(`## ${replaceNewLines(laneTitleWithMaxItems(lane.data.title, lane.data.maxItems))}`);
let title = replaceNewLines(laneTitleWithMaxItems(lane.data.title, lane.data.maxItems));

// Add query block if lane has a query
if (lane.data.query) {
title += `\n\`\`\`tasks\n${lane.data.query}\n\`\`\``;
}

lines.push(`## ${title}`);

lines.push('');

if (lane.data.shouldMarkItemsComplete) {
lines.push(completeString);
}

lane.children.forEach((item) => {
// Only serialize non-query items to avoid duplicating query results
const itemsToSerialize = lane.children.filter((item: Item) =>
!(item.data as any)?.fromQuery
);

itemsToSerialize.forEach((item) => {
lines.push(itemToMd(item));
});

Expand Down
7 changes: 7 additions & 0 deletions src/parsers/helpers/hydrateBoard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ import { getEntityFromPath } from 'src/dnd/util/data';
import { Op } from 'src/helpers/patch';

import { getSearchValue } from '../common';
import { populateLaneFromQuery } from './taskQuery';

export function hydrateLane(stateManager: StateManager, lane: Lane) {
// If the lane has a query, populate it with tasks from the query
if (lane.data.query) {
const populatedItems = populateLaneFromQuery(stateManager, lane);
lane.children = populatedItems;
}

return lane;
}

Expand Down
16 changes: 14 additions & 2 deletions src/parsers/helpers/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,20 @@ export function dedentNewLines(str: string) {
export function parseLaneTitle(str: string) {
str = replaceBrs(str);

// Check for tasks query block first
const queryMatch = str.match(/```tasks\n([\s\S]*?)\n```/);
let query: string | undefined;

if (queryMatch) {
query = queryMatch[1].trim();
// Remove the query block from the title
str = str.replace(/```tasks\n[\s\S]*?\n```/, '').trim();
}

const match = str.match(/^(.*?)\s*\((\d+)\)$/);
if (match == null) return { title: str, maxItems: 0 };
if (match == null) {
return { title: str, maxItems: 0, query };
}

return { title: match[1], maxItems: Number(match[2]) };
return { title: match[1], maxItems: Number(match[2]), query };
}
Loading