From 808f7d3df4f1fbd2ee17b731dccf4428619d2cde Mon Sep 17 00:00:00 2001 From: Sytone Date: Sun, 22 May 2022 03:18:31 -0700 Subject: [PATCH 1/3] chore: add a pull request template (#663) --- .../pull_request_template.md | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE/pull_request_template.md diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000000..de21bfb22e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,31 @@ + + +## Description + + +## Motivation and Context + + + +## How has this been tested? + + + + +## Screenshots (if appropriate): + +## Types of changes + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) + +## Checklist: + + +- [ ] 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 accordingly. +- [ ] My change has adequate Unit Test coverage. + +By creating a Pull Request you agree to our [Code of Conduct](https://github.com/schemar/obsidian-tasks/blob/main/CODE_OF_CONDUCT.md). For further guidance on contributing please see [contributing guide](https://github.com/schemar/obsidian-tasks/blob/main/CONTRIBUTING.md) From 8b5bef288c48ce4b13ec0e3893efac24f8a9b46e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 May 2022 03:44:47 +0000 Subject: [PATCH 2/3] Bump nokogiri from 1.13.4 to 1.13.6 in /docs Bumps [nokogiri](https://github.com/sparklemotion/nokogiri) from 1.13.4 to 1.13.6. - [Release notes](https://github.com/sparklemotion/nokogiri/releases) - [Changelog](https://github.com/sparklemotion/nokogiri/blob/main/CHANGELOG.md) - [Commits](https://github.com/sparklemotion/nokogiri/compare/v1.13.4...v1.13.6) --- updated-dependencies: - dependency-name: nokogiri dependency-type: indirect ... Signed-off-by: dependabot[bot] --- docs/Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock index 37c8dd38c2..3c189d6c5e 100644 --- a/docs/Gemfile.lock +++ b/docs/Gemfile.lock @@ -226,10 +226,10 @@ GEM jekyll-seo-tag (~> 2.1) minitest (5.14.4) multipart-post (2.1.1) - nokogiri (1.13.4) + nokogiri (1.13.6) mini_portile2 (~> 2.8.0) racc (~> 1.4) - nokogiri (1.13.4-x86_64-linux) + nokogiri (1.13.6-x86_64-linux) racc (~> 1.4) octokit (4.21.0) faraday (>= 0.9) From ff1216477242401139fee2031b28ebec9422334c Mon Sep 17 00:00:00 2001 From: Clare Macrae Date: Sun, 22 May 2022 19:41:45 +0100 Subject: [PATCH 3/3] Add 'group by' option, for various file properties and status (#644) * Add new 'group by' instruction, that allows grouping tasks by backlink, filename, folder, heading, path and status values. * Multiple 'group by' instructions may be used, to create nested groups. * Add 'tasks-group-heading' styling to group headings. * Add new file 'tests/TestHelpers.ts', containing some re-usable code to help with writing of tests. * Add new documentation page 'docs/queries/grouping.md'. * Add 'group by' example to 'docs/queries/examples.md'. * Add 'Maintaining the tests' section to CONTRIBUTING.md. --- CONTRIBUTING.md | 27 ++ docs/advanced/styling.md | 5 +- docs/queries/comments.md | 2 +- docs/queries/examples.md | 14 +- docs/queries/grouping.md | 110 ++++++++ docs/queries/layout.md | 2 +- docs/queries/limit.md | 2 +- resources/screenshots/tasks_grouped.png | Bin 0 -> 33580 bytes resources/screenshots/tasks_ungrouped.png | Bin 0 -> 29001 bytes src/Query.ts | 40 ++- src/Query/Group.ts | 124 +++++++++ src/Query/GroupHeading.ts | 26 ++ src/Query/GroupHeadings.ts | 117 +++++++++ src/Query/IntermediateTaskGroups.ts | 87 +++++++ src/Query/TaskGroup.ts | 94 +++++++ src/Query/TaskGroups.ts | 77 ++++++ src/QueryRenderer.ts | 64 ++++- tests/Group.test.ts | 297 ++++++++++++++++++++++ tests/Query.test.ts | 79 ++++++ tests/Sort.test.ts | 12 +- tests/TestHelpers.ts | 41 +++ 21 files changed, 1193 insertions(+), 27 deletions(-) create mode 100644 docs/queries/grouping.md create mode 100644 resources/screenshots/tasks_grouped.png create mode 100644 resources/screenshots/tasks_ungrouped.png create mode 100644 src/Query/Group.ts create mode 100644 src/Query/GroupHeading.ts create mode 100644 src/Query/GroupHeadings.ts create mode 100644 src/Query/IntermediateTaskGroups.ts create mode 100644 src/Query/TaskGroup.ts create mode 100644 src/Query/TaskGroups.ts create mode 100644 tests/Group.test.ts create mode 100644 tests/TestHelpers.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67d72dbcec..e35be7cee5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,6 +32,33 @@ Discussion will take place inside the PR. If you can, please add/update tests and documentation where appropriate. +## Maintaining the tests + +The tests use the [ts-jest](https://www.npmjs.com/package/ts-jest) wrapper around the +[jest](https://jestjs.io) test framework. + +The [Expect](https://jestjs.io/docs/expect) page is a good reference for the many jest testing features. + +### Snapshot Tests + +For testing more complex objects, some of the tests here use Jest's +[Snapshot Testing](https://jestjs.io/docs/snapshot-testing) facility, which is similar to +[Approval Tests](https://approvaltests.com) but easier to use in JavaScript. + +For readability of snapshots, we favour [Inline Snapshots](https://jestjs.io/docs/snapshot-testing#inline-snapshots), +which are saved in the source code. See that documentation for how to easily update the inline +snapshot, if the output is intended to be changed. + +### Jest and the WebStorm IDE + +The WebStorm IDE has a [helpful page](https://www.jetbrains.com/help/webstorm/running-unit-tests-on-jest.html) +on how it makes testing with jest easy. + +Note in particular the +[Snapshot testing section](https://www.jetbrains.com/help/webstorm/running-unit-tests-on-jest.html#ws_jest_snapshot_testing) +for how to view the diffs in the event of snapshot test failures, and also how to update the saved snapshot +of one or all snapshot failures. + ## Setting up build environment This project uses Node 14.x, if you need to use a different version, look at using `nvm` to manage your Node versions. If you are using `nvm`, you can install the 14.x version of Node with `nvm install 14.19.1; nvm use 14.19.1`. diff --git a/docs/advanced/styling.md b/docs/advanced/styling.md index ef49fe778c..cdb17eaf35 100644 --- a/docs/advanced/styling.md +++ b/docs/advanced/styling.md @@ -8,8 +8,8 @@ has_toc: false # Styling Tasks -Each task entry has CSS styles that allow you to change the look and feel of how the tasks are displayed. The -following styles are available. +Each task entry has CSS styles that allow you to change the look and feel of how the tasks are displayed. The +following styles are available. | Class | Usage | | ------------------------ | -------------------------------------------------------------------------------------------------------------- | @@ -18,4 +18,5 @@ following styles are available. | tasks-backlink | This is applied to the SPAN that wraps the backlink if displayed on the task. | | tasks-edit | This is applied to the SPAN that wraps the edit button/icon shown next to the task that opens the task edit UI.| | task-list-item-checkbox | This is applied to the INPUT element for the task. | +| tasks-group-heading | This is applied to H4, H5 and H6 group headings | diff --git a/docs/queries/comments.md b/docs/queries/comments.md index 4e1da57a02..914af3110f 100644 --- a/docs/queries/comments.md +++ b/docs/queries/comments.md @@ -1,7 +1,7 @@ --- layout: default title: Comments -nav_order: 5 +nav_order: 6 parent: Queries has_toc: false --- diff --git a/docs/queries/examples.md b/docs/queries/examples.md index 227a91ee82..82dc5a0065 100644 --- a/docs/queries/examples.md +++ b/docs/queries/examples.md @@ -1,7 +1,7 @@ --- layout: default title: Examples -nav_order: 6 +nav_order: 7 parent: Queries has_toc: false --- @@ -74,3 +74,15 @@ Show one task that is due on the 5th of May and includes `#prio1` in its descrip description includes #prio1 limit to 1 tasks ``` + +--- + +All open tasks that are due today or earlier, sorted by due date, then grouped together by the folder containing the task: + + ```tasks + not done + due before tomorrow + sort by due + group by folder + ``` + diff --git a/docs/queries/grouping.md b/docs/queries/grouping.md new file mode 100644 index 0000000000..e87ee65710 --- /dev/null +++ b/docs/queries/grouping.md @@ -0,0 +1,110 @@ +--- +layout: default +title: Grouping +nav_order: 3 +parent: Queries +--- + +# Grouping +{: .no_toc } + +
+ + Table of contents + + {: .text-delta } +1. TOC +{:toc} +
+ +--- + +## Basics + +By default, Tasks displays tasks in a single list. + +To divide the matching tasks up with headings, you can add `group by` lines to the query. + +### Available grouping properties + +You can group by the following properties: + +File locations: + +1. `path` (the path to the file that contains the task, that is, the folder and the filename) +1. `folder` (the folder to the file that contains the task, which will be `/` for files in root of the vault) +1. `filename` (the filename of the file that contains the task, without the `.md` extension) + * Note that tasks from different notes with the same file name will be grouped together in the same group. + +File contents: + +1. `backlink` (the text that would be shown in the task's backlink, combining the task's file name and heading, but with no link added) +1. `heading` (the heading preceding the task, or `(No heading)` if there are no headings in the file) + +Task properties: + +1. `status` (Done or Todo, which is capitalized for visibility in the headings) + * Note that the Done group is displayed before the Todo group, + which differs from the Sorting ordering of this property. + +### Multiple groups + +You can add multiple `group by` query options, each on an extra line. +This will create nested groups. +The first group has the highest priority. + +Each subsequent `group by` will generate a new heading-level within the existing grouping: + +- First `group by` is displayed as `h4` headings +- Second `group by` is displayed as `h5` headings +- Third and subsequent `group by` are displayed as `h6` headings + +See the [screenshots below](#screenshots) for how this looks in practice. + +
+Info +{: .label .label-blue } +Headings are displayed in case-sensitive alphabetical order, not the original order. + +--- + +Info +{: .label .label-blue } +The order of operations ensures that grouping does not modify which tasks are displayed, for example when the `limit` option is used: + +1. all the filter instructions are run +1. then any sorting instructions are run +1. then any `limit` instructions are run +1. then finally any grouping instructions are run + +
+ + +--- + +## Screenshots + +### Before + +Here is an example Tasks result, without any `group by` commands: + +![Tasks Ungrouped](https://github.com/schemar/obsidian-tasks/raw/main/resources/screenshots/tasks_ungrouped.png) +Tasks not grouped. + +### After + +And here is what this might look like, when grouped by folder, filename and heading: + +![Tasks Grouped](https://github.com/schemar/obsidian-tasks/raw/main/resources/screenshots/tasks_grouped.png) +Tasks grouped. + +--- + +## Examples + + ```tasks + not done + group by folder + group by filename + group by heading + ``` diff --git a/docs/queries/layout.md b/docs/queries/layout.md index d3bb5cfcd8..c8b0829d06 100644 --- a/docs/queries/layout.md +++ b/docs/queries/layout.md @@ -1,7 +1,7 @@ --- layout: default title: Layout -nav_order: 3 +nav_order: 4 parent: Queries --- diff --git a/docs/queries/limit.md b/docs/queries/limit.md index 94dd6d9654..def9d198db 100644 --- a/docs/queries/limit.md +++ b/docs/queries/limit.md @@ -1,7 +1,7 @@ --- layout: default title: Limiting -nav_order: 4 +nav_order: 5 parent: Queries has_toc: false --- diff --git a/resources/screenshots/tasks_grouped.png b/resources/screenshots/tasks_grouped.png new file mode 100644 index 0000000000000000000000000000000000000000..06ad8e48fabb9c1860c30a442c07ae6f2c420c4a GIT binary patch literal 33580 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 literal 0 HcmV?d00001 diff --git a/resources/screenshots/tasks_ungrouped.png b/resources/screenshots/tasks_ungrouped.png new file mode 100644 index 0000000000000000000000000000000000000000..bf7b89c625d77e7970e6ad8d0988703c4b4bbd85 GIT binary patch literal 29001 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 literal 0 HcmV?d00001 diff --git a/src/Query.ts b/src/Query.ts index d739630a55..82e6d36649 100644 --- a/src/Query.ts +++ b/src/Query.ts @@ -1,4 +1,6 @@ import * as chrono from 'chrono-node'; +import { Group } from './Query/Group'; +import type { TaskGroups } from './Query/TaskGroups'; import { getSettings } from './Settings'; import { LayoutOptions } from './LayoutOptions'; @@ -22,12 +24,22 @@ type Sorting = { propertyInstance: number; }; +export type GroupingProperty = + | 'backlink' + | 'filename' + | 'folder' + | 'heading' + | 'path' + | 'status'; +export type Grouping = { property: GroupingProperty }; + export class Query { private _limit: number | undefined = undefined; private _layoutOptions: LayoutOptions = new LayoutOptions(); private _filters: ((task: Task) => boolean)[] = []; private _error: string | undefined = undefined; private _sorting: Sorting[] = []; + private _grouping: Grouping[] = []; private readonly priorityRegexp = /^priority (is )?(above|below)? ?(low|none|medium|high)/; @@ -63,6 +75,9 @@ export class Query { private readonly sortByRegexp = /^sort by (urgency|status|priority|start|scheduled|due|done|path|description|tag)( reverse)?[\s]*(\d+)?/; + private readonly groupByRegexp = + /^group by (backlink|filename|folder|heading|path|status)/; + private readonly headingRegexp = /^heading (includes|does not include) (.*)/; @@ -166,6 +181,9 @@ export class Query { case this.sortByRegexp.test(line): this.parseSortBy({ line }); break; + case this.groupByRegexp.test(line): + this.parseGroupBy({ line }); + break; case this.hideOptionsRegexp.test(line): this.parseHideOptions({ line }); break; @@ -173,7 +191,7 @@ export class Query { // Comment lines are ignored break; default: - this._error = 'do not understand query'; + this._error = `do not understand query: ${line}`; } }); } @@ -194,16 +212,21 @@ export class Query { return this._sorting; } + public get grouping() { + return this._grouping; + } + public get error(): string | undefined { return this._error; } - public applyQueryToTasks(tasks: Task[]): Task[] { + public applyQueryToTasks(tasks: Task[]): TaskGroups { this.filters.forEach((filter) => { tasks = tasks.filter(filter); }); - return Sort.by(this, tasks).slice(0, this.limit); + const tasksSortedLimited = Sort.by(this, tasks).slice(0, this.limit); + return Group.by(this.grouping, tasksSortedLimited); } private parseHideOptions({ line }: { line: string }): void { @@ -602,6 +625,17 @@ export class Query { } } + private parseGroupBy({ line }: { line: string }): void { + const fieldMatch = line.match(this.groupByRegexp); + if (fieldMatch !== null) { + this._grouping.push({ + property: fieldMatch[1] as GroupingProperty, + }); + } else { + this._error = 'do not understand query grouping'; + } + } + private static parseDate(input: string): moment.Moment { // Using start of day to correctly match on comparison with other dates (like equality). return window.moment(chrono.parseDate(input)).startOf('day'); diff --git a/src/Query/Group.ts b/src/Query/Group.ts new file mode 100644 index 0000000000..32bd9efc59 --- /dev/null +++ b/src/Query/Group.ts @@ -0,0 +1,124 @@ +import type { Grouping, GroupingProperty } from '../Query'; +import type { Task } from '../Task'; +import { TaskGroups } from './TaskGroups'; + +/** + * A naming function, that takes a Task object and returns the corresponding group property name + */ +type Grouper = (task: Task) => string; + +/** + * Implementation of the 'group by' instruction. + */ +export class Group { + /** + * Group a list of tasks, according to one or more task properties + * @param grouping 0 or more Grouping values, one per 'group by' line + * @param tasks The tasks that match the task block's Query + */ + public static by(grouping: Grouping[], tasks: Task[]): TaskGroups { + return new TaskGroups(grouping, tasks); + } + + /** + * Return the Grouper functions matching the 'group by' lines + * @param grouping 0 or more Grouping values, one per 'group by' line + */ + public static getGroupersForAllQueryGroupings(grouping: Grouping[]) { + const groupers: Grouper[] = []; + for (const { property } of grouping) { + const comparator = Group.groupers[property]; + groupers.push(comparator); + } + return groupers; + } + + /** + * Return the group names for a single task + * @param groupers The Grouper functions indicating the requested types of group + * @param task + */ + public static getGroupNamesForTask(groupers: Grouper[], task: Task) { + const groupNames = []; + for (const grouper of groupers) { + const groupName = grouper(task); + groupNames.push(groupName); + } + return groupNames; + } + + /** + * Return a single property name for a single task. + * A convenience method for unit tests. + * @param property + * @param task + */ + public static getGroupNameForTask( + property: GroupingProperty, + task: Task, + ): string { + const grouper = Group.groupers[property]; + return grouper(task); + } + + private static groupers: Record = { + backlink: Group.groupByBacklink, + filename: Group.groupByFileName, + folder: Group.groupByFolder, + heading: Group.groupByHeading, + path: Group.groupByPath, + status: Group.groupByStatus, + }; + + private static groupByPath(task: Task): string { + // Does this need to be made stricter? + // Is there a better way of getting the file name? + return task.path.replace('.md', ''); + } + + private static groupByFolder(task: Task): string { + const path = task.path; + const fileNameWithExtension = task.filename + '.md'; + const folder = path.substring( + 0, + path.lastIndexOf(fileNameWithExtension), + ); + if (folder === '') { + return '/'; + } + return folder; + } + + private static groupByFileName(task: Task): string { + // Note current limitation: Tasks from different notes with the + // same name will be grouped together, even though they are in + // different files and their links will look different. + const filename = task.filename; + if (filename === null) { + return 'Unknown Location'; + } + return filename; + } + + private static groupByBacklink(task: Task): string { + const linkText = task.getLinkText({ isFilenameUnique: true }); + if (linkText === null) { + return 'Unknown Location'; + } + return linkText; + } + + private static groupByStatus(task: Task): string { + return task.status; + } + + private static groupByHeading(task: Task): string { + if ( + task.precedingHeader === null || + task.precedingHeader.length === 0 + ) { + return '(No heading)'; + } + return task.precedingHeader; + } +} diff --git a/src/Query/GroupHeading.ts b/src/Query/GroupHeading.ts new file mode 100644 index 0000000000..de8a31aebe --- /dev/null +++ b/src/Query/GroupHeading.ts @@ -0,0 +1,26 @@ +/** + * GroupHeading contains the data needed to render one heading for a group of tasks + */ +export class GroupHeading { + /** + * How nested the heading is. + * 0 is the first group, meaning this heading was generated by + * the first 'group by' instruction. + */ + public readonly nestingLevel: number; + + /** + * The text to be displayed for the group. + */ + public readonly name: string; + + /** + * Construct a GroupHeading object + * @param {number} nestingLevel - See this.nestingLevel for details + * @param {string} name - The text to be displayed for the group + */ + constructor(nestingLevel: number, name: string) { + this.nestingLevel = nestingLevel; + this.name = name; + } +} diff --git a/src/Query/GroupHeadings.ts b/src/Query/GroupHeadings.ts new file mode 100644 index 0000000000..0732d19417 --- /dev/null +++ b/src/Query/GroupHeadings.ts @@ -0,0 +1,117 @@ +import { GroupHeading } from './GroupHeading'; +import type { IntermediateTaskGroupsStorage } from './IntermediateTaskGroups'; + +/* + * This file contains implementation details of Group.ts + */ + +/** + * Explanation of the algorithms used here. + * + * The following text is taken from + * https://discord.com/channels/686053708261228577/840286264964022302/955240812973809674 + * + * The Problem + * =========== + * + * Imagine that the user has supplied 3 'group by' instructions, and in order + * to present the results, we simply concatenate the group names together + * with '>'. + * + * So the display might look something like: + * #### 10.0 > 2022-03-20 > Some heading name + * - task 1 + * - task 2 + * #### 10.0 > 2022-03-22 > Some heading name + * - task 7 + * - task 9 + * + * The headings get very hard to read, very quickly. + * + * What we want instead is: + * #### 10.0 + * ##### 2022-03-20 + * ###### Some heading name + * - task 1 + * - task 2 + * ##### 2022-03-22 + * ###### Some heading name + * - task 7 + * - task 9 + * + * I'm struggling to get my head around how, in TS, I can store something like a tree structure, + * of arbitrary depth - to represent the grouped tasks. + * + * pjeby's answer + * ============== + * + * User pjeby replied: + * https://discord.com/channels/686053708261228577/840286264964022302/955579560034983946 + * + * If all you're doing is generating headings, the simple algorithm would be to sort everything by a multi-value key - + * i.e., [level 1, level 2, ..., item sort key] -- then iterate the whole list and output a heading for each level + * where the value changed. + * + * i.e., you start with a [null, null, null, null....] "last seen" array and compare it item by item to the current + * item's data, and output a heading of the correct level if there's a change, updating the item in your + * "last seen" array. + * + * i.e. if the first item is different, output an H1 for the new value and set the rest of the array to null. + * If the second item is also different, output an H2, save the value, set the rest to null, and so on. + * After all the levels are checked, output the actual item. + * If there are no changes, then basically you'll just be outputting the item. + * No trees or graphs or whatnot needed. + * + * You could also just keep the last item and set a flag as soon as something doesn't match, and keep outputting + * headings as soon as the flag is set. + * + * What the code does + * ================== + * + * The IntermediateTaskGroups class below does the initial grouping and sorting. + * + * The GroupHeadings class below implements pjeby's heading detection algorithm, but instead of doing the printing directly, + * it returns the calculated heading levels in an array of GroupHeading objects, for later use in QueryRenderer.ts. + */ + +/** + * GroupHeadings calculates which headings need to be displayed, for + * a given group of tasks. + * + * See the explanation in GroupHeadings.ts for how it works. + */ +export class GroupHeadings { + private lastHeadingAtLevel = new Array(); + + constructor(groupedTasks: IntermediateTaskGroupsStorage) { + const firstGroup = groupedTasks.keys().next().value; + const groupCount = firstGroup.length; + for (let i = 0; i < groupCount; i++) { + this.lastHeadingAtLevel.push(''); + } + } + + /** + * Calculate the minimal set of headings that should be displayed + * before the tasks with the given group names. + * + * Data for each required heading is stored in a GroupHeading object. + * @param groupNames 0 or more group names, one per 'group by' line + */ + getHeadingsForTaskGroup(groupNames: string[]): GroupHeading[] { + // See 'pjeby's answer' above for an explanation of this algorithm. + const headingsForGroup = new Array(); + for (let level = 0; level < groupNames.length; level++) { + const group = groupNames[level]; + if (group != this.lastHeadingAtLevel[level]) { + headingsForGroup.push(new GroupHeading(level, group)); + // Reset all the lower heading levels to un-seen + for (let j = level; j < groupNames.length; j++) { + this.lastHeadingAtLevel[j] = ''; + } + this.lastHeadingAtLevel[level] = group; + } + } + return headingsForGroup; + } +} diff --git a/src/Query/IntermediateTaskGroups.ts b/src/Query/IntermediateTaskGroups.ts new file mode 100644 index 0000000000..de9b19ac7b --- /dev/null +++ b/src/Query/IntermediateTaskGroups.ts @@ -0,0 +1,87 @@ +import type { Grouping } from '../Query'; +import type { Task } from '../Task'; +import { Group } from './Group'; + +/** + * Storage used for the initial grouping together of tasks. + * + * The keys of the map are the names of the groups. + * For example, one set of keys might be ['Folder Name/', 'File Name'] + * and the values would be all the matching Tasks from that file. + */ +export class IntermediateTaskGroupsStorage extends Map {} + +/** + * IntermediateTaskGroups does the initial grouping together of tasks, + * in alphabetical order by group names. + * + * It is essentially a thin wrapper around Map - see IntermediateTaskGroupsStorage. + * + * It is named "Intermediate" because its results are only temporary. + * They will be discarded once the final TaskGroups object is created. + * + * Ideally, this code would be simplified and moved in to TaskGroups. + */ +export class IntermediateTaskGroups { + public groups = new IntermediateTaskGroupsStorage(); + + /** + * Group a list of tasks, according to one or more task properties + * @param grouping 0 or more Grouping values, one per 'group by' line + * @param tasks The tasks that match the task block's Query + */ + constructor(grouping: Grouping[], tasks: Task[]) { + if (grouping.length === 0 || tasks.length === 0) { + // There are no groups or no tasks: treat this as a single group, + // with an empty group name. + this.groups.set([], tasks); + } else { + const groupers = Group.getGroupersForAllQueryGroupings(grouping); + + for (const task of tasks) { + const groupNames = Group.getGroupNamesForTask(groupers, task); + this.addTask(groupNames, task); + } + this.groups = this.getSortedGroups(); + } + } + + private addTask(groupNames: string[], task: Task) { + const groupForNames = this.getOrCreateGroupForGroupNames(groupNames); + groupForNames?.push(task); + } + + /** + * If a task has been seen before with this exact combination of group names, + * return the container that the previous task(s) were added to. + * + * Otherwise, create and return a new container. + * @param newGroupNames + * @private + */ + private getOrCreateGroupForGroupNames(newGroupNames: string[]) { + for (const [groupNames, taskGroup] of this.groups) { + // Is there a better way to check if the contents of two string arrays + // are identical? + // Use of JSON feels inefficient, and is O(n-squared) on the number + // of unique group-name combinations, so may scale badly if + // very large numbers of tasks are displayed. + // Related: https://stackoverflow.com/questions/7837456/how-to-compare-arrays-in-javascript + if (JSON.stringify(groupNames) === JSON.stringify(newGroupNames)) { + return taskGroup; + } + } + const taskGroup: Task[] = []; + this.groups.set(newGroupNames, taskGroup); + return taskGroup; + } + + private getSortedGroups() { + // groups.keys() will initially be in the order the entries were added, + // so effectively random. + // Return a duplicate map, with the keys (that is, group names) sorted in alphabetical order: + return new IntermediateTaskGroupsStorage( + [...this.groups.entries()].sort(), + ); + } +} diff --git a/src/Query/TaskGroup.ts b/src/Query/TaskGroup.ts new file mode 100644 index 0000000000..9e153741ab --- /dev/null +++ b/src/Query/TaskGroup.ts @@ -0,0 +1,94 @@ +import type { Task } from '../Task'; +import type { GroupHeading } from './GroupHeading'; + +/** + * TaskGroup stores a single group of tasks, that all share the same group names. + * TaskGroup objects are stored in a TaskGroups object. + * + * For example, if the user supplied these 'group by' lines: + * group by folder + * group by filename + * group by heading + * Then the names of one TaskGroup might be this: + * Some/Folder/In/The/Vault + * A Particular File Name + * My lovely heading + * And the TaskGroup would store all the tasks from that location + * that match the task block's filters, in the task block's sort order + */ +export class TaskGroup { + /** + * The names of the group properties for this set of tasks, + * in the order of the 'group by' lines the user specified + */ + public readonly groups: string[]; + + /** + * The headings to be displayed in front of this set of tasks, + * when rendering the results. + * + * It only contains the minimal set of headings required to separate + * this group of tasks from the previous group of tasks. + * + * If there were no 'group by' instructions in the tasks code block, + * this will be empty. + */ + public readonly groupHeadings: GroupHeading[]; + + /** + * All the tasks that match the user's filters and that have the + * group names exactly matching groups(). + */ + public readonly tasks: Task[]; + + /** + * Constructor for TaskGroup + * @param {string[]} groups - See this.groups for details + * @param {GroupHeading[]} groupHeadings - See this.groupHeadings for details + * @param tasks {Task[]} - See this.tasks for details + */ + constructor( + groups: string[], + groupHeadings: GroupHeading[], + tasks: Task[], + ) { + this.groups = groups; + this.groupHeadings = groupHeadings; + this.tasks = tasks; + } + + /** + * A markdown-format representation of all the tasks in this group. + * + * Useful for testing. + */ + public tasksAsStringOfLines(): string { + let output = ''; + for (const task of this.tasks) { + output += task.toFileLineString() + '\n'; + } + return output; + } + + /** + * A human-readable representation of this task group, including names + * and headings that should be displayed. + * + * Note that this is used in snapshot testing, so if the format is + * changed, the snapshots will need to be updated. + */ + public toString(): string { + let output = '\n'; + output += `Group names: [${this.groups}]\n`; + + for (const heading of this.groupHeadings) { + // These headings mimic the behaviour of QueryRenderer, + // which uses 'h4', 'h5' and 'h6' for nested groups. + const headingPrefix = '#'.repeat(4 + heading.nestingLevel); + output += `${headingPrefix} ${heading.name}\n`; + } + + output += this.tasksAsStringOfLines(); + return output; + } +} diff --git a/src/Query/TaskGroups.ts b/src/Query/TaskGroups.ts new file mode 100644 index 0000000000..3920d46226 --- /dev/null +++ b/src/Query/TaskGroups.ts @@ -0,0 +1,77 @@ +import type { Grouping } from '../Query'; +import type { Task } from '../Task'; +import { GroupHeadings } from './GroupHeadings'; +import { IntermediateTaskGroups } from './IntermediateTaskGroups'; +import { TaskGroup } from './TaskGroup'; + +/** + * TaskGroup stores all the groups of tasks generated by any 'group by' + * instructions in the task block. + */ +export class TaskGroups { + private _groups: TaskGroup[] = new Array(); + + /** + * Constructor for TaskGroups + * @param {Grouping[]} groups - 0 or more Grouping values, + * 1 per 'group by' line in the task query block + * @param {Task[]} tasks - 0 more more Task objects, with all the tasks + * matching the query, already in sort order + */ + constructor(groups: Grouping[], tasks: Task[]) { + const initialGroups = new IntermediateTaskGroups(groups, tasks); + this.addTasks(initialGroups); + } + + /** + * All the tasks matching the query, grouped together, and in the order + * that they should be displayed. + */ + public get groups(): TaskGroup[] { + return this._groups; + } + + /** + * The total number of tasks matching the query. + */ + public totalTasksCount() { + let totalTasksCount = 0; + for (const group of this.groups) { + totalTasksCount += group.tasks.length; + } + return totalTasksCount; + } + + /** + * A human-readable representation of all the task groups. + * + * Note that this is used in snapshot testing, so if the format is + * changed, the snapshots will need to be updated. + */ + public toString(): string { + let output = ''; + for (const taskGroup of this.groups) { + output += taskGroup.toString(); + output += '\n---\n'; + } + const totalTasksCount = this.totalTasksCount(); + output += `\n${totalTasksCount} tasks\n`; + return output; + } + + private addTasks(initialGroups: IntermediateTaskGroups) { + // Get the headings + const grouper = new GroupHeadings(initialGroups.groups); + + // Build a container of all the groups + for (const [groups, tasks] of initialGroups.groups) { + const groupHeadings = grouper.getHeadingsForTaskGroup(groups); + const taskGroup = new TaskGroup(groups, groupHeadings, tasks); + this.add(taskGroup); + } + } + + private add(taskGroup: TaskGroup) { + this._groups.push(taskGroup); + } +} diff --git a/src/QueryRenderer.ts b/src/QueryRenderer.ts index be6cf544a3..d45711e225 100644 --- a/src/QueryRenderer.ts +++ b/src/QueryRenderer.ts @@ -10,6 +10,7 @@ import { import { State } from './Cache'; import { replaceTaskWithTasks } from './File'; import { Query } from './Query'; +import type { GroupHeading } from './Query/GroupHeading'; import { TaskModal } from './TaskModal'; import type { Events } from './Events'; import type { Task } from './Task'; @@ -120,13 +121,21 @@ class QueryRenderChild extends MarkdownRenderChild { private async render({ tasks, state }: { tasks: Task[]; state: State }) { const content = this.containerEl.createEl('div'); if (state === State.Warm && this.query.error === undefined) { - const tasksSortedLimited = this.query.applyQueryToTasks(tasks); - const { taskList, tasksCount } = await this.createTasksList({ - tasks: tasksSortedLimited, - content: content, - }); - content.appendChild(taskList); - this.addTaskCount(content, tasksCount); + const tasksSortedLimitedGrouped = + this.query.applyQueryToTasks(tasks); + for (const group of tasksSortedLimitedGrouped.groups) { + // If there were no 'group by' instructions, group.groupHeadings + // will be empty, and no headings will be added. + QueryRenderChild.addGroupHeadings(content, group.groupHeadings); + + const { taskList } = await this.createTasksList({ + tasks: group.tasks, + content: content, + }); + content.appendChild(taskList); + } + const totalTasksCount = tasksSortedLimitedGrouped.totalTasksCount(); + this.addTaskCount(content, totalTasksCount); } else if (this.query.error !== undefined) { content.setText(`Tasks query: ${this.query.error}`); } else { @@ -206,6 +215,47 @@ class QueryRenderChild extends MarkdownRenderChild { }); } + /** + * Display headings for a group of tasks. + * @param content + * @param groupHeadings - The headings to display. This can be an empty array, + * in which case no headings will be added. + * @private + */ + private static addGroupHeadings( + content: HTMLDivElement, + groupHeadings: GroupHeading[], + ) { + for (const heading of groupHeadings) { + QueryRenderChild.addGroupHeading(content, heading); + } + } + + private static addGroupHeading( + content: HTMLDivElement, + group: GroupHeading, + ) { + let header: any; + // Is it possible to remove the repetition here? + // Ideally, by creating a variable that contains h4, h5 or h6 + // and then only having one call to content.createEl(). + if (group.nestingLevel === 0) { + header = content.createEl('h4', { + cls: 'tasks-group-heading', + }); + } else if (group.nestingLevel === 1) { + header = content.createEl('h5', { + cls: 'tasks-group-heading', + }); + } else { + // Headings nested to 2 or more levels are all displayed with 'h6: + header = content.createEl('h6', { + cls: 'tasks-group-heading', + }); + } + header.appendText(group.name); + } + private addBacklinks( postInfo: HTMLSpanElement, task: Task, diff --git a/tests/Group.test.ts b/tests/Group.test.ts new file mode 100644 index 0000000000..73093f54f7 --- /dev/null +++ b/tests/Group.test.ts @@ -0,0 +1,297 @@ +/** + * @jest-environment jsdom + */ +import moment from 'moment'; +import { Group } from '../src/Query/Group'; +import type { Grouping, GroupingProperty } from '../src/Query'; +import type { Task } from '../src/Task'; +import { fromLine } from './TestHelpers'; + +window.moment = moment; + +function checkGroupNameOfTask( + task: Task, + property: GroupingProperty, + expectedGroupName: string, +) { + const group = Group.getGroupNameForTask(property, task); + expect(group).toEqual(expectedGroupName); +} + +describe('Grouping tasks', () => { + it('groups correctly by path', () => { + // Arrange + const a = fromLine({ line: '- [ ] a', path: 'file2.md' }); + const b = fromLine({ line: '- [ ] b', path: 'file1.md' }); + const c = fromLine({ line: '- [ ] c', path: 'file1.md' }); + const inputs = [a, b, c]; + + // Act + const groupBy: GroupingProperty = 'path'; + const grouping = [{ property: groupBy }]; + const groups = Group.by(grouping, inputs); + + // Assert + expect(groups.toString()).toMatchInlineSnapshot(` + " + Group names: [file1] + #### file1 + - [ ] b + - [ ] c + + --- + + Group names: [file2] + #### file2 + - [ ] a + + --- + + 3 tasks + " + `); + }); + + it('groups correctly by default grouping', () => { + // Arrange + const a = fromLine({ line: '- [ ] a 📅 1970-01-01', path: '2.md' }); + const b = fromLine({ line: '- [ ] b 📅 1970-01-02', path: '3.md' }); + const c = fromLine({ line: '- [ ] c 📅 1970-01-02', path: '3.md' }); + const inputs = [a, b, c]; + + // Act + const grouping: Grouping[] = []; + const groups = Group.by(grouping, inputs); + + // Assert + // No grouping specified, so no headings generated + expect(groups.toString()).toMatchInlineSnapshot(` + " + Group names: [] + - [ ] a 📅 1970-01-01 + - [ ] b 📅 1970-01-02 + - [ ] c 📅 1970-01-02 + + --- + + 3 tasks + " + `); + }); + + it('groups empty task list correctly', () => { + // Arrange + const inputs: Task[] = []; + const group_by: GroupingProperty = 'path'; + const grouping = [{ property: group_by }]; + + // Act + const groups = Group.by(grouping, inputs); + + // Assert + expect(groups.groups.length).toEqual(1); + expect(groups.groups[0].groups.length).toEqual(0); + expect(groups.groups[0].tasks.length).toEqual(0); + }); + + it('sorts group names correctly', () => { + const a = fromLine({ + line: '- [ ] third file path', + path: 'd/e/f.md', + }); + const b = fromLine({ + line: '- [ ] second file path', + path: 'b/c/d.md', + }); + const c = fromLine({ + line: '- [ ] first file path, alphabetically', + path: 'a/b/c.md', + }); + const inputs = [a, b, c]; + + const group_by: GroupingProperty = 'path'; + const grouping = [{ property: group_by }]; + const groups = Group.by(grouping, inputs); + expect(groups.toString()).toMatchInlineSnapshot(` + " + Group names: [a/b/c] + #### a/b/c + - [ ] first file path, alphabetically + + --- + + Group names: [b/c/d] + #### b/c/d + - [ ] second file path + + --- + + Group names: [d/e/f] + #### d/e/f + - [ ] third file path + + --- + + 3 tasks + " + `); + }); + + it('should create nested headings if multiple groups used', () => { + // Arrange + const t1 = fromLine({ + line: '- [ ] Task 1 - but path is 2nd, alphabetically', + path: 'folder_b/folder_c/file_c.md', + }); + const t2 = fromLine({ + line: '- [ ] Task 2 - but path is 2nd, alphabetically', + path: 'folder_b/folder_c/file_d.md', + }); + const t3 = fromLine({ + line: '- [ ] Task 3 - but path is 1st, alphabetically', + path: 'folder_a/folder_b/file_c.md', + }); + const tasks = [t1, t2, t3]; + + const grouping: Grouping[] = [ + { property: 'folder' }, + { property: 'filename' }, + ]; + + // Act + const groups = Group.by(grouping, tasks); + + // Assert + expect(groups.toString()).toMatchInlineSnapshot(` + " + Group names: [folder_a/folder_b/,file_c] + #### folder_a/folder_b/ + ##### file_c + - [ ] Task 3 - but path is 1st, alphabetically + + --- + + Group names: [folder_b/folder_c/,file_c] + #### folder_b/folder_c/ + ##### file_c + - [ ] Task 1 - but path is 2nd, alphabetically + + --- + + Group names: [folder_b/folder_c/,file_d] + ##### file_d + - [ ] Task 2 - but path is 2nd, alphabetically + + --- + + 3 tasks + " + `); + }); +}); + +describe('Group names', () => { + type GroupNameCase = { + groupBy: GroupingProperty; + taskLine: string; + expectedGroupName: string; + path?: string; + precedingHeading?: string | null; + }; + + const groupNameCases: Array = [ + // Maintenance Note: tests are in alphabetical order of 'groupBy' name + + // ----------------------------------------------------------- + // group by backlink + { + groupBy: 'backlink', + taskLine: '- [ ] xxx', + expectedGroupName: 'c > heading', + path: 'a/b/c.md', + precedingHeading: 'heading', + }, + + // ----------------------------------------------------------- + // group by filename + { + groupBy: 'filename', + taskLine: '- [ ] a', + expectedGroupName: 'c', + path: 'a/b/c.md', + }, + + // ----------------------------------------------------------- + // group by folder + { + groupBy: 'folder', + taskLine: '- [ ] a', + expectedGroupName: 'a/b/', + path: 'a/b/c.md', + }, + { + // file in root of vault: + groupBy: 'folder', + taskLine: '- [ ] a', + expectedGroupName: '/', + path: 'a.md', + }, + + // ----------------------------------------------------------- + // group by heading + { + groupBy: 'heading', + taskLine: '- [ ] xxx', + expectedGroupName: '(No heading)', + precedingHeading: null, + }, + { + groupBy: 'heading', + taskLine: '- [ ] xxx', + expectedGroupName: '(No heading)', + precedingHeading: '', + }, + { + groupBy: 'heading', + taskLine: '- [ ] xxx', + expectedGroupName: 'heading', + precedingHeading: 'heading', + }, + + // ----------------------------------------------------------- + // group by path + { + groupBy: 'path', + taskLine: '- [ ] a', + path: 'a/b/c.md', + expectedGroupName: 'a/b/c', + }, + + // ----------------------------------------------------------- + // group by status + { + groupBy: 'status', + taskLine: '- [ ] a', + expectedGroupName: 'Todo', + }, + { + groupBy: 'status', + taskLine: '- [x] a', + expectedGroupName: 'Done', + }, + + // ----------------------------------------------------------- + ]; + + test.concurrent.each(groupNameCases)( + 'assigns correct group name (%j)', + ({ groupBy, taskLine, path, expectedGroupName, precedingHeading }) => { + const task = fromLine({ + line: taskLine, + path: path ? path : '', + precedingHeader: precedingHeading, + }); + checkGroupNameOfTask(task, groupBy, expectedGroupName); + }, + ); +}); diff --git a/tests/Query.test.ts b/tests/Query.test.ts index dd39475fe8..85d8509236 100644 --- a/tests/Query.test.ts +++ b/tests/Query.test.ts @@ -5,6 +5,7 @@ import moment from 'moment'; import { getSettings, updateSettings } from '../src/Settings'; import { Query } from '../src/Query'; import { Priority, Status, Task } from '../src/Task'; +import { createTasksFromMarkdown } from './TestHelpers'; window.moment = moment; @@ -698,4 +699,82 @@ describe('Query', () => { expect(query.error).toBeUndefined(); }); }); + + // This tests the parsing of 'group by' instructions. + // Group.test.ts tests the actual grouping code. + describe('grouping instructions', () => { + it('should default to ungrouped', () => { + // Arrange + const source = ''; + const query = new Query({ source }); + + // Assert + expect(query.grouping.length).toEqual(0); + }); + + it('should parse a supported group command without error', () => { + // Arrange + const input = 'group by path'; + const query = new Query({ source: input }); + + // Assert + expect(query.error).toBeUndefined(); + expect(query.grouping.length).toEqual(1); + }); + + it('should log meaningful error for supported group type', () => { + // Arrange + const input = 'group by xxxx'; + const query = new Query({ source: input }); + + // Assert + // Check that the error message contains the actual problem line + expect(query.error).toContain(input); + expect(query.grouping.length).toEqual(0); + }); + + it('should apply limit correctly, after sorting tasks', () => { + // Arrange + const input = ` + # sorting by status will move the incomplete tasks first + sort by status + + # grouping by status will give two groups: Done and Todo + group by status + + # Apply a limit, to test which tasks make it to + limit 2 + `; + const query = new Query({ source: input }); + + const tasksAsMarkdown = ` +- [x] Task 1 - should not appear in output +- [x] Task 2 - should not appear in output +- [ ] Task 3 - will be sorted to 1st place, so should pass limit +- [ ] Task 4 - will be sorted to 2nd place, so should pass limit +- [ ] Task 5 - should not appear in output +- [ ] Task 6 - should not appear in output + `; + + const tasks = createTasksFromMarkdown( + tasksAsMarkdown, + 'some_markdown_file', + 'Some Heading', + ); + + // Act + const groups = query.applyQueryToTasks(tasks); + + // Assert + expect(groups.groups.length).toEqual(1); + const soleTaskGroup = groups.groups[0]; + const expectedTasks = ` +- [ ] Task 3 - will be sorted to 1st place, so should pass limit +- [ ] Task 4 - will be sorted to 2nd place, so should pass limit +`; + expect('\n' + soleTaskGroup.tasksAsStringOfLines()).toStrictEqual( + expectedTasks, + ); + }); + }); }); diff --git a/tests/Sort.test.ts b/tests/Sort.test.ts index b48118d63d..f710b28fca 100644 --- a/tests/Sort.test.ts +++ b/tests/Sort.test.ts @@ -5,19 +5,9 @@ import moment from 'moment'; window.moment = moment; -import { Task } from '../src/Task'; import { Sort } from '../src/Sort'; import { getSettings, updateSettings } from '../src/Settings'; - -function fromLine({ line, path = '' }: { line: string; path?: string }) { - return Task.fromLine({ - line, - path, - precedingHeader: '', - sectionIndex: 0, - sectionStart: 0, - })!; -} +import { fromLine } from './TestHelpers'; describe('Sort', () => { it('sorts correctly by default order', () => { diff --git a/tests/TestHelpers.ts b/tests/TestHelpers.ts new file mode 100644 index 0000000000..a4714a4f9b --- /dev/null +++ b/tests/TestHelpers.ts @@ -0,0 +1,41 @@ +import { Task } from '../src/Task'; + +export function fromLine({ + line, + path = '', + precedingHeader = '', +}: { + line: string; + path?: string; + precedingHeader?: string | null; +}) { + return Task.fromLine({ + line, + path, + precedingHeader, + sectionIndex: 0, + sectionStart: 0, + })!; +} + +export function createTasksFromMarkdown( + tasksAsMarkdown: string, + path: string, + precedingHeader: string, +): Task[] { + const taskLines = tasksAsMarkdown.split('\n'); + const tasks: Task[] = []; + for (const line of taskLines) { + const task = Task.fromLine({ + line: line, + path: path, + precedingHeader: precedingHeader, + sectionIndex: 0, + sectionStart: 0, + }); + if (task) { + tasks.push(task); + } + } + return tasks; +}