whatnext 3.3.2__tar.gz → 3.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. {whatnext-3.3.2 → whatnext-3.4}/CHANGELOG.md +10 -1
  2. {whatnext-3.3.2 → whatnext-3.4}/PKG-INFO +1 -1
  3. {whatnext-3.3.2 → whatnext-3.4}/docs/usage.md +5 -0
  4. whatnext-3.4/tasks/muting.md +18 -0
  5. {whatnext-3.3.2 → whatnext-3.4}/tests/config.bats +10 -0
  6. whatnext-3.4/tests/mute/.whatnext.mute +4 -0
  7. whatnext-3.4/tests/mute/.whatnext.mute.expected +4 -0
  8. whatnext-3.4/tests/mute/tasks.md +7 -0
  9. whatnext-3.4/tests/mute-fixture/tasks.md +7 -0
  10. whatnext-3.4/tests/mute.bats +180 -0
  11. whatnext-3.4/tests/test_config.py +43 -0
  12. whatnext-3.4/tests/test_mute.py +115 -0
  13. {whatnext-3.3.2 → whatnext-3.4}/tests/usage.bats +11 -6
  14. {whatnext-3.3.2 → whatnext-3.4}/whatnext/next.py +4 -2
  15. {whatnext-3.3.2 → whatnext-3.4}/whatnext/whatnext.py +142 -8
  16. {whatnext-3.3.2 → whatnext-3.4}/.flake8 +0 -0
  17. {whatnext-3.3.2 → whatnext-3.4}/.github/dependabot.yml +0 -0
  18. {whatnext-3.3.2 → whatnext-3.4}/.github/workflows/publish.yml +0 -0
  19. {whatnext-3.3.2 → whatnext-3.4}/.github/workflows/tests.yml +0 -0
  20. {whatnext-3.3.2 → whatnext-3.4}/.gitignore +0 -0
  21. {whatnext-3.3.2 → whatnext-3.4}/.whatnext +0 -0
  22. {whatnext-3.3.2 → whatnext-3.4}/LICENCE +0 -0
  23. {whatnext-3.3.2 → whatnext-3.4}/LICENSE +0 -0
  24. {whatnext-3.3.2 → whatnext-3.4}/Makefile +0 -0
  25. {whatnext-3.3.2 → whatnext-3.4}/README.md +0 -0
  26. {whatnext-3.3.2 → whatnext-3.4}/archive/done/tasks.md +0 -0
  27. {whatnext-3.3.2 → whatnext-3.4}/docs/annotations.md +0 -0
  28. {whatnext-3.3.2 → whatnext-3.4}/docs/basics.md +0 -0
  29. {whatnext-3.3.2 → whatnext-3.4}/docs/deadlines.md +0 -0
  30. {whatnext-3.3.2 → whatnext-3.4}/docs/deferring.md +0 -0
  31. {whatnext-3.3.2 → whatnext-3.4}/docs/exclusions.md +0 -0
  32. {whatnext-3.3.2 → whatnext-3.4}/docs/index.md +0 -0
  33. {whatnext-3.3.2 → whatnext-3.4}/docs/installation.md +0 -0
  34. {whatnext-3.3.2 → whatnext-3.4}/docs/next.md +0 -0
  35. {whatnext-3.3.2 → whatnext-3.4}/docs/prioritisation.md +0 -0
  36. {whatnext-3.3.2 → whatnext-3.4}/docs/sequential.md +0 -0
  37. {whatnext-3.3.2 → whatnext-3.4}/example/archived/projects/tangerine.md +0 -0
  38. {whatnext-3.3.2 → whatnext-3.4}/example/projects/curtain.md +0 -0
  39. {whatnext-3.3.2 → whatnext-3.4}/example/projects/fountain.md +0 -0
  40. {whatnext-3.3.2 → whatnext-3.4}/example/projects/harvest.md +0 -0
  41. {whatnext-3.3.2 → whatnext-3.4}/example/projects/obelisk.md +0 -0
  42. {whatnext-3.3.2 → whatnext-3.4}/example/projects/tinsel.md +0 -0
  43. {whatnext-3.3.2 → whatnext-3.4}/example/tasks.md +0 -0
  44. {whatnext-3.3.2 → whatnext-3.4}/example/template.md +0 -0
  45. {whatnext-3.3.2 → whatnext-3.4}/pyproject.toml +0 -0
  46. {whatnext-3.3.2 → whatnext-3.4}/scripts/update-usage-examples.py +0 -0
  47. {whatnext-3.3.2 → whatnext-3.4}/tasks/deferring.md +0 -0
  48. {whatnext-3.3.2 → whatnext-3.4}/tasks/documentation.md +0 -0
  49. {whatnext-3.3.2 → whatnext-3.4}/tasks/ignoring.md +0 -0
  50. {whatnext-3.3.2 → whatnext-3.4}/tasks/listing.md +0 -0
  51. {whatnext-3.3.2 → whatnext-3.4}/tasks/next.md +0 -0
  52. {whatnext-3.3.2 → whatnext-3.4}/tasks/summaries.md +0 -0
  53. {whatnext-3.3.2 → whatnext-3.4}/tasks/test_refactor.md +0 -0
  54. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_0.1.md +0 -0
  55. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_0.2.md +0 -0
  56. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_0.3.md +0 -0
  57. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_0.4.md +0 -0
  58. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_0.5.md +0 -0
  59. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_1.0.md +0 -0
  60. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_1.1.md +0 -0
  61. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_1.2.md +0 -0
  62. {whatnext-3.3.2 → whatnext-3.4}/tasks/version_1.3.md +0 -0
  63. {whatnext-3.3.2 → whatnext-3.4}/tasks.md +0 -0
  64. {whatnext-3.3.2 → whatnext-3.4}/tests/colour.bats +0 -0
  65. {whatnext-3.3.2 → whatnext-3.4}/tests/config/.whatnext +0 -0
  66. {whatnext-3.3.2 → whatnext-3.4}/tests/config/.whatnext.alt +0 -0
  67. {whatnext-3.3.2 → whatnext-3.4}/tests/config/apples.md +0 -0
  68. {whatnext-3.3.2 → whatnext-3.4}/tests/config/oranges.md +0 -0
  69. {whatnext-3.3.2 → whatnext-3.4}/tests/config-external +0 -0
  70. {whatnext-3.3.2 → whatnext-3.4}/tests/deadline.bats +0 -0
  71. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/bare-after.md +0 -0
  72. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/circular-a.md +0 -0
  73. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/circular-b.md +0 -0
  74. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/deferred.md +0 -0
  75. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/dir-complete/tasks.md +0 -0
  76. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/duplicate-warnings.md +0 -0
  77. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/missing-dep.md +0 -0
  78. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/needs-absolute.md +0 -0
  79. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/needs-dir-complete.md +0 -0
  80. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/needs-subdir-file.md +0 -0
  81. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/needs-subdir.md +0 -0
  82. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/phase-misplaced.md +0 -0
  83. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/phases-empty.md +0 -0
  84. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/phases-partial.md +0 -0
  85. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/phases-queue.md +0 -0
  86. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/phases.md +0 -0
  87. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/regular.md +0 -0
  88. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/root-target.md +0 -0
  89. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/samename.md +0 -0
  90. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/subdir/needs-parent.md +0 -0
  91. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/subdir/needs-sibling.md +0 -0
  92. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/subdir/samename.md +0 -0
  93. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring/subdir/target.md +0 -0
  94. {whatnext-3.3.2 → whatnext-3.4}/tests/deferring.bats +0 -0
  95. {whatnext-3.3.2 → whatnext-3.4}/tests/edit.bats +0 -0
  96. {whatnext-3.3.2 → whatnext-3.4}/tests/headerless/annotation.md +0 -0
  97. {whatnext-3.3.2 → whatnext-3.4}/tests/headerless/deadline.md +0 -0
  98. {whatnext-3.3.2 → whatnext-3.4}/tests/housekeeping.bats +0 -0
  99. {whatnext-3.3.2 → whatnext-3.4}/tests/invalid.md +0 -0
  100. {whatnext-3.3.2 → whatnext-3.4}/tests/list.bats +0 -0
  101. {whatnext-3.3.2 → whatnext-3.4}/tests/matching.bats +0 -0
  102. {whatnext-3.3.2 → whatnext-3.4}/tests/next/alternate.md +0 -0
  103. {whatnext-3.3.2 → whatnext-3.4}/tests/next/append-after.md +0 -0
  104. {whatnext-3.3.2 → whatnext-3.4}/tests/next/append.md +0 -0
  105. {whatnext-3.3.2 → whatnext-3.4}/tests/next/empty.md +0 -0
  106. {whatnext-3.3.2 → whatnext-3.4}/tests/next/insert.md +0 -0
  107. {whatnext-3.3.2 → whatnext-3.4}/tests/next/projects/alpha/tasks/things.md +0 -0
  108. {whatnext-3.3.2 → whatnext-3.4}/tests/next/projects/alpha/tasks.md +0 -0
  109. {whatnext-3.3.2 → whatnext-3.4}/tests/next/projects/beta/things.md +0 -0
  110. {whatnext-3.3.2 → whatnext-3.4}/tests/next/something.txt +0 -0
  111. {whatnext-3.3.2 → whatnext-3.4}/tests/next/tasks.md +0 -0
  112. {whatnext-3.3.2 → whatnext-3.4}/tests/next/wide.md +0 -0
  113. {whatnext-3.3.2 → whatnext-3.4}/tests/next/wrapped.md +0 -0
  114. {whatnext-3.3.2 → whatnext-3.4}/tests/next.bats +0 -0
  115. {whatnext-3.3.2 → whatnext-3.4}/tests/notnext.bats +0 -0
  116. {whatnext-3.3.2 → whatnext-3.4}/tests/queue/all-complete.md +0 -0
  117. {whatnext-3.3.2 → whatnext-3.4}/tests/queue/blocked.md +0 -0
  118. {whatnext-3.3.2 → whatnext-3.4}/tests/queue/in-progress.md +0 -0
  119. {whatnext-3.3.2 → whatnext-3.4}/tests/queue/normal.md +0 -0
  120. {whatnext-3.3.2 → whatnext-3.4}/tests/queue/prioritised.md +0 -0
  121. {whatnext-3.3.2 → whatnext-3.4}/tests/queue.bats +0 -0
  122. {whatnext-3.3.2 → whatnext-3.4}/tests/summary.bats +0 -0
  123. {whatnext-3.3.2 → whatnext-3.4}/tests/test_defer.py +0 -0
  124. {whatnext-3.3.2 → whatnext-3.4}/tests/test_edit.py +0 -0
  125. {whatnext-3.3.2 → whatnext-3.4}/tests/test_file.py +0 -0
  126. {whatnext-3.3.2 → whatnext-3.4}/tests/test_limit.py +0 -0
  127. {whatnext-3.3.2 → whatnext-3.4}/tests/test_output.py +0 -0
  128. {whatnext-3.3.2 → whatnext-3.4}/tests/test_summary.py +0 -0
  129. {whatnext-3.3.2 → whatnext-3.4}/tests/test_task.py +0 -0
  130. {whatnext-3.3.2 → whatnext-3.4}/tests/version.bats +0 -0
  131. {whatnext-3.3.2 → whatnext-3.4}/whatnext/__init__.py +0 -0
  132. {whatnext-3.3.2 → whatnext-3.4}/whatnext/guide.txt +0 -0
  133. {whatnext-3.3.2 → whatnext-3.4}/whatnext/models.py +0 -0
  134. {whatnext-3.3.2 → whatnext-3.4}/whatnext/summary.py +0 -0
