Repository: obsidian-tasks-group/obsidian-tasks
Pull request #2664 (2024-02-17 15:04:05)

fix: Improve auto-suggest for dataview task format
<!--- Provide a general summary of your changes in the Title above -->

# Description
I have optimized the function of auto-suggest in dataview task format, in dataview task format
1. removed the extra space when accepting the suggestion.
2. modified the effect of accepting the 'Enter' suggestion, now it will make the cursor jump out of the current task property.
3. fixed the duplicate words in the priority suggestion.

<!--- Describe your changes in detail -->

## Motivation and Context

<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
1. when I accept the day suggestion, it will add a extra space after date, see gif below
![iShot_2024-02-17_22 31 55](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/28787183/b7d5fedf-25c4-4775-af91-64a6cc9ddd3b)

2. when I accept the 'Enter' suggestion, it will break my current task property and split the `]` to next line. see gif below
![iShot_2024-02-17_22 34 59](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/28787183/e6ab01fd-99c1-4343-a8ac-ed0ee2d76a4c)

3. below is duplicate words bug in the priority suggestion when in dataview task format
<img width="793" alt="image" src="https://github.com/obsidian-tasks-group/obsidian-tasks/assets/28787183/3d846eb4-bbd4-4d6c-abd1-8b6a9d678a20">

