Skip to content

Commit

Permalink
Merge pull request #3110 from obsidian-tasks-group/add-random-sort
Browse files Browse the repository at this point in the history
feat: Add random sorting, with 'sort by random'
  • Loading branch information
claremacrae authored Oct 5, 2024
2 parents 40466ce + 233f09a commit fc07a42
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 1 deletion.
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

0 comments on commit fc07a42

Please sign in to comment.