@@ -1,6 +1,13 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [v3.4] - 2026-04-17
5
+
6
+ **Added:**
7
+ - Task muting: temporarily hide tasks matching patterns from output by
8
+ using `whatnext --mute [time] [match]`.
9
+
10
+
4
11
  ## [v3.3.2] - 2026-04-17
5
12
 
6
13
  **Fixes:** `@after` with a directory waits on all tasks under that dir.
@@ -62,12 +69,14 @@ Previously `whatnext` assumed a flat hierarchy of unique filenames.
62
69
  the file; the first phase is always visible, and subsequent phases
63
70
  will only show when all previous phases are complete.
64
71
 
72
+ ## [v2.1.1] - 2026-02-10
73
+
65
74
  **Fixes:**
66
75
  - `@after` referencing a file excluded by `--ignore` no longer warns about
67
76
  the file not existing, instead treating the dependency as complete.
68
77
 
69
78
 
70
- ## [v2.0] - 2026-02-09
79
+ ## [v2.1] - 2026-02-09
71
80
 
72
81
  Added `@queue` directive show only one task at a time, for simple sequential
73
82
  work that doesn't need to be split across files using `@after`.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whatnext
3
- Version: 3.3.2
3
+ Version: 3.4
4
4
  Summary: A lister of tasks found in Markdown files
