Skip to content

feat: Add random sorting, with 'sort by random' #3110

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Oct 5, 2024
Merged
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
29 changes: 29 additions & 0 deletions docs/Queries/Sorting.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,35 @@ sort by function task.originalMarkdown

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

### Random sorting

> [!released]
> Random sorting was introduced in Tasks X.Y.Z.

This instruction sorts tasks in a random order:

- `sort by random`

The order is random but deterministic, calculated from task's description, and changes each day.

> [!example] Example: Randomly select a few tasks to review
> If you have a large vault with lots of undated tasks, reviewing them can be tedious: we have found it useful to be able to view a small selection every day.
>
> Review your backlog each day:
>
> - randomly select up to 10 undated tasks,
> - then complete, update or delete a few of them!
>
> ````text
> ```tasks
> not done
> no happens date
> limit 10
>
> sort by random
> ```
> ````

## Sort by File Properties

### File Path
Expand Down
1 change: 1 addition & 0 deletions docs/Quick Reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ This table summarizes the filters and other options available inside a `tasks` b
| `description (includes, does not include) <string>`<br>`description (regex matches, regex does not match) /regex/i` | `sort by description` | | | `task.description`<br>`task.descriptionWithoutTags` |
| `has tags`<br>`no tags`<br>`tag (includes, does not include) <tag>`<br>`tags (include, do not include) <tag>`<br>`tag (regex matches, regex does not match) /regex/i`<br>`tags (regex matches, regex does not match) /regex/i` | `sort by tag`<br>`sort by tag <tag_number>` | `group by tags` | `hide tags` | `task.tags` |
| | | | | `task.originalMarkdown` |
| | `sort by random` | | | |
| **[[About Scripting\|Scripting]]** | | | | |
| `filter by function` | `sort by function` | `group by function` | | |
| **[[Combining Filters]]** | | | | |
Expand Down
2 changes: 2 additions & 0 deletions docs/What is New/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ _In recent [Tasks releases](https://github.com/obsidian-tasks-group/obsidian-tas

## 7.x releases

- X.Y.Z:
- Add [[Sorting#Random sorting|random sorting]], with `sort by random`
- 7.10.0:
- Right-click on any task date field in Reading and Query Results views to:
- postpone Start, Scheduled and Due dates
Expand Down
47 changes: 47 additions & 0 deletions src/Query/Filter/RandomField.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Comparator } from '../Sort/Sorter';
import type { Task } from '../../Task/Task';
import { FilterInstructionsBasedField } from './FilterInstructionsBasedField';

/**
* Sort tasks in a stable, random order.
*
* The sort order changes each day.
*/
export class RandomField extends FilterInstructionsBasedField {
public fieldName(): string {
return 'random';
}

// -----------------------------------------------------------------------------------------------------------------
// Sorting
// -----------------------------------------------------------------------------------------------------------------

supportsSorting(): boolean {
return true;
}

public comparator(): Comparator {
return (a: Task, b: Task) => {
return this.sortKey(a) - this.sortKey(b);
};
}

public sortKey(task: Task): number {
// Credit:
// - @qelo https://github.com/obsidian-tasks-group/obsidian-tasks/discussions/330#discussioncomment-8902878
// - Based on TinySimpleHash in https://stackoverflow.com/a/52171480/104370
const tinySimpleHash = (s: string): number => {
let i = 0; // Index for iterating over the string
let h = 9; // Initial hash value

while (i < s.length) {
h = Math.imul(h ^ s.charCodeAt(i++), 9 ** 9);
}

return h ^ (h >>> 9);
};

const currentDate = window.moment().format('Y-MM-DD');
return tinySimpleHash(currentDate + ' ' + task.description);
}
}
2 changes: 2 additions & 0 deletions src/Query/FilterParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { CancelledDateField } from './Filter/CancelledDateField';
import { BlockingField } from './Filter/BlockingField';
import { IdField } from './Filter/IdField';
import { DependsOnField } from './Filter/DependsOnField';
import { RandomField } from './Filter/RandomField';

// When parsing a query the fields are tested one by one according to this order.
// Since BooleanField is a meta-field, which needs to aggregate a few fields together, it is intended to
Expand Down Expand Up @@ -66,6 +67,7 @@ export const fieldCreators: EndsWith<BooleanField> = [
() => new IdField(),
() => new DependsOnField(),
() => new BlockingField(),
() => new RandomField(),
() => new BooleanField(), // --- Please make sure to keep BooleanField last (see comment above) ---
];

Expand Down
77 changes: 77 additions & 0 deletions tests/Query/Filter/RandomField.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* @jest-environment jsdom
*/
import moment from 'moment';

import { RandomField } from '../../../src/Query/Filter/RandomField';
import { fromLine } from '../../TestingTools/TestHelpers';
import { expectTaskComparesEqual } from '../../CustomMatchers/CustomMatchersForSorting';
import { TaskBuilder } from '../../TestingTools/TaskBuilder';

window.moment = moment;

const field = new RandomField();

beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-23'));
});

afterAll(() => {
jest.useRealTimers();
});

describe('filtering by random', () => {
it('should be named random', () => {
expect(field.fieldName()).toEqual('random');
});
});

describe('sorting by random', () => {
it('should support sorting', () => {
expect(field.supportsSorting()).toEqual(true);
});

it('should sort identical tasks the same', () => {
const sorter = field.createNormalSorter();
const task1 = fromLine({ line: '- [ ] Some description' });

expectTaskComparesEqual(sorter, task1, task1);
});

it('sort key should ignore task properties except description', () => {
const fullyPopulatedTask = TaskBuilder.createFullyPopulatedTask();
const taskWithSameDescription = new TaskBuilder().description(fullyPopulatedTask.description).build();
expect(field.sortKey(fullyPopulatedTask)).toEqual(field.sortKey(taskWithSameDescription));
});

it('sort key should not change, at different times', () => {
const task1 = fromLine({ line: '- [ ] My sort key should be same, regardless of time' });

jest.setSystemTime(new Date('2024-10-19 10:42'));
const sortKeyAtTime1 = field.sortKey(task1);

jest.setSystemTime(new Date('2024-10-19 21:05'));
const sortKeyAtTime2 = field.sortKey(task1);

expect(sortKeyAtTime1).toEqual(sortKeyAtTime2);
});

it('sort key should change on different dates', () => {
const task1 = fromLine({ line: '- [ ] My sort key should differ on different dates' });

jest.setSystemTime(new Date('2024-01-23'));
const sortKeyOnDay1 = field.sortKey(task1);

jest.setSystemTime(new Date('2024-01-24'));
const sortKeyOnDay2 = field.sortKey(task1);

expect(sortKeyOnDay1).not.toEqual(sortKeyOnDay2);
});
});

describe('grouping by random', () => {
it('should not support grouping', () => {
expect(field.supportsGrouping()).toEqual(false);
});
});
4 changes: 3 additions & 1 deletion tests/Query/Query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ describe('Query parsing', () => {

describe.each(namedFields)('has sufficient sample "filter" lines for field "%s"', ({ name, field }) => {
function fieldDoesNotSupportFiltering() {
return name === 'backlink' || name === 'urgency';
return name === 'backlink' || name === 'urgency' || name === 'random';
}

// This is a bit weaker than the corresponding tests for 'sort by' and 'group by',
Expand Down Expand Up @@ -311,6 +311,8 @@ describe('Query parsing', () => {
'sort by path reverse',
'sort by priority',
'sort by priority reverse',
'sort by random',
'sort by random reverse',
'sort by recurring',
'sort by recurring reverse',
'sort by scheduled',
Expand Down