4. My pull request solve the 3 issue above, which is shown below:
![iShot_2024-02-17_22 46 46](https://github.com/obsidian-tasks-group/obsidian-tasks/assets/28787183/e3f7010a-6c95-4e78-ac85-f5d9fd6d2e6a)

## How has this been tested?
For I only modify the auto-suggest action, I just test all type of suggestions, include priority, created date, due, schedule, repeat ...

<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment, tests ran to see how -->
<!--- your change affects other areas of the code, etc. -->

## Screenshots (if appropriate)

## Types of changes

<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->

Changes visible to users:

- [x] **Bug fix** (prefix: `fix` - non-breaking change which fixes an issue)
- [ ] **New feature** (prefix: `feat` - non-breaking change which adds functionality)
- [ ] **Breaking change** (prefix: `feat!!` or `fix!!` - fix or feature that would cause existing functionality to not work as expected)
- [ ] **Documentation** (prefix: `docs` - improvements to any documentation content **for users**)
- [ ] **Sample vault** (prefix: `vault` - improvements to the [Tasks-Demo sample vault](https://github.com/obsidian-tasks-group/obsidian-tasks/tree/main/resources/sample_vaults/Tasks-Demo))
- [ ] **Contributing Guidelines** (prefix: `contrib` - any improvements to documentation content **for contributors** - see [Contributing to Tasks](https://publish.obsidian.md/tasks-contributing/))

Internal changes:

- [ ] **Refactor** (prefix: `refactor` - non-breaking change which only improves the design or structure of existing code, and making no changes to its external behaviour)
- [ ] **Tests** (prefix: `test` - additions and improvements to unit tests and the smoke tests)
- [ ] **Infrastructure** (prefix: `chore` - examples include GitHub Actions, issue templates)

## Checklist

<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
<!--- If you're unsure about any of these, don't hesitate to ask. We're here to help! -->

- [x] My code follows the code style of this project and passes `yarn run lint`.
- [ ] My change requires a change to the documentation.
- [ ] I have [updated the documentation](https://publish.obsidian.md/tasks-contributing/Documentation/About+Documentation) accordingly.
- [x] My change has adequate [Unit Test coverage](https://publish.obsidian.md/tasks-contributing/Testing/About+Testing).

## Terms

<!--
By submitting this pull request, you must agree to follow our
[contributing guide](https://publish.obsidian.md/tasks-contributing) and
[Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md).
Put an x in the boxes to confirm you agree.
-->

- [x] My contribution follow this project's [contributing guide](https://publish.obsidian.md/tasks-contributing)
- [x] I agree to follow this project's [Code of Conduct](https://github.com/obsidian-tasks-group/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md)



Patch:
diff --git a/docs/Editing/Auto-Suggest.md b/docs/Editing/Auto-Suggest.md
index b56c90fb..a8944d0f 100644
--- a/docs/Editing/Auto-Suggest.md
+++ b/docs/Editing/Auto-Suggest.md
@@ -50,7 +50,7 @@ Here is a more detailed walk through of the creation of a new task, which can be
     - use the up/down keyboard keys and then type `Return` or `Enter`
     - type a few more characters, to make the menu items more specific. For example, type `pri` to show all the options for setting the task's priority.
 
-    **Note**: When the `⏎` item is shown at the top of the menu, it is given as a default option to enter a new line instead of choosing a suggestion. It is only shown when there is no concrete match.
+    **Note**: When the `⏎` item is shown at the top of the menu, it is given as a default option to enter a new line instead of choosing a suggestion. It is only shown when there is no concrete match when using the [[Tasks Emoji Format]].
 
 3. Here we selected the 'high priority' item, and so now the menu is updated to show the next most likely items you might want to add. We are going to select 'recurring (repeat)' from this menu:
 
diff --git a/src/Suggestor/Suggestor.ts b/src/Suggestor/Suggestor.ts
index 25cce472..2def1e7b 100644
--- a/src/Suggestor/Suggestor.ts
+++ b/src/Suggestor/Suggestor.ts
@@ -42,11 +42,13 @@ export function makeDefaultSuggestionBuilder(
         // rather than insert a suggestion
         if (suggestions.length > 0 && !suggestions.some((value) => value.suggestionType === 'match')) {
             // No actual match, only default ones
-            suggestions.unshift({
-                suggestionType: 'empty',
-                displayText: '⏎',
-                appendText: '\n',
-            });
+            if (settings.taskFormat != 'dataview') {
+                suggestions.unshift({
+                    suggestionType: 'empty',
+                    displayText: '⏎',
+                    appendText: '\n',
+                });
+            }
         }
 
         // Either way, after all the aggregations above, never suggest more than the max items
@@ -69,6 +71,16 @@ function addTaskPropertySuggestions(
         Object.values(symbols.prioritySymbols).some((value) => value.length > 0 && line.includes(value));
 
     const genericSuggestions: SuggestInfo[] = [];
+    const dataviewMode = _settings.taskFormat === 'dataview';
+    const close_bracket =
+        lastOpenBracket(line.substring(0, cursorPos), [
+            ['(', ')'],
+            ['[', ']'],
+        ]) == '('
+            ? ')'
+            : ']';
+    const postfix = dataviewMode ? close_bracket + ' ' : ' ';
+    const insertSkip = dataviewMode && line.length > cursorPos && line.charAt(cursorPos) === close_bracket ? 1 : 0;
 
     // NEW_TASK_FIELD_EDIT_REQUIRED
     if (!line.includes(symbols.dueDateSymbol))
@@ -87,27 +99,24 @@ function addTaskPropertySuggestions(
             appendText: `${symbols.scheduledDateSymbol} `,
         });
     if (!hasPriority(line)) {
-        genericSuggestions.push({
-            displayText: `${symbols.prioritySymbols.High} high priority`,
-            appendText: `${symbols.prioritySymbols.High} `,
-        });
-        genericSuggestions.push({
-            displayText: `${symbols.prioritySymbols.Medium} medium priority`,
-            appendText: `${symbols.prioritySymbols.Medium} `,
-        });
-        genericSuggestions.push({
-            displayText: `${symbols.prioritySymbols.Low} low priority`,
-            appendText: `${symbols.prioritySymbols.Low} `,
-        });
-        genericSuggestions.push({
-            displayText: `${symbols.prioritySymbols.Highest} highest priority`,
-            appendText: `${symbols.prioritySymbols.Highest} `,
-        });
-        genericSuggestions.push({
-            displayText: `${symbols.prioritySymbols.Lowest} lowest priority`,
-            appendText: `${symbols.prioritySymbols.Lowest} `,
-        });
+        const prioritySymbols: { [key: string]: string } = symbols.prioritySymbols;
+        const priorityTexts = ['High', 'Medium', 'Low', 'Highest', 'Lowest'];
+
+        for (let i = 0; i < priorityTexts.length; i++) {
+            const priorityText = priorityTexts[i];
+            const prioritySymbol = prioritySymbols[priorityText];
+
+            genericSuggestions.push({
+                displayText:
+                    _settings.taskFormat == 'dataview'
+                        ? `${prioritySymbol} priority`
+                        : `${prioritySymbol} ${priorityText.toLowerCase()} priority`,
+                appendText: `${prioritySymbol}${postfix}`,
+                insertSkip: dataviewMode ? insertSkip : undefined,
+            });
+        }
     }
+
     if (!line.includes(symbols.recurrenceSymbol))
         genericSuggestions.push({
             displayText: `${symbols.recurrenceSymbol} recurring (repeat)`,
@@ -120,7 +129,8 @@ function addTaskPropertySuggestions(
             // We don't want this to match when the user types "today"
             textToMatch: `${symbols.createdDateSymbol} created`,
             displayText: `${symbols.createdDateSymbol} created today (${formattedDate})`,
-            appendText: `${symbols.createdDateSymbol} ${formattedDate} `,
+            appendText: `${symbols.createdDateSymbol} ${formattedDate}` + postfix,
+            insertSkip: dataviewMode ? insertSkip : undefined,
         });
     }
 
@@ -138,12 +148,17 @@ function addTaskPropertySuggestions(
                 return textToMatch.toLowerCase().includes(wordUnderCursor.toLowerCase());
             });
             for (const filtered of filteredSuggestions) {
+                const insertSkipValue =
+                    dataviewMode &&
+                    (filtered.displayText.includes('priority') || filtered.displayText.includes('created'))
+                        ? wordUnderCursor.length + insertSkip
+                        : wordUnderCursor.length;
                 matchingSuggestions.push({
                     suggestionType: 'match',
                     displayText: filtered.displayText,
                     appendText: filtered.appendText,
                     insertAt: wordMatch.index,
-                    insertSkip: wordUnderCursor.length,
+                    insertSkip: insertSkipValue,
                 });
             }
         }
@@ -185,6 +200,17 @@ function addDatesSuggestions(
         'next year',
     ];
 
+    const dataviewMode = settings.taskFormat === 'dataview';
+    const close_bracket =
+        lastOpenBracket(line.substring(0, cursorPos), [
+            ['(', ')'],
+            ['[', ']'],
+        ]) == '('
+            ? ')'
+            : ']';
+    const postfix = dataviewMode ? close_bracket + ' ' : ' ';
+    const insertSkip = dataviewMode && line.length > cursorPos && line.charAt(cursorPos) === close_bracket ? 1 : 0;
+
     const results: SuggestInfo[] = [];
     const dateRegex = new RegExp(`(${datePrefixRegex})\\s*([0-9a-zA-Z ]*)`, 'ug');
     const dateMatch = matchByPosition(line, dateRegex, cursorPos);
@@ -233,12 +259,13 @@ function addDatesSuggestions(
         for (const match of genericMatches) {
             const parsedDate = DateParser.parseDate(match, true);
             const formattedDate = `${parsedDate.format(TaskRegularExpressions.dateFormat)}`;
+            const insertSkipValue = dataviewMode ? dateMatch[0].length + insertSkip : dateMatch[0].length;
             results.push({
                 suggestionType: 'match',
                 displayText: `${match} (${formattedDate})`,
-                appendText: `${datePrefix} ${formattedDate} `,
+                appendText: `${datePrefix} ${formattedDate}` + postfix,
                 insertAt: dateMatch.index,
-                insertSkip: dateMatch[0].length,
+                insertSkip: insertSkipValue,
             });
         }
     }
@@ -269,6 +296,16 @@ function addRecurrenceSuggestions(line: string, cursorPos: number, settings: Set
         'every week on Friday',
         'every week on Saturday',
     ];
+    const dataviewMode = settings.taskFormat === 'dataview';
+    const close_bracket =
+        lastOpenBracket(line.substring(0, cursorPos), [
+            ['(', ')'],
+            ['[', ']'],
+        ]) == '('
+            ? ')'
+            : ']';
+    const postfix = dataviewMode ? close_bracket + ' ' : ' ';
+    const insertSkip = dataviewMode && line.length > cursorPos && line.charAt(cursorPos) === close_bracket ? 1 : 0;
 
     const results: SuggestInfo[] = [];
     const recurrenceRegex = new RegExp(`(${recurrenceSymbol})\\s*([0-9a-zA-Z ]*)`, 'ug');
@@ -287,13 +324,16 @@ function addRecurrenceSuggestions(line: string, cursorPos: number, settings: Set
                 dueDate: null,
             })?.toText();
             if (parsedRecurrence) {
-                const appendedText = `${recurrencePrefix} ${parsedRecurrence} `;
+                const appendedText = `${recurrencePrefix} ${parsedRecurrence}` + postfix;
+                const insertSkipValue = dataviewMode
+                    ? recurrenceMatch[0].length + insertSkip
+                    : recurrenceMatch[0].length;
                 results.push({
                     suggestionType: 'match',
                     displayText: `✅ ${parsedRecurrence}`,
                     appendText: appendedText,
                     insertAt: recurrenceMatch.index,
-                    insertSkip: recurrenceMatch[0].length,
+                    insertSkip: insertSkipValue,
                 });
                 // If the full match includes a complete valid suggestion *ending with space*,
                 // don't suggest anything. The user is trying to continue to type something that is likely
@@ -325,6 +365,7 @@ function addRecurrenceSuggestions(line: string, cursorPos: number, settings: Set
             // there *was* a text to match (because it means the user is actually typing something else)
             genericMatches = genericSuggestions.slice(0, maxGenericDateSuggestions);
         }
+
         for (const match of genericMatches) {
             results.push({
                 suggestionType: 'match',
@@ -386,6 +427,53 @@ function isAnyBracketOpen(line: string, brackets: [opening_bracket: string, clos
     return Object.values(numOpeningBrackets).some((n) => n > 0);
 }
 
+/**
+ * Checks whether _any_ of the bracket pairs in {@link brackets} is open at the end of the string {@link line}
+ * If there are any open brackets, returns the last one. Else, returns null.
+ *
+ * @example
+ *     lastOpenBracket("(hello world",   [['(', ')']]);             // '('
+ *     lastOpenBracket("[hello world",   [['[', ']']]);             // '['
+ *     lastOpenBracket("[hello world",   [['(', ')'], ['[', ']']]); // '['
+ *     lastOpenBracket("([hello world)", [['(', ')'], ['[', ']']])  // '['
+ *     lastOpenBracket("))))(",          [['(', ')']])              // '('
+ *     lastOpenBracket("(hello world)",  [['(', ')']]);             // null
+ *     lastOpenBracket("(hello world)",  []);                       // null
+ *
+ * @param line - The line of text to scan
+ * @param brackets - A listed of tuples that defines bracket pairs.
+ * @returns The last open bracket in line among the given bracket pairs. If no such bracket exists, return null.
+ */
+export function lastOpenBracket(
+    line: string,
+    brackets: [opening_bracket: string, closing_bracket: string][],
+): string | null {
+    if (brackets.length === 0) {
+        return null;
+    }
+    const numOpeningBrackets = Object.fromEntries(brackets.map(([open, _]) => [open, 0]));
+    const openingOf = Object.fromEntries(brackets.map(([open, close]) => [close, open]));
+    const openBracketsStack = [];
+    for (let i = 0; i < line.length; i++) {
+        const c = line[i];
+        if (c in numOpeningBrackets) {
+            numOpeningBrackets[c]++;
+            openBracketsStack.push({ bracket: c, idx: i });
+        } else if (c in openingOf) {
+            if (numOpeningBrackets[openingOf[c]] >= 1) {
+                for (let idx = openBracketsStack.length - 1; idx >= 0; idx--) {
+                    if (openBracketsStack[idx].bracket == openingOf[c]) {
+                        openBracketsStack.splice(idx, 1);
+                        break;
+                    }
+                }
+            }
+            numOpeningBrackets[openingOf[c]] = Math.max(0, numOpeningBrackets[openingOf[c]] - 1);
+        }
+    }
+    return openBracketsStack.length > 0 ? openBracketsStack[openBracketsStack.length - 1].bracket : null;
+}
+
 /**
  * Given a SuggestionBuilder {@link fn}, returns a new SuggestionBuilder with identical behavior to {@link fn} except
  *     it only returns suggestions if there is an open bracket at the given cursor position.


Test patch:
diff --git a/tests/Suggestor/Suggestor.test.ts b/tests/Suggestor/Suggestor.test.ts
index 5027e1ca..b01dacfd 100644
--- a/tests/Suggestor/Suggestor.test.ts
+++ b/tests/Suggestor/Suggestor.test.ts
@@ -7,6 +7,7 @@ import { getSettings } from '../../src/Config/Settings';
 import type { SuggestInfo, SuggestionBuilder } from '../../src/Suggestor';
 import {
     canSuggestForLine,
+    lastOpenBracket,
     makeDefaultSuggestionBuilder,
     onlySuggestIfBracketOpen,
 } from '../../src/Suggestor/Suggestor';
@@ -283,3 +284,39 @@ describe('canSuggestForLine', () => {
         expect(canSuggestForLineWithCursor('    - [ ] |')).toEqual(true);
     });
 });
+
+describe('lastOpenBracket', () => {
+    it('should return null if there are no open brackets', () => {
+        expect(lastOpenBracket('hello world', [])).toEqual(null);
+        expect(
+            lastOpenBracket('hello world', [
+                ['(', ')'],
+                ['[', ']'],
+            ]),
+        ).toEqual(null);
+        expect(
+            lastOpenBracket('(hello world) [Hello world]', [
+                ['(', ')'],
+                ['[', ']'],
+            ]),
+        ).toEqual(null);
+        expect(
+            lastOpenBracket('([hello world)]', [
+                ['(', ')'],
+                ['[', ']'],
+            ]),
+        ).toEqual(null);
+    });
+
+    it('should return the last open bracket', () => {
+        expect(lastOpenBracket('(hello world', [['(', ')']])).toEqual('(');
+        expect(lastOpenBracket('[hello world', [['[', ']']])).toEqual('[');
+        expect(
+            lastOpenBracket('([hello world)', [
+                ['(', ')'],
+                ['[', ']'],
+            ]),
+        ).toEqual('[');
+        expect(lastOpenBracket('))))(', [['(', ')']])).toEqual('(');
+    });
+});