5
5
  Project-URL: Homepage, https://github.com/norm/whatnext
6
6
  Project-URL: Repository, https://github.com/norm/whatnext
@@ -232,6 +232,11 @@ To show a random selection of tasks rather than by priority, use `5r`.
232
232
 
233
233
  - `--ignore-all` — ignore all deferral constraints (`@after`, `@phase`, `@queue`).
234
234
 
235
+ - `--ignore-mute` — ignore mute settings.
236
+
237
+ - `--mute [time] [match]` — for the next 'time' period (formatted `4d`, `2w`, `1m2d`)
238
+ mute any tasks with 'match' in the text, header, or filename.
239
+
235
240
  - `-o` / `--open` — show only open tasks.
236
241
 
237
242
  - `-p` / `--partial` — show only in progress tasks.
@@ -0,0 +1,18 @@
1
+ There is a difference between the _truth_ of the tasks as captured in the
2
+ files, where dependencies are declared, and the _availability_ of what can or
3
+ will be done by an individual on a team, so the person running `whatnext`
4
+ should be able to temporarily or permanently ignore a task or group of tasks
5
+ from showing up as available for them to work on.
6
+
7
+ - [X] `.whatnext.mute` merges with `.whatnext` to populate the 'mute' key in
8
+ the config
9
+ - contains a list, each entry being a timestamp and a match pattern
10
+ - if the timestamp is now in the past, the entry is removed
11
+ - [X] listing tasks uses the mute list
12
+ - anything matching a pattern is excluded from output
13
+ - `--ignore-mute`, `--ignore-all`, `--all` bypasses this
14
+ - show a count of muted tasks at the end
15
+ - [X] `whatnext --mute 'period' 'pattern'` creates the entry
16
+ - period is formatted like 1d, 2d, 1w, 1m, 2m3w
17
+ - pattern cannot be empty
18
+ - [ ] if `whatnext [...]` produces no tasks, try again without the mute list
@@ -82,3 +82,13 @@ function setup {
82
82
  diff -u <(echo "$expected_output") <(echo "$output")
83
83
  [ $status -eq 0 ]
84
84
  }
