Skip to content

Commit

Permalink
feat: Add Auto Suggest for Task dependencies (obsidian-tasks-group#2771)
Browse files Browse the repository at this point in the history
* WIP

* WIP: Suggests Dependent Tasks

* WIP: Suggests Dependent Tasks

* Suggests Dependent Tasks

* Remove Missed TODO

* Reset Sample Vault Files

* Refractor Blocked By too Depends On

* Remove Already Added IDs from Suggestion

* Remove Whitespace Problem

* Fix: Wrong Suggestion if Cursor before Symbol

* Update Docs

* add Unique ID Suggestion

* Fix: "Has Been Modifed Externally Error"

* refactor: Move DependencyHelpers.ts back to src/ui

This keeps it consistent with the location of its tests,
and more importantly, will make the diffs easier to see when
the PR is accepted and squashed.

---------

Co-authored-by: Clare Macrae <[email protected]>
  • Loading branch information
Genei180 and claremacrae authored May 4, 2024
1 parent 9b5a867 commit 884b750
Show file tree
Hide file tree
Showing 15 changed files with 328 additions and 111 deletions.
6 changes: 2 additions & 4 deletions docs/Editing/Auto-Suggest.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ Similarly, you can type some fraction of the word `start` (of whatever length is
| 📅 due date | 📅 |
| 🛫 start date | 🛫 |
| ⏳ scheduled date ||
| 🆔 Task ID | 🆔 'Generated ID' |
| ⛔ Task depends on ID ||
| ⏫ high priority ||
| 🔼 medium priority | 🔼 |
| 🔽 low priority | 🔽 |
Expand Down Expand Up @@ -288,7 +290,3 @@ at least the specified number of characters to find a match.
How many suggestions should be shown when an auto-suggest menu pops up (including the "⏎" option).

The default is 6, and you can select any value from 3 to 12.

## Current Limitations

- The Auto-Suggest mechanism does not yet support [[task dependencies]]. We are tracking this in [issue #2681](https://github.com/obsidian-tasks-group/obsidian-tasks/issues/2681).
15 changes: 15 additions & 0 deletions docs/Getting Started/Task Dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,21 @@ In this scenario, testing with users can only occur after the initial draft is c
![Making the 'Test with users' task depend on the 'Build a first draft'](../images/task-dependencies-blocked-by-example.png)
<span class="caption">Making the **'Test with users'** task depend on the **'Build a first draft'** task.</span>

**Via the Auto Suggest Feature:**

1. (Optional) On the 'Build a frist draft' You can select the ID symbol via the Automatic suggestion dialog box. An ID can be generated automatically via Auto Suggestions. Or you can Specify your own.
2. select the "Task depends on ID" icon in the "Test with users" task via the Automatic suggestion dialog box.
3. start entering the task on which it should depend ("Create a first draft")
4. Press the enter key. If no ID has been entered, an ID is generated for the task.

> [!info]
> When selecting Task depends on ID:
>
> - To depend on multiple tasks, type a comma after the last id in an existing 'depends on' value, and select another task.
> - it only searche/shows descriptions of not-done tasks.
> - It initially filters to show tasks in the same file ('closer' to the Task), but if you type more text, it will search all non-done tasks in the vault.
> - Circular Dependecy, should not be created
By implementing either of these methods, the task list is updated to reflect the dependency relationship:

```text
Expand Down
4 changes: 0 additions & 4 deletions docs/Support and Help/Known Limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,6 @@ This page gathers together all the documentation on known limitations of the plu

![[Create or edit Task#Known limitations]]

## Editing Tasks: Auto-Suggest

![[Auto-Suggest#Current Limitations]]

## Editing Tasks: Postponing

![[Postponing#Current Limitations]]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"theme": "moonstone",
"theme": "system",
"enabledCssSnippets": [
"tasks-code-block-blue-border"
],
Expand Down
2 changes: 1 addition & 1 deletion src/Commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export class Commands {
icon: 'pencil',
editorCheckCallback: (checking: boolean, editor: Editor, view: MarkdownView | MarkdownFileInfo) => {
// TODO Need to explore what happens if a tasks code block is rendered before the Cache has been created.
return createOrEdit(checking, editor, view as View, this.app, this.plugin.getTasks()!);
return createOrEdit(checking, editor, view as View, this.app, this.plugin.getTasks());
},
});

Expand Down
48 changes: 45 additions & 3 deletions src/Suggestor/EditorSuggestorPopup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { App, Editor, EditorSuggest, TFile } from 'obsidian';
import type { EditorPosition, EditorSuggestContext, EditorSuggestTriggerInfo } from 'obsidian';
import type TasksPlugin from 'main';
import { ensureTaskHasId } from 'Task/TaskDependency';
import { replaceTaskWithTasks } from 'Obsidian/File';
import { type Settings, getUserSelectedTaskFormat } from '../Config/Settings';
import { canSuggestForLine } from './Suggestor';
import type { SuggestInfo } from '.';
Expand All @@ -10,10 +13,12 @@ export type SuggestInfoWithContext = SuggestInfo & {

export class EditorSuggestor extends EditorSuggest<SuggestInfoWithContext> {
private settings: Settings;
private plugin: TasksPlugin;

constructor(app: App, settings: Settings) {
constructor(app: App, settings: Settings, plugin: TasksPlugin) {
super(app);
this.settings = settings;
this.plugin = plugin;

// EditorSuggestor swallows tabs while the suggestor popup is open
// This is a hack to support indenting while popup is open
Expand Down Expand Up @@ -48,9 +53,20 @@ export class EditorSuggestor extends EditorSuggest<SuggestInfoWithContext> {
getSuggestions(context: EditorSuggestContext): SuggestInfoWithContext[] {
const line = context.query;
const currentCursor = context.editor.getCursor();
const allTasks = this.plugin.getTasks();

const taskToSuggestFor = allTasks.find(
(task) => task.taskLocation.path == context.file.path && task.taskLocation.lineNumber == currentCursor.line,
);

const suggestions: SuggestInfo[] =
getUserSelectedTaskFormat().buildSuggestions?.(line, currentCursor.ch, this.settings) ?? [];
getUserSelectedTaskFormat().buildSuggestions?.(
line,
currentCursor.ch,
this.settings,
allTasks,
taskToSuggestFor,
) ?? [];

// Add the editor context to all the suggestions
return suggestions.map((s) => ({ ...s, context }));
Expand All @@ -60,7 +76,7 @@ export class EditorSuggestor extends EditorSuggest<SuggestInfoWithContext> {
el.setText(value.displayText);
}

selectSuggestion(value: SuggestInfoWithContext, _evt: MouseEvent | KeyboardEvent) {
async selectSuggestion(value: SuggestInfoWithContext, _evt: MouseEvent | KeyboardEvent) {
const editor = value.context.editor;

if (value.suggestionType === 'empty') {
Expand All @@ -73,6 +89,32 @@ export class EditorSuggestor extends EditorSuggest<SuggestInfoWithContext> {
(editor as any)?.cm?.contentDOM?.dispatchEvent(eventClone);
return;
}

if (value.taskItDependsOn != null) {
const newTask = ensureTaskHasId(
value.taskItDependsOn,
this.plugin.getTasks().map((task) => task.id),
);
value.appendText += ` ${newTask.id}`;

if (value.context.file.basename == newTask.filename) {
// Avoid "Has Been Modifed Externally Error" and Replace Task in Editor Context
console.log(value.taskItDependsOn.toFileLineString());
const start = {
line: value.taskItDependsOn.lineNumber,
ch: 0,
};
const end = {
line: value.taskItDependsOn.lineNumber,
ch: value.taskItDependsOn.toFileLineString().length,
};
value.context.editor.replaceRange(newTask.toFileLineString(), start, end);
} else {
// Replace Task in File Context
replaceTaskWithTasks({ originalTask: value.taskItDependsOn, newTasks: newTask });
}
}

const currentCursor = value.context.editor.getCursor();
const replaceFrom = {
line: currentCursor.line,
Expand Down
123 changes: 112 additions & 11 deletions src/Suggestor/Suggestor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import { doAutocomplete } from '../lib/DateAbbreviations';
import { Recurrence } from '../Task/Recurrence';
import type { DefaultTaskSerializerSymbols } from '../TaskSerializer/DefaultTaskSerializer';
import { Task } from '../Task/Task';
import { generateUniqueId } from '../Task/TaskDependency';
import { GlobalFilter } from '../Config/GlobalFilter';
import { TaskRegularExpressions } from '../Task/TaskRegularExpressions';
import { searchForCandidateTasksForDependency } from '../ui/DependencyHelpers';
import type { SuggestInfo, SuggestionBuilder } from '.';

/**
Expand All @@ -24,20 +26,34 @@ export function makeDefaultSuggestionBuilder(
/*
* Return a list of suggestions, either generic or more fine-grained to the words at the cursor.
*/
return (line: string, cursorPos: number, settings: Settings): SuggestInfo[] => {
return (
line: string,
cursorPos: number,
settings: Settings,
allTasks: Task[],
taskToSuggestFor?: Task,
): SuggestInfo[] => {
let suggestions: SuggestInfo[] = [];

// Step 1: add date suggestions if relevant
// add date suggestions if relevant
suggestions = suggestions.concat(
addDatesSuggestions(line, cursorPos, settings, datePrefixRegex, maxGenericSuggestions, dataviewMode),
);

// Step 2: add recurrence suggestions if relevant
// add recurrence suggestions if relevant
suggestions = suggestions.concat(
addRecurrenceSuggestions(line, cursorPos, settings, symbols.recurrenceSymbol, dataviewMode),
);

// Step 3: add task property suggestions ('due', 'recurrence' etc)
// add Auto ID suggestions
suggestions = suggestions.concat(addIDSuggestion(line, cursorPos, symbols.idSymbol, allTasks));

// add dependecy suggestions
suggestions = suggestions.concat(
addDependsOnSuggestions(line, cursorPos, settings, symbols.dependsOnSymbol, allTasks, taskToSuggestFor),
);

// add task property suggestions ('due', 'recurrence' etc)
suggestions = suggestions.concat(addTaskPropertySuggestions(line, cursorPos, settings, symbols, dataviewMode));

// Unless we have a suggestion that is a match for something the user is currently typing, add
Expand Down Expand Up @@ -107,6 +123,19 @@ function addTaskPropertySuggestions(
displayText: `${symbols.scheduledDateSymbol} scheduled date`,
appendText: `${symbols.scheduledDateSymbol} `,
});

if (!line.includes(symbols.idSymbol))
genericSuggestions.push({
displayText: `${symbols.idSymbol} Task ID`,
appendText: `${symbols.idSymbol}`,
});

if (!line.includes(symbols.dependsOnSymbol))
genericSuggestions.push({
displayText: `${symbols.dependsOnSymbol} Task depends on ID`,
appendText: `${symbols.dependsOnSymbol}`,
});

if (!hasPriority(line)) {
const prioritySymbols: { [key: string]: string } = symbols.prioritySymbols;
const priorityTexts = ['High', 'Medium', 'Low', 'Highest', 'Lowest'];
Expand Down Expand Up @@ -146,7 +175,7 @@ function addTaskPropertySuggestions(
// something to match, we filter the suggestions accordingly, so the user can get more specific
// results according to what she's typing.
// If there's no good match, present the suggestions as they are
const wordMatch = matchByPosition(line, /([a-zA-Z'_-]*)/g, cursorPos);
const wordMatch = matchIfCursorInRegex(line, /([a-zA-Z'_-]*)/g, cursorPos);
const matchingSuggestions: SuggestInfo[] = [];
if (wordMatch && wordMatch.length > 0) {
const wordUnderCursor = wordMatch[0];
Expand Down Expand Up @@ -213,7 +242,7 @@ function addDatesSuggestions(

const results: SuggestInfo[] = [];
const dateRegex = new RegExp(`(${datePrefixRegex})\\s*([0-9a-zA-Z ]*)`, 'ug');
const dateMatch = matchByPosition(line, dateRegex, cursorPos);
const dateMatch = matchIfCursorInRegex(line, dateRegex, cursorPos);
if (dateMatch && dateMatch.length >= 2) {
const datePrefix = dateMatch[1];
const dateString = dateMatch[2];
Expand Down Expand Up @@ -307,7 +336,7 @@ function addRecurrenceSuggestions(

const results: SuggestInfo[] = [];
const recurrenceRegex = new RegExp(`(${recurrenceSymbol})\\s*([0-9a-zA-Z ]*)`, 'ug');
const recurrenceMatch = matchByPosition(line, recurrenceRegex, cursorPos);
const recurrenceMatch = matchIfCursorInRegex(line, recurrenceRegex, cursorPos);
if (recurrenceMatch && recurrenceMatch.length >= 2) {
const recurrencePrefix = recurrenceMatch[1];
const recurrenceString = recurrenceMatch[2];
Expand Down Expand Up @@ -378,14 +407,86 @@ function addRecurrenceSuggestions(
return results;
}

function addIDSuggestion(line: string, cursorPos: number, idSymbol: string, allTasks: Task[]) {
const results: SuggestInfo[] = [];
const idRegex = new RegExp(`(${idSymbol})\\s*([0-9a-zA-Z ]*)`, 'ug');
const idMatch = matchIfCursorInRegex(line, idRegex, cursorPos);

if (idMatch && idMatch[0].trim().length <= idSymbol.length) {
const ID = generateUniqueId(allTasks.map((task) => task.id));
results.push({
suggestionType: 'match',
displayText: 'Auto Generate Unique ID',
appendText: `${idSymbol} ${ID}`,
insertAt: idMatch.index,
insertSkip: idSymbol.length,
});
}

return results;
}

/*
* If the cursor is located in a section that is followed by a Depends On Symbol, suggest options
* for what to enter as Depend on Option.
* It should contain suggestion of Possible Dependant Tasks
* of what the user is typing.
*/
function addDependsOnSuggestions(
line: string,
cursorPos: number,
settings: Settings,
dependsOnSymbol: string,
allTasks: Task[],
taskToSuggestFor?: Task,
) {
const results: SuggestInfo[] = [];

const dependsOnRegex = new RegExp(`(${dependsOnSymbol})([0-9a-zA-Z ^,]*,)*([0-9a-zA-Z ^,]*)`, 'ug');
const dependsOnMatch = matchIfCursorInRegex(line, dependsOnRegex, cursorPos);
if (dependsOnMatch && dependsOnMatch.length >= 1) {
// dependsOnMatch[1] = Depends On Symbol
const existingDependsOnIdStrings = dependsOnMatch[2] || '';
const newTaskToAppend = dependsOnMatch[3];

// Find all Tasks, Already Added
let blockingTasks = [] as Task[];
if (existingDependsOnIdStrings) {
blockingTasks = allTasks.filter((task) => task.id && existingDependsOnIdStrings.contains(task.id));
}

if (newTaskToAppend.length >= settings.autoSuggestMinMatch) {
const genericMatches = searchForCandidateTasksForDependency(
newTaskToAppend.trim(),
allTasks,
taskToSuggestFor,
[] as Task[],
blockingTasks,
);

for (const task of genericMatches) {
results.push({
suggestionType: 'match',
displayText: `${task.descriptionWithoutTags} - From: ${task.filename}.md`,
appendText: `${dependsOnSymbol}${existingDependsOnIdStrings}`,
insertAt: dependsOnMatch.index,
insertSkip: dependsOnSymbol.length + existingDependsOnIdStrings.length + newTaskToAppend.length,
taskItDependsOn: task,
});
}
}
}
return results;
}

/**
* Matches a string with a regex according to a position (typically of a cursor).
* Will return a result only if a match exists and the given position is part of it.
*/
export function matchByPosition(s: string, r: RegExp, position: number): RegExpMatchArray | void {
export function matchIfCursorInRegex(s: string, r: RegExp, position: number): RegExpMatchArray | void {
const matches = s.matchAll(r);
for (const match of matches) {
if (match?.index && match.index <= position && position <= match.index + match[0].length) return match;
if (match?.index && match.index < position && position <= match.index + match[0].length) return match;
}
}

Expand Down Expand Up @@ -483,11 +584,11 @@ export function lastOpenBracket(
* * {@link fn}`(line, cursorPos, settings)` otherwise
*/
export function onlySuggestIfBracketOpen(fn: SuggestionBuilder, brackets: [string, string][]): SuggestionBuilder {
return (line, cursorPos, settings): SuggestInfo[] => {
return (line, cursorPos, settings, taskToSuggestFor, allTasks): SuggestInfo[] => {
if (!isAnyBracketOpen(line.slice(0, cursorPos), brackets)) {
return [];
}
return fn(line, cursorPos, settings);
return fn(line, cursorPos, settings, taskToSuggestFor, allTasks);
};
}

Expand Down
11 changes: 10 additions & 1 deletion src/Suggestor/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { Task } from 'Task/Task';
import type { Settings } from '../Config/Settings';

/*
Expand All @@ -15,9 +16,17 @@ export type SuggestInfo = {
insertSkip?: number;
/** Text to match with the user input if matching against the display text is not desirable */
textToMatch?: string;
/** Task which needs too be Updated on Select */
taskItDependsOn?: Task;
};

/*
* Return a list of suggestions, either generic or more fine-grained to the words at the cursor.
*/
export type SuggestionBuilder = (line: string, cursorPos: number, settings: Settings) => SuggestInfo[];
export type SuggestionBuilder = (
line: string,
cursorPos: number,
settings: Settings,
allTasks: Task[],
taskToSuggestFor?: Task,
) => SuggestInfo[];
Loading

0 comments on commit 884b750

Please sign in to comment.