85
+
86
+ @test "expired mute entries removed from file" {
87
+ cp -r "$BATS_TEST_DIRNAME/mute/." "$BATS_TEST_TMPDIR"
88
+
89
+ run --separate-stderr \
90
+ whatnext --dir "$BATS_TEST_TMPDIR"
91
+
92
+ diff -u "$BATS_TEST_DIRNAME/mute/.whatnext.mute.expected" "$BATS_TEST_TMPDIR/.whatnext.mute"
93
+ [ $status -eq 0 ]
94
+ }
@@ -0,0 +1,4 @@
1
+ [[mute]]
2
+ until = 2099-12-31T23:59:59
3
+ pattern = "future"
4
+
@@ -0,0 +1,4 @@
1
+ [[mute]]
2
+ until = 2099-12-31T23:59:59
3
+ pattern = "future"
4
+
@@ -0,0 +1,7 @@
1
+ # Shopping
2
+ - [ ] buy apples
3
+ - [ ] buy oranges
4
+
5
+ # Gardening
6
+ - [ ] plant seeds
7
+ - [ ] water plants
@@ -0,0 +1,7 @@
1
+ # Shopping
2
+ - [ ] buy apples
3
+ - [ ] buy oranges
4
+
5
+ # Gardening
6
+ - [ ] plant seeds
7
+ - [ ] water plants
@@ -0,0 +1,180 @@
1
+ bats_require_minimum_version 1.5.0
2
+
3
+ @test "--mute creates mute entry" {
4
+ run whatnext --config "$BATS_TEST_TMPDIR/.whatnext" --mute 1d "apples"
5
+
6
+ # check pattern is in file (until timestamp varies)
7
+ diff -u <(echo 'pattern = "apples"') <(grep 'pattern' "$BATS_TEST_TMPDIR/.whatnext.mute")
8
+ [ $status -eq 0 ]
9
+ }
10
+
11
+ @test "--mute appends to existing file" {
12
+ sed -e 's/^ //' > "$BATS_TEST_TMPDIR/.whatnext.mute" <<-EOF
13
+ [[mute]]
14
+ until = 2099-12-31T23:59:59
15
+ pattern = "existing"
16
+ EOF
17
+
18
+ run whatnext --config "$BATS_TEST_TMPDIR/.whatnext" --mute 1w "new"
19
+
20
+ expected=$(sed -e 's/^ //' <<-EOF
21
+ pattern = "existing"
22
+ pattern = "new"
23
+ EOF
24
+ )
25
+ diff -u <(echo "$expected") <(grep 'pattern' "$BATS_TEST_TMPDIR/.whatnext.mute")
26
+ [ $status -eq 0 ]
27
+ }
28
+
29
+ @test "--mute rejects empty pattern" {
30
+ run --separate-stderr \
31
+ whatnext --config "$BATS_TEST_TMPDIR/.whatnext" --mute 1d ""
32
+
33
+ diff -u <(echo "ERROR: pattern cannot be empty") <(echo "$stderr")
34
+ [ $status -eq 1 ]
35
+ }
36
+
37
+ @test "--mute rejects invalid period" {
38
+ run --separate-stderr \
39
+ whatnext --config "$BATS_TEST_TMPDIR/.whatnext" --mute "abc" "pattern"
40
+
41
+ diff -u <(echo "ERROR: invalid period 'abc'") <(echo "$stderr")
42
+ [ $status -eq 1 ]
43
+ }
44
+
45
+ @test "muted tasks are excluded from output" {
46
+ sed -e 's/^ //' > "$BATS_TEST_TMPDIR/.whatnext.mute" <<-EOF
47
+ [[mute]]
48
+ until = 2099-12-31T23:59:59
49
+ pattern = "apples"
50
+ EOF
51
+
52
+ expected_output=$(sed -e 's/^ //' <<-EOF
53
+ tasks.md:
54
+ # Shopping
55
+ - [ ] buy oranges
56
+ # Gardening
57
+ - [ ] plant seeds
58
+ - [ ] water plants
59
+
60
+ (1 task muted)
61
+ EOF
62
+ )
63
+
64
+ run --separate-stderr \
65
+ whatnext --config "$BATS_TEST_TMPDIR/.whatnext" tests/mute
66
+
67
+ diff -u <(echo "$expected_output") <(echo "$output")
68
+ [[ $status -eq 0 ]]
69
+ }
70
+
71
+ @test "muting matches heading" {
72
+ sed -e 's/^ //' > "$BATS_TEST_TMPDIR/.whatnext.mute" <<-EOF
73
+ [[mute]]
74
+ until = 2099-12-31T23:59:59
75
+ pattern = "shopping"
76
+ EOF
77
+
78
+ expected_output=$(sed -e 's/^ //' <<-EOF
79
+ tasks.md:
80
+ # Gardening
81
+ - [ ] plant seeds
82
+ - [ ] water plants
83
+
84
+ (2 tasks muted)
85
+ EOF
86
+ )
87
+
88
+ run --separate-stderr \
89
+ whatnext --config "$BATS_TEST_TMPDIR/.whatnext" tests/mute
90
+
91
+ diff -u <(echo "$expected_output") <(echo "$output")
92
+ [[ $status -eq 0 ]]
93
+ }
94
+
95
+ @test "muting matches filename" {
96
+ sed -e 's/^ //' > "$BATS_TEST_TMPDIR/.whatnext.mute" <<-EOF
97
+ [[mute]]
98
+ until = 2099-12-31T23:59:59
99
+ pattern = "tasks"
100
+ EOF
101
+
102
+ expected_output=$(sed -e 's/^ //' <<-EOF
103
+ Only muted tasks remain:
104
+
105
+ tasks.md:
106
+ # Shopping
107
+ - [ ] buy apples
108
+ - [ ] buy oranges
109
+ # Gardening
110
+ - [ ] plant seeds
111
+ - [ ] water plants
112
+ EOF
113
+ )
114
+
115
+ run --separate-stderr \
116
+ whatnext --config "$BATS_TEST_TMPDIR/.whatnext" tests/mute
117
+
118
+ diff -u <(echo "$expected_output") <(echo "$output")
119
+ [[ $status -eq 0 ]]
120
+ }
121
+
122
+ @test "multiple patterns exclude multiple tasks" {
123
+ sed -e 's/^ //' > "$BATS_TEST_TMPDIR/.whatnext.mute" <<-EOF
124
+ [[mute]]
125
+ until = 2099-12-31T23:59:59
126
+ pattern = "apples"
127
+
128
+ [[mute]]
129
+ until = 2099-12-31T23:59:59
130
+ pattern = "seeds"
131
+ EOF
132
+
133
+ expected_output=$(sed -e 's/^ //' <<-EOF
134
+ tasks.md:
135
+ # Shopping
136
+ - [ ] buy oranges
137
+ # Gardening
138
+ - [ ] water plants
139
+
140
+ (2 tasks muted)
141
+ EOF
142
+ )
143
+
144
+ run --separate-stderr \
145
+ whatnext --config "$BATS_TEST_TMPDIR/.whatnext" tests/mute
146
+
147
+ diff -u <(echo "$expected_output") <(echo "$output")
148
+ [[ $status -eq 0 ]]
149
+ }
150
+
151
+ @test "options bypass mute filtering" {
152
+ sed -e 's/^ //' > "$BATS_TEST_TMPDIR/.whatnext.mute" <<-EOF
153
+ [[mute]]
154
+ until = 2099-12-31T23:59:59
155
+ pattern = "apples"
156
+ EOF
157
+
158
+ # no count when nothing muted
159
+ expected_output=$(sed -e 's/^ //' <<-EOF
160
+ tasks.md:
161
+ # Shopping
162
+ - [ ] buy apples
163
+ - [ ] buy oranges
164
+ # Gardening
165
+ - [ ] plant seeds
166
+ - [ ] water plants
167
+ EOF
168
+ )
169
+
170
+ for flag in --ignore-mute --all --ignore-all; do
171
+ run --separate-stderr \
172
+ whatnext \
173
+ --config "$BATS_TEST_TMPDIR/.whatnext" \
174
+ "$flag" \
175
+ tests/mute
176
+
177
+ diff -u <(echo "$expected_output") <(echo "$output")
178
+ [[ $status -eq 0 ]]
179
+ done
180
+ }
@@ -0,0 +1,43 @@
1
+ import shutil
2
+ from datetime import timedelta
3
+
4
+ import pytest
5
+
6
+ from whatnext.whatnext import load_config, parse_period, resolve_config_path
7
+
8
+
9
+ class TestParsePeriod:
10
+ def test_days(self):
11
+ assert parse_period("1d") == timedelta(days=1)
12
+ assert parse_period("5d") == timedelta(days=5)
13
+
14
+ def test_weeks(self):
15
+ assert parse_period("1w") == timedelta(weeks=1)
16
+ assert parse_period("2w") == timedelta(weeks=2)
17
+
18
+ def test_months(self):
19
+ assert parse_period("1m") == timedelta(days=30)
20
+ assert parse_period("2m") == timedelta(days=60)
21
+
22
+ def test_combined(self):
23
+ assert parse_period("2m3w") == timedelta(days=60 + 21)
24
+ assert parse_period("1m2w3d") == timedelta(days=30 + 14 + 3)
25
+
26
+ def test_invalid_format_raises(self):
27
+ with pytest.raises(ValueError):
28
+ parse_period("")
29
+ with pytest.raises(ValueError):
30
+ parse_period("abc")
31
+ with pytest.raises(ValueError):
32
+ parse_period("1x")
33
+
34
+
35
+ class TestMuteLoading:
36
+ def test_expired_mutes_filtered_from_config(self, tmp_path):
37
+ shutil.copytree("tests/mute", tmp_path / "mute")
38
+ config_path = resolve_config_path(None, str(tmp_path / "mute"))
39
+ config = load_config(config_path, extensions=["mute"])
40
+
41
+ patterns = [entry["pattern"] for entry in config["mute"]]
42
+ assert "future" in patterns
43
+ assert "old" not in patterns
@@ -0,0 +1,115 @@
1
+ from datetime import date
2
+ from textwrap import dedent
3
+
4
+ from whatnext.models import MarkdownFile
5
+ from whatnext.whatnext import filter_muted
6
+
7
+
8
+ def make_data(source_string):
9
+ file = MarkdownFile(source_string=source_string, today=date(2025, 1, 1))
10
+ return [(file, file.tasks)]
11
+
12
+
13
+ class TestFilterMuted:
14
+ def test_empty_mute_list_returns_all_tasks(self):
15
+ data = make_data(dedent("""\
16
+ - [ ] first task
17
+ - [ ] second task
18
+ """))
19
+
20
+ filtered, count = filter_muted(data, [])
21
+
22
+ assert len(filtered[0][1]) == 2
23
+ assert count == 0
24
+
25
+ def test_pattern_matches_task_text(self):
26
+ data = make_data(dedent("""\
27
+ - [ ] buy apples
28
+ - [ ] buy oranges
29
+ """))
30
+
31
+ filtered, count = filter_muted(data, ["apples"])
32
+
33
+ assert len(filtered[0][1]) == 1
34
+ assert filtered[0][1][0].text == "buy oranges"
35
+ assert count == 1
36
+
37
+ def test_pattern_matches_heading(self):
38
+ data = make_data(dedent("""\
39
+ # Shopping
40
+ - [ ] buy apples
41
+ # Gardening
42
+ - [ ] plant seeds
43
+ """))
44
+
45
+ filtered, count = filter_muted(data, ["shopping"])
46
+
47
+ assert len(filtered[0][1]) == 1
48
+ assert filtered[0][1][0].text == "plant seeds"
49
+ assert count == 1
50
+
51
+ def test_matching_is_case_insensitive(self):
52
+ data = make_data(dedent("""\
53
+ - [ ] Buy APPLES
54
+ - [ ] buy oranges
55
+ """))
56
+
57
+ filtered, count = filter_muted(data, ["apples"])
58
+
59
+ assert len(filtered[0][1]) == 1
60
+ assert filtered[0][1][0].text == "buy oranges"
61
+ assert count == 1
62
+
63
+ def test_multiple_patterns_any_match_excludes(self):
64
+ data = make_data(dedent("""\
65
+ - [ ] buy apples
66
+ - [ ] buy oranges
67
+ - [ ] buy bananas
68
+ """))
69
+
70
+ filtered, count = filter_muted(data, ["apples", "oranges"])
71
+
72
+ assert len(filtered[0][1]) == 1
73
+ assert filtered[0][1][0].text == "buy bananas"
74
+ assert count == 2
75
+
76
+ def test_no_match_includes_task(self):
77
+ data = make_data(dedent("""\
78
+ - [ ] buy apples
79
+ - [ ] buy oranges
80
+ """))
81
+
82
+ filtered, count = filter_muted(data, ["bananas"])
83
+
84
+ assert len(filtered[0][1]) == 2
85
+ assert count == 0
86
+
87
+ def test_counts_across_multiple_files(self):
88
+ file1 = MarkdownFile(
89
+ source_string="- [ ] buy apples\n- [ ] buy oranges\n",
90
+ today=date(2025, 1, 1),
91
+ )
92
+ file2 = MarkdownFile(
93
+ source_string="- [ ] plant apples\n- [ ] plant oranges\n",
94
+ today=date(2025, 1, 1),
95
+ )
96
+ data = [(file1, file1.tasks), (file2, file2.tasks)]
97
+
98
+ filtered, count = filter_muted(data, ["apples"])
99
+
100
+ assert len(filtered[0][1]) == 1
101
+ assert len(filtered[1][1]) == 1
102
+ assert count == 2
103
+
104
+ def test_pattern_matches_filename(self):
105
+ file = MarkdownFile(
106
+ source_string="- [ ] first task\n- [ ] second task\n",
107
+ path="shopping.md",
108
+ today=date(2025, 1, 1),
109
+ )
110
+ data = [(file, file.tasks)]
111
+
112
+ filtered, count = filter_muted(data, ["shopping"])
113
+
114
+ assert len(filtered[0][1]) == 0
115
+ assert count == 2
@@ -8,9 +8,10 @@ bats_require_minimum_version 1.5.0
8
8
  expected_output=$(sed -e 's/^ //' <<" EOF"
9
9
  Usage: whatnext [-h] [--help] [--version] [--guide] [--dir DIR] [-s] [-e]
10
10
  [--relative] [-a] [--ignore-after] [--ignore-queue]
11
- [--ignore-phase] [--ignore-all] [--config CONFIG]
12
- [--ignore PATTERN] [-q] [-o] [-p] [-b] [-d] [-c]
13
- [--priority LEVEL] [--color | --no-color]
11
+ [--ignore-phase] [--ignore-all] [--ignore-mute]
12
+ [--mute TIME MATCH] [--config CONFIG] [--ignore PATTERN] [-q]
13
+ [-o] [-p] [-b] [-d] [-c] [--priority LEVEL]
14
+ [--color | --no-color]
14
15
  [match ...]
15
16
  EOF
16
17
  )
@@ -26,9 +27,10 @@ bats_require_minimum_version 1.5.0
26
27
  expected_output=$(sed -e 's/^ //' <<" EOF"
27
28
  Usage: whatnext [-h] [--help] [--version] [--guide] [--dir DIR] [-s] [-e]
28
29
  [--relative] [-a] [--ignore-after] [--ignore-queue]
29
- [--ignore-phase] [--ignore-all] [--config CONFIG]
30
- [--ignore PATTERN] [-q] [-o] [-p] [-b] [-d] [-c]
31
- [--priority LEVEL] [--color | --no-color]
30
+ [--ignore-phase] [--ignore-all] [--ignore-mute]
31
+ [--mute TIME MATCH] [--config CONFIG] [--ignore PATTERN] [-q]
32
+ [-o] [-p] [-b] [-d] [-c] [--priority LEVEL]
33
+ [--color | --no-color]
32
34
  [match ...]
33
35
 
34
36
  List tasks found in Markdown files
@@ -58,6 +60,9 @@ bats_require_minimum_version 1.5.0
58
60
  --ignore-phase Ignore @phase constraints
59
61
  --ignore-all Ignore all deferral constraints (@after, @phase,
60
62
  @queue)
63
+ --ignore-mute Ignore mute settings
64
+ --mute TIME MATCH Mute tasks matching pattern for period (e.g. 1d, 2w,
65
+ 1m)
61
66
  --config CONFIG Path to config file (default: WHATNEXT_CONFIG, or
62
67
  '.whatnext' in --dir)
63
68
  --ignore PATTERN Ignore files matching pattern (can be specified
@@ -7,7 +7,7 @@ import sys
7
7
  import textwrap
8
8
 
9
9
  from whatnext.models import MarkdownFile
10
- from whatnext.whatnext import load_config
10
+ from whatnext.whatnext import load_config, resolve_config_path
11
11
 
12
12
 
13
13
  def rewind_insertion_point(lines, position, min_position):
@@ -63,7 +63,9 @@ def get_wrap_width(lines, tasks_file, config_path):
63
63
  if env_width:
64
64
  default_width = int(env_width)
65
65
  else:
66
- config = load_config(config_path, os.path.dirname(tasks_file) or ".")
66
+ directory = os.path.dirname(tasks_file) or "."
67
+ resolved_path = resolve_config_path(config_path, directory)
68
+ config = load_config(resolved_path)
67
69
  default_width = config.get("wrap_width", 80)
68
70
  file_width = detect_task_width(lines)
69
71
  return max(file_width, default_width)
@@ -1,5 +1,5 @@
1
1
  import argparse
2
- from datetime import date
2
+ from datetime import date, datetime, timedelta
3
3
  import fnmatch
4
4
  import importlib.metadata
5
5
  import importlib.resources
@@ -29,6 +29,28 @@ PRIORITY_COLOURS = {
29
29
  ACTIVE_STATES = {State.OPEN, State.IN_PROGRESS, State.BLOCKED}
30
30
  COMPLETE_STATES = {State.COMPLETE, State.CANCELLED}
31
31
 
32
+ PERIOD_PATTERN = re.compile(r'(\d+)([dwm])')
33
+
34
+
35
+ def parse_period(period_str):
36
+ matches = PERIOD_PATTERN.findall(period_str)
37
+ if not matches:
38
+ raise ValueError(f"invalid period '{period_str}'")
39
+ # verify the entire string was consumed
40
+ reconstructed = ''.join(f"{n}{u}" for n, u in matches)
41
+ if reconstructed != period_str:
42
+ raise ValueError(f"invalid period '{period_str}'")
43
+ total_days = 0
44
+ for amount, unit in matches:
45
+ amount = int(amount)
46
+ if unit == 'd':
47
+ total_days += amount
48
+ elif unit == 'w':
49
+ total_days += amount * 7
50
+ elif unit == 'm':
51
+ total_days += amount * 30
52
+ return timedelta(days=total_days)
53
+
32
54
 
33
55
  class CircularDependencyError(Exception):
34
56
  pass
@@ -226,6 +248,35 @@ def filter_phases(data):
226
248
  return result
227
249
 
228
250
 
251
+ def filter_muted(data, mute_patterns):
252
+ if not mute_patterns:
253
+ return data, 0
254
+
255
+ muted_count = 0
256
+ result = []
257
+ for file, tasks in data:
258
+ file_lower = file.display_path.lower()
259
+ filtered_tasks = []
260
+ for task in tasks:
261
+ heading_lower = ""
262
+ if task.heading:
263
+ heading_lower = task.heading.lower()
264
+ text_lower = task.text.lower()
265
+ is_muted = any(
266
+ pattern in file_lower
267
+ or pattern in heading_lower
268
+ or pattern in text_lower
269
+ for pattern in mute_patterns
270
+ )
271
+ if is_muted:
272
+ muted_count += 1
273
+ else:
274
+ filtered_tasks.append(task)
275
+ result.append((file, filtered_tasks))
276
+
277
+ return result, muted_count
278
+
279
+
229
280
  def get_terminal_width():
230
281
  columns_env = os.environ.get("COLUMNS")
231
282
  if columns_env:
@@ -244,15 +295,45 @@ def get_editor():
244
295
  return "vi"
245
296
 
246
297
 
247
- def load_config(config_path=None, directory="."):
298
+ def resolve_config_path(config_path, directory):
248
299
  if config_path is None:
249
- config_path = os.path.join(directory, ".whatnext")
250
- elif not (config_path.startswith("./") or os.path.isabs(config_path)):
251
- config_path = os.path.join(directory, config_path)
300
+ return os.path.join(directory, ".whatnext")
301
+ if config_path.startswith("./") or os.path.isabs(config_path):
302
+ return config_path
303
+ return os.path.join(directory, config_path)
304
+
305
+
306
+ def write_mute_config(config_path, mutes):
307
+ mute_path = config_path + ".mute"
308
+ with open(mute_path, "w") as handle:
309
+ toml.dump({"mute": mutes}, handle)
310
+
311
+
312
+ def load_config(config_path, extensions=None):
252
313
  if os.path.exists(config_path):
253
314
  with open(config_path) as handle:
254
- return toml.load(handle)
255
- return {}
315
+ config = toml.load(handle)
316
+ else:
317
+ config = {}
318
+
319
+ if extensions:
320
+ for ext in extensions:
321
+ ext_path = config_path + "." + ext
322
+ if os.path.exists(ext_path):
323
+ with open(ext_path) as handle:
324
+ config[ext] = toml.load(handle).get(ext, [])
325
+
326
+ if "mute" in config:
327
+ now = datetime.now()
328
+ original = config["mute"]
329
+ config["mute"] = [
330
+ entry for entry in original
331
+ if entry["until"] > now
332
+ ]
333
+ if len(config["mute"]) != len(original):
334
+ write_mute_config(config_path, config["mute"])
335
+
336
+ return config
256
337
 
257
338
 
258
339
  def is_ignored(filepath, ignore_patterns):
@@ -522,6 +603,17 @@ def main():
522
603
  action="store_true",
523
604
  help="Ignore all deferral constraints (@after, @phase, @queue)",
524
605
  )
606
+ parser.add_argument(
607
+ "--ignore-mute",
608
+ action="store_true",
609
+ help="Ignore mute settings",
610
+ )
611
+ parser.add_argument(
612
+ "--mute",
613
+ nargs=2,
614
+ metavar=("TIME", "MATCH"),
615
+ help="Mute tasks matching pattern for period (e.g. 1d, 2w, 1m)",
616
+ )
525
617
  parser.add_argument(
526
618
  "--config",
527
619
  default=os.environ.get("WHATNEXT_CONFIG"),
@@ -593,6 +685,24 @@ def main():
593
685
  " [n]r - limit to n results, selected at random",
594
686
  )
595
687
  args = parser.parse_args()
688
+ config_path = resolve_config_path(args.config, args.dir)
689
+
690
+ if args.mute:
691
+ period_str, pattern = args.mute
692
+ if not pattern:
693
+ print("ERROR: pattern cannot be empty", file=sys.stderr)
694
+ sys.exit(1)
695
+ try:
696
+ period = parse_period(period_str)
697
+ except ValueError as e:
698
+ print(f"ERROR: {e}", file=sys.stderr)
699
+ sys.exit(1)
700
+ until = datetime.now() + period
701
+ config = load_config(config_path, extensions=["mute"])
702
+ mutes = config.get("mute", [])
703
+ mutes.append({"until": until, "pattern": pattern})
704
+ write_mute_config(config_path, mutes)
705
+ return
596
706
 
597
707
  # build the search space
598
708
  paths = []
@@ -613,8 +723,12 @@ def main():
613
723
  if not paths:
614
724
  paths = [args.dir]
615
725
 
616
- config = load_config(args.config, args.dir)
726
+ config = load_config(config_path, extensions=["mute"])
617
727
  ignore_patterns = config.get("ignore", []) + args.ignore
728
+ mute_patterns = [
729
+ entry["pattern"].lower()
730
+ for entry in config.get("mute", [])
731
+ ]
618
732
  quiet = args.quiet
619
733
 
620
734
  if "WHATNEXT_TODAY" in os.environ:
@@ -687,6 +801,12 @@ def main():
687
801
  ):
688
802
  filtered_data = filter_queue(filtered_data)
689
803
 
804
+ muted_count = 0
805
+ pre_mute_data = None
806
+ if not args.all and not args.ignore_all and not args.ignore_mute:
807
+ pre_mute_data = filtered_data
808
+ filtered_data, muted_count = filter_muted(filtered_data, mute_patterns)
809
+
690
810
  if args.summary:
691
811
  output = format_summary(
692
812
  filtered_data,
@@ -701,6 +821,10 @@ def main():
701
821
  )
702
822
  else:
703
823
  tasks = flatten_by_priority(filtered_data)
824
+ all_muted = False
825
+ if not tasks and muted_count > 0:
826
+ tasks = flatten_by_priority(pre_mute_data)
827
+ all_muted = True
704
828
  if randomise:
705
829
  random.shuffle(tasks)
706
830
  if limit:
@@ -716,6 +840,10 @@ def main():
716
840
  for line, filepath in files_to_edit:
717
841
  subprocess.run([editor, f"+{line}", filepath])
718
842
 
843
+ if all_muted:
844
+ print("Only muted tasks remain:\n")
845
+ muted_count = 0
846
+
719
847
  output = format_tasks(
720
848
  tasks,
721
849
  get_terminal_width(),
@@ -723,3 +851,9 @@ def main():
723
851
  )
724
852
 
725
853
  print(output)
854
+
855
+ if muted_count > 0:
856
+ noun = "tasks"
857
+ if muted_count == 1:
858
+ noun = "task"
859
+ print(f"\n({muted_count} {noun} muted)")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes