todo-jsonl 1.0.7 → 1.0.8

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 (4) hide show
  1. checksums.yaml +4 -4
  2. data/bin/todo +2 -2
  3. data/bin/todo.rb +414 -407
  4. metadata +8 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e5df379407838c3c11f109e146fdddcb560fffdfc30ed29885913b8362ae565
4
- data.tar.gz: 4dafcccddbc47174db8dfa37c09e508a10943e5b94ef339784a1e4097dd0443e
3
+ metadata.gz: 01a891896f26ef6117a6cbc3f62c7f1473e83c6c7cb12a93b79134be5d25bd6e
4
+ data.tar.gz: 9fed56888aca1e9330e1da951ea1457af30849f46a4cca4335867a60fb039b1c
5
5
  SHA512:
6
- metadata.gz: 936dcca49f95ba126bb7b072f09ae4f3e6e05bfc31635b9d7acdee7b7393c5495e2e1f9ccb5cbab0a52d31509b3b94016cfbf102aad1e6ceaea292a890b2c777
7
- data.tar.gz: d4c0ca6ed381cd2a38f40c88a3c451b45851c35dfeebe4afde82be5022c17d5b45d5e43c954c52418324df38bf319d4719bbe214b039521aa6b8fca6d742712d
6
+ metadata.gz: 13f21e68b1e5f1b7d11c8f351c225c79d71759baddd5ac4af00b238a7b1868c6ea0542d22b1d346b04dd6e5e3c87a9bb221f4743fba5b583ed8f58a45d5f2d28
7
+ data.tar.gz: c28c4622a9897633c28ec629616386d985ffbc9ccdbf890e0d726fd9673ebbaef3fca3a921b8bd9089fbb985088731029717609f15913f827e1f0d713c592647
data/bin/todo CHANGED
@@ -1,2 +1,2 @@
1
- #!/usr/bin/env ruby
2
- require_relative 'todo.rb'
1
+ #!/usr/bin/env ruby
2
+ require_relative 'todo.rb'
data/bin/todo.rb CHANGED
@@ -1,407 +1,414 @@
1
- #!/usr/bin/env ruby
2
-
3
- # todo.rb - todo list manager on the command-line
4
- # inspired by todo.txt using the jsonl format.
5
- #
6
- # Copyright (c) 2020-2021 Gabor Bata
7
- #
8
- # Permission is hereby granted, free of charge, to any person
9
- # obtaining a copy of this software and associated documentation files
10
- # (the "Software"), to deal in the Software without restriction,
11
- # including without limitation the rights to use, copy, modify, merge,
12
- # publish, distribute, sublicense, and/or sell copies of the Software,
13
- # and to permit persons to whom the Software is furnished to do so,
14
- # subject to the following conditions:
15
- #
16
- # The above copyright notice and this permission notice shall be
17
- # included in all copies or substantial portions of the Software.
18
- #
19
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
- # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
- # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
- # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
23
- # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
24
- # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
- # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
- # SOFTWARE.
27
-
28
- require 'json'
29
- require 'date'
30
-
31
- class Todo
32
- COLOR_CODES = {
33
- black: 30,
34
- red: 31,
35
- green: 32,
36
- yellow: 33,
37
- blue: 34,
38
- magenta: 35,
39
- cyan: 36,
40
- white: 37
41
- }
42
-
43
- STATES = {
44
- 'new' => '[ ]',
45
- 'done' => '[x]',
46
- 'started' => '[>]',
47
- 'blocked' => '[!]',
48
- 'default' => '[?]'
49
- }
50
-
51
- ORDER = {
52
- 'new' => 3,
53
- 'done' => 4,
54
- 'started' => 2,
55
- 'blocked' => 1,
56
- 'default' => 100
57
- }
58
-
59
- COLORS = {
60
- 'new' => :white,
61
- 'done' => :blue,
62
- 'started' => :green,
63
- 'blocked' => :yellow,
64
- 'default' => :magenta
65
- }
66
-
67
- DATE_FORMAT = '%Y-%m-%d'
68
- DUE_DATE_DAYS_SIMPLE = ['today', 'tomorrow']
69
- DUE_DATE_TAG_PATTERN = /(^| )due:([a-zA-Z0-9-]+)/
70
- CONTEXT_TAG_PATTERN = /(^| )[@+][\w-]+/
71
- PRIORITY_FLAG = '*'
72
- TODO_FILE = File.join(Dir.home, 'todo.jsonl')
73
-
74
- def execute(arguments)
75
- begin
76
- setup
77
- action = arguments.first
78
- args = arguments.drop(1)
79
- case action
80
- when 'add'
81
- raise action + ' command requires at least one parameter' if args.nil? || args.empty?
82
- add(args.join(' '))
83
- when 'start'
84
- args.length > 0 ? change_state(args.first.to_i, 'started', args.drop(1).join(' ')) : list(nil, [':started'])
85
- when 'done'
86
- args.length > 0 ? change_state(args.first.to_i, 'done', args.drop(1).join(' ')) : list(nil, [':done'])
87
- when 'block'
88
- args.length > 0 ? change_state(args.first.to_i, 'blocked', args.drop(1).join(' ')) : list(nil, [':blocked'])
89
- when 'reset'
90
- args.length > 0 ? change_state(args.first.to_i, 'new', args.drop(1).join(' ')) : list(nil, [':new'])
91
- when 'prio'
92
- raise action + ' command requires at least one parameter' if args.length < 1
93
- set_priority(args.first.to_i, args.drop(1).join(' '))
94
- when 'due'
95
- raise action + ' command requires at least one parameter' if args.length < 1
96
- due_date(args.first.to_i, args.drop(1).join(' '))
97
- when 'append'
98
- raise action + ' command requires at least two parameters' if args.length < 2
99
- append(args.first.to_i, args.drop(1).join(' '))
100
- when 'rename'
101
- raise action + ' command requires at least two parameters' if args.length < 2
102
- rename(args.first.to_i, args.drop(1).join(' '))
103
- when 'del'
104
- raise action + ' command requires exactly one parameter' if args.length != 1
105
- delete(args.first.to_i)
106
- when 'note'
107
- raise action + ' command requires at least two parameters' if args.length < 2
108
- add_note(args.first.to_i, args.drop(1).join(' '))
109
- when 'delnote'
110
- raise action + ' command requires one or two parameters' if args.length < 1 || args.length > 2
111
- delete_note(args.first.to_i, args[1])
112
- when 'list'
113
- list(nil, args)
114
- when 'show'
115
- raise action + ' command requires exactly one parameter' if args.length != 1
116
- show(args.first.to_i)
117
- when 'help'
118
- raise action + ' command has no parameters' if args.length > 0
119
- puts usage
120
- when 'repl'
121
- raise action + ' command has no parameters' if args.length > 0
122
- start_repl
123
- when 'cleanup'
124
- raise action + ' command requires at least one parameter' if args.nil? || args.empty?
125
- cleanup(args)
126
- else
127
- list(nil, arguments)
128
- end
129
- rescue StandardError => error
130
- puts "#{colorize('ERROR:', :red)} #{error}"
131
- end
132
- self
133
- end
134
-
135
- private
136
-
137
- def usage
138
- <<~USAGE
139
- Usage: todo <command> <arguments>
140
-
141
- Commands:
142
- * add <text> add new task
143
- * start <tasknumber> [text] mark task as started, with optional note
144
- * done <tasknumber> [text] mark task as completed, with optional note
145
- * block <tasknumber> [text] mark task as blocked, with optional note
146
- * reset <tasknumber> [text] reset task to new state, with optional note
147
- * prio <tasknumber> [text] toggle high priority flag, with optional note
148
- * due <tasknumber> [date] set/unset due date (in YYYY-MM-DD format)
149
-
150
- * append <tasknumber> <text> append text to task title
151
- * rename <tasknumber> <text> rename task
152
- * del <tasknumber> delete task
153
- * note <tasknumber> <text> add note to task
154
- * delnote <tasknumber> [number] delete a specific or all notes from task
155
-
156
- * list <regex> [regex...] list tasks (only active tasks by default)
157
- * show <tasknumber> show all task details
158
- * repl enter read-eval-print loop mode
159
- * cleanup <regex> [regex...] cleanup completed tasks by regex
160
- * help this help screen
161
-
162
- With list command the following pre-defined queries can be also used:
163
- #{@queries.keys.each_with_index.map { |k, i| (i == 8 ? "\n" : '') + k }.join(', ')}
164
-
165
- Due dates can be also added via tags in task title: "due:YYYY-MM-DD"
166
- In addition to formatted dates, you can use date synonyms:
167
- "due:today", "due:tomorrow", and day names e.g. "due:monday" or "due:tue"
168
-
169
- Legend: #{STATES.select { |k, v| k != 'default' }.map { |k, v| "#{k} #{v}" }.join(', ') }, priority #{PRIORITY_FLAG}
170
-
171
- Todo file: #{TODO_FILE}
172
- USAGE
173
- end
174
-
175
- def setup
176
- @today = Date.today
177
- next_7_days = (0..6).map { |day| @today + day }
178
- @due_date_days = next_7_days.map { |day| day.strftime('%A').downcase }
179
- due_dates_for_queries = next_7_days.map { |day| day.strftime(DATE_FORMAT) }
180
- recent_date = (@today - 7).strftime(DATE_FORMAT)
181
- @queries = {
182
- ':active' => lambda { |task| /(new|started|blocked)/.match(task[:state]) },
183
- ':done' => lambda { |task| 'done' == task[:state] },
184
- ':blocked' => lambda { |task| 'blocked' == task[:state] },
185
- ':started' => lambda { |task| 'started' == task[:state] },
186
- ':new' => lambda { |task| 'new' == task[:state] },
187
- ':all' => lambda { |task| /\w+/.match(task[:state]) },
188
- ':priority' => lambda { |task| task[:priority] },
189
- ':note' => lambda { |task| task[:note] && !task[:note].empty? },
190
- ':today' => lambda { |task| due_dates_for_queries[0] == task[:due] },
191
- ':tomorrow' => lambda { |task| due_dates_for_queries[1] == task[:due] },
192
- ':next7days' => lambda { |task| /(#{due_dates_for_queries.join('|')})/.match(task[:due]) },
193
- ':overdue' => lambda { |task| task[:due] && task[:due] < due_dates_for_queries[0] },
194
- ':due' => lambda { |task| task[:due] },
195
- ':recent' => lambda { |task| recent_date <= task[:modified] }
196
- }
197
- end
198
-
199
- def load_tasks(item_to_check = nil)
200
- count = 0
201
- tasks = {}
202
- if File.exist?(TODO_FILE)
203
- File.open(TODO_FILE, 'r:UTF-8') do |file|
204
- file.each_line do |line|
205
- next if line.strip == ''
206
- count += 1
207
- tasks[count] = JSON.parse(line.chomp, :symbolize_names => true)
208
- end
209
- end
210
- end
211
- raise "#{item_to_check}: No such todo" if item_to_check && !tasks.has_key?(item_to_check)
212
- tasks
213
- end
214
-
215
- def write_tasks(tasks)
216
- File.open(TODO_FILE, 'w:UTF-8') do |file|
217
- tasks.keys.sort.each { |key| file.write(JSON.generate(tasks[key]) + "\n") }
218
- end
219
- end
220
-
221
- def postprocess_tags(task)
222
- match_data = task[:title].match(DUE_DATE_TAG_PATTERN)
223
- if match_data
224
- task[:title] = task[:title].gsub(DUE_DATE_TAG_PATTERN, '')
225
- task[:due] = convert_due_date(match_data[2])
226
- end
227
- raise 'title must not be empty' if task[:title].empty?
228
- end
229
-
230
- def add(text)
231
- task = { state: 'new', title: text, modified: @today.strftime(DATE_FORMAT) }
232
- postprocess_tags(task)
233
- File.open(TODO_FILE, 'a:UTF-8') { |file| file.write(JSON.generate(task) + "\n") }
234
- list
235
- end
236
-
237
- def update_task(item, post_action, update_function)
238
- tasks = load_tasks(item)
239
- update_function.call(tasks[item])
240
- tasks[item][:modified] = @today.strftime(DATE_FORMAT)
241
- write_tasks(tasks)
242
- case post_action
243
- when :show then show(item, tasks)
244
- when :list then list(tasks)
245
- end
246
- end
247
-
248
- def append(item, text)
249
- update_task(item, :list, lambda do |task|
250
- task[:title] = [task[:title], text].join(' ')
251
- postprocess_tags(task)
252
- end)
253
- end
254
-
255
- def rename(item, text)
256
- update_task(item, :list, lambda do |task|
257
- task[:title] = text
258
- postprocess_tags(task)
259
- end)
260
- end
261
-
262
- def delete(item)
263
- tasks = load_tasks(item)
264
- tasks.delete(item)
265
- write_tasks(tasks)
266
- list
267
- end
268
-
269
- def change_state(item, state, note = nil)
270
- update_task(item, :list, lambda do |task|
271
- task[:state] = state
272
- if !note.nil? && !note.empty?
273
- task[:note] ||= []
274
- task[:note].push(note)
275
- end
276
- end)
277
- end
278
-
279
- def set_priority(item, note = nil)
280
- update_task(item, :list, lambda do |task|
281
- task[:priority] = !task[:priority]
282
- task.delete(:priority) if !task[:priority]
283
- if !note.nil? && !note.empty?
284
- task[:note] ||= []
285
- task[:note].push(note)
286
- end
287
- end)
288
- end
289
-
290
- def due_date(item, date = '')
291
- update_task(item, :list, lambda do |task|
292
- task[:due] = convert_due_date(date)
293
- task.delete(:due) if task[:due].nil?
294
- end)
295
- end
296
-
297
- def list(tasks = nil, patterns = nil)
298
- tasks ||= load_tasks
299
- task_indent = [tasks.keys.max.to_s.size, 4].max
300
- patterns ||= []
301
- patterns += [':active'] if (patterns & [':active', ':done', ':blocked', ':started', ':new', ':all']).empty?
302
- items = filter_tasks(tasks, patterns).sort_by do |num, task|
303
- [
304
- task[:priority] && task[:state] != 'done' ? 0 : 1,
305
- ORDER[task[:state] || 'default'],
306
- task[:state] != 'done' ? task[:due] || 'n/a' : task[:modified],
307
- num
308
- ]
309
- end
310
- items.each do |num, task|
311
- state = task[:state] || 'default'
312
- display_state = colorize(STATES[state], COLORS[state])
313
- title = task[:title].gsub(CONTEXT_TAG_PATTERN) do |tag|
314
- (tag.start_with?(' ') ? ' ' : '') + colorize(tag.strip, :cyan)
315
- end
316
- priority_flag = task[:priority] && state != 'done' ? colorize(PRIORITY_FLAG, :red) : ' '
317
- due_date = ''
318
- if task[:due] && state != 'done'
319
- date_diff = (Date.strptime(task[:due], DATE_FORMAT) - @today).to_i
320
- if date_diff < 0
321
- due_date = colorize("(#{date_diff.abs}d overdue)", :red)
322
- elsif date_diff == 0 || date_diff == 1
323
- due_date = colorize("(#{DUE_DATE_DAYS_SIMPLE[date_diff]})", :yellow)
324
- else
325
- due_date = colorize("(#{@due_date_days[date_diff] || task[:due]})", :magenta) if date_diff > 1
326
- end
327
- due_date = ' ' + due_date
328
- end
329
- puts "#{num.to_s.rjust(task_indent)}:#{priority_flag}#{display_state} #{title}#{due_date}"
330
- end
331
- puts 'No todos found' if items.empty?
332
- end
333
-
334
- def add_note(item, text)
335
- update_task(item, :show, lambda do |task|
336
- task[:note] ||= []
337
- task[:note].push(text)
338
- end)
339
- end
340
-
341
- def delete_note(item, num = nil)
342
- update_task(item, :show, lambda do |task|
343
- if num.to_s.empty?
344
- task.delete(:note)
345
- else
346
- raise "#{num.to_i}: Note does not exist" if num.to_i <= 0 || task[:note].to_a.size < num.to_i
347
- task[:note].delete_at(num.to_i - 1)
348
- task.delete(:note) if task[:note].empty?
349
- end
350
- end)
351
- end
352
-
353
- def show(item, tasks = nil)
354
- tasks ||= load_tasks(item)
355
- tasks[item].each do |k, v|
356
- v = "\n" + v.each_with_index.
357
- map { |n, i| v.size > 1 ? "#{(i + 1).to_s.rjust(v.size.to_s.size)}: #{n}" : n }.
358
- join("\n") if v.is_a?(Array)
359
- puts "#{colorize(k.to_s.rjust(10) + ':', :cyan)} #{v}"
360
- end
361
- end
362
-
363
- def start_repl
364
- command = ''
365
- while !['exit', 'quit'].include?(command)
366
- if ['clear', 'cls'].include?(command)
367
- print "\e[H\e[2J"
368
- else
369
- execute(command == 'repl' ? [] : command.split(/\s+/))
370
- end
371
- print "\ntodo> "
372
- command = STDIN.gets.chomp.strip
373
- end
374
- end
375
-
376
- def cleanup(patterns)
377
- tasks = load_tasks
378
- patterns = [':done'] + patterns.to_a
379
- items = filter_tasks(tasks, patterns)
380
- items.each_key { |num| tasks.delete(num) }
381
- write_tasks(tasks)
382
- puts "Deleted #{items.size} todo(s)"
383
- end
384
-
385
- def filter_tasks(tasks, patterns)
386
- patterns = patterns.uniq
387
- tasks.select do |num, task|
388
- patterns.all? do |pattern|
389
- @queries[pattern] ? @queries[pattern].call(task) : /#{pattern}/ix.match(task[:title])
390
- end
391
- end
392
- end
393
-
394
- def colorize(text, color)
395
- "\e[#{COLOR_CODES[color] || 37}m#{text}\e[0m"
396
- end
397
-
398
- def convert_due_date(date)
399
- day_index = @due_date_days.index(date.to_s.downcase) ||
400
- DUE_DATE_DAYS_SIMPLE.index(date.to_s.downcase) ||
401
- @due_date_days.map { |day| day[0..2] }.index(date.to_s.downcase)
402
- return (@today + day_index).strftime(DATE_FORMAT) if day_index
403
- date.nil? || date.empty? ? nil : Date.strptime(date, DATE_FORMAT).strftime(DATE_FORMAT)
404
- end
405
- end
406
-
407
- Todo.new.execute(ARGV)
1
+ #!/usr/bin/env ruby
2
+
3
+ # todo.rb - todo list manager on the command-line
4
+ # inspired by todo.txt using the jsonl format.
5
+ #
6
+ # Copyright (c) 2020-2021 Gabor Bata
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person
9
+ # obtaining a copy of this software and associated documentation files
10
+ # (the "Software"), to deal in the Software without restriction,
11
+ # including without limitation the rights to use, copy, modify, merge,
12
+ # publish, distribute, sublicense, and/or sell copies of the Software,
13
+ # and to permit persons to whom the Software is furnished to do so,
14
+ # subject to the following conditions:
15
+ #
16
+ # The above copyright notice and this permission notice shall be
17
+ # included in all copies or substantial portions of the Software.
18
+ #
19
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
23
+ # BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
24
+ # ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
25
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ # SOFTWARE.
27
+
28
+ require 'json'
29
+ require 'date'
30
+
31
+ class Todo
32
+ COLOR_CODES = {
33
+ black: 30,
34
+ red: 31,
35
+ green: 32,
36
+ yellow: 33,
37
+ blue: 34,
38
+ magenta: 35,
39
+ cyan: 36,
40
+ white: 37
41
+ }
42
+
43
+ STATES = {
44
+ 'new' => '[ ]',
45
+ 'done' => '[x]',
46
+ 'started' => '[>]',
47
+ 'blocked' => '[!]',
48
+ 'waiting' => '[@]',
49
+ 'default' => '[?]'
50
+ }
51
+
52
+ ORDER = {
53
+ 'new' => 3,
54
+ 'done' => 5,
55
+ 'started' => 2,
56
+ 'blocked' => 1,
57
+ 'waiting' => 4,
58
+ 'default' => 100
59
+ }
60
+
61
+ COLORS = {
62
+ 'new' => :white,
63
+ 'done' => :blue,
64
+ 'started' => :green,
65
+ 'blocked' => :yellow,
66
+ 'waiting' => :cyan,
67
+ 'default' => :magenta
68
+ }
69
+
70
+ DATE_FORMAT = '%Y-%m-%d'
71
+ DUE_DATE_DAYS_SIMPLE = ['today', 'tomorrow']
72
+ DUE_DATE_TAG_PATTERN = /(^| )due:([a-zA-Z0-9-]+)/
73
+ CONTEXT_TAG_PATTERN = /(^| )[@+][\w-]+/
74
+ PRIORITY_FLAG = '*'
75
+ TODO_FILE = File.join(Dir.home, 'todo.jsonl')
76
+
77
+ def execute(arguments)
78
+ begin
79
+ setup
80
+ action = arguments.first
81
+ args = arguments.drop(1)
82
+ case action
83
+ when 'add'
84
+ raise action + ' command requires at least one parameter' if args.empty?
85
+ add(args.join(' '))
86
+ when 'start'
87
+ args.length > 0 ? change_state(args.first.to_i, 'started', args.drop(1).join(' ')) : list(nil, [':started'])
88
+ when 'done'
89
+ args.length > 0 ? change_state(args.first.to_i, 'done', args.drop(1).join(' ')) : list(nil, [':done'])
90
+ when 'block'
91
+ args.length > 0 ? change_state(args.first.to_i, 'blocked', args.drop(1).join(' ')) : list(nil, [':blocked'])
92
+ when 'wait'
93
+ args.length > 0 ? change_state(args.first.to_i, 'waiting', args.drop(1).join(' ')) : list(nil, [':waiting'])
94
+ when 'reset'
95
+ args.length > 0 ? change_state(args.first.to_i, 'new', args.drop(1).join(' ')) : list(nil, [':new'])
96
+ when 'prio'
97
+ raise action + ' command requires at least one parameter' if args.length < 1
98
+ set_priority(args.first.to_i, args.drop(1).join(' '))
99
+ when 'due'
100
+ raise action + ' command requires at least one parameter' if args.length < 1
101
+ due_date(args.first.to_i, args.drop(1).join(' '))
102
+ when 'append'
103
+ raise action + ' command requires at least two parameters' if args.length < 2
104
+ append(args.first.to_i, args.drop(1).join(' '))
105
+ when 'rename'
106
+ raise action + ' command requires at least two parameters' if args.length < 2
107
+ rename(args.first.to_i, args.drop(1).join(' '))
108
+ when 'del'
109
+ raise action + ' command requires exactly one parameter' if args.length != 1
110
+ delete(args.first.to_i)
111
+ when 'note'
112
+ raise action + ' command requires at least two parameters' if args.length < 2
113
+ add_note(args.first.to_i, args.drop(1).join(' '))
114
+ when 'delnote'
115
+ raise action + ' command requires one or two parameters' if args.length < 1 || args.length > 2
116
+ delete_note(args.first.to_i, args[1])
117
+ when 'list'
118
+ list(nil, args)
119
+ when 'show'
120
+ raise action + ' command requires exactly one parameter' if args.length != 1
121
+ show(args.first.to_i)
122
+ when 'help'
123
+ raise action + ' command has no parameters' if args.length > 0
124
+ puts usage
125
+ when 'repl'
126
+ raise action + ' command has no parameters' if args.length > 0
127
+ start_repl
128
+ when 'cleanup'
129
+ raise action + ' command requires at least one parameter' if args.empty?
130
+ cleanup(args)
131
+ else
132
+ list(nil, arguments)
133
+ end
134
+ rescue StandardError => error
135
+ puts "#{colorize('ERROR:', :red)} #{error}"
136
+ end
137
+ self
138
+ end
139
+
140
+ private
141
+
142
+ def usage
143
+ <<~USAGE
144
+ Usage: todo <command> <arguments>
145
+
146
+ Commands:
147
+ * add <text> add new task
148
+ * start <tasknumber> [text] mark task as started, with optional note
149
+ * done <tasknumber> [text] mark task as completed, with optional note
150
+ * block <tasknumber> [text] mark task as blocked, with optional note
151
+ * wait <tasknumber> [text] mark task as waiting, with optional note
152
+ * reset <tasknumber> [text] reset task to new state, with optional note
153
+ * prio <tasknumber> [text] toggle high priority flag, with optional note
154
+ * due <tasknumber> [date] set/unset due date (in YYYY-MM-DD format)
155
+
156
+ * append <tasknumber> <text> append text to task title
157
+ * rename <tasknumber> <text> rename task
158
+ * del <tasknumber> delete task
159
+ * note <tasknumber> <text> add note to task
160
+ * delnote <tasknumber> [number] delete a specific or all notes from task
161
+
162
+ * list <regex> [regex...] list tasks (only active tasks by default)
163
+ * show <tasknumber> show all task details
164
+ * repl enter read-eval-print loop mode
165
+ * cleanup <regex> [regex...] cleanup completed tasks by regex
166
+ * help this help screen
167
+
168
+ With list command the following pre-defined queries can be also used:
169
+ #{@queries.keys.each_with_index.map { |k, i| (i == 8 ? "\n" : '') + k }.join(', ')}
170
+
171
+ Due dates can be also added via tags in task title: "due:YYYY-MM-DD"
172
+ In addition to formatted dates, you can use date synonyms:
173
+ "due:today", "due:tomorrow", and day names e.g. "due:monday" or "due:tue"
174
+
175
+ Legend: #{STATES.select { |k, v| k != 'default' }.map { |k, v| "#{k} #{v}" }.join(', ') }, priority #{PRIORITY_FLAG}
176
+
177
+ Todo file: #{TODO_FILE}
178
+ USAGE
179
+ end
180
+
181
+ def setup
182
+ @today = Date.today
183
+ next_7_days = (0..6).map { |day| @today + day }
184
+ @due_date_days = next_7_days.map { |day| day.strftime('%A').downcase }
185
+ due_dates_for_queries = next_7_days.map { |day| day.strftime(DATE_FORMAT) }
186
+ recent_date = (@today - 7).strftime(DATE_FORMAT)
187
+ @queries = {
188
+ ':active' => lambda { |task| /(new|started|blocked|waiting)/.match(task[:state]) },
189
+ ':done' => lambda { |task| 'done' == task[:state] },
190
+ ':blocked' => lambda { |task| 'blocked' == task[:state] },
191
+ ':waiting' => lambda { |task| 'waiting' == task[:state] },
192
+ ':started' => lambda { |task| 'started' == task[:state] },
193
+ ':new' => lambda { |task| 'new' == task[:state] },
194
+ ':all' => lambda { |task| /\w+/.match(task[:state]) },
195
+ ':priority' => lambda { |task| task[:priority] },
196
+ ':note' => lambda { |task| task[:note] && !task[:note].empty? },
197
+ ':today' => lambda { |task| due_dates_for_queries[0] == task[:due] },
198
+ ':tomorrow' => lambda { |task| due_dates_for_queries[1] == task[:due] },
199
+ ':next7days' => lambda { |task| /(#{due_dates_for_queries.join('|')})/.match(task[:due]) },
200
+ ':overdue' => lambda { |task| task[:due] && task[:due] < due_dates_for_queries[0] },
201
+ ':due' => lambda { |task| task[:due] },
202
+ ':recent' => lambda { |task| recent_date <= task[:modified] }
203
+ }
204
+ end
205
+
206
+ def load_tasks(item_to_check = nil)
207
+ count = 0
208
+ tasks = {}
209
+ if File.exist?(TODO_FILE)
210
+ File.open(TODO_FILE, 'r:UTF-8') do |file|
211
+ file.each_line do |line|
212
+ next if line.strip == ''
213
+ count += 1
214
+ tasks[count] = JSON.parse(line.chomp, :symbolize_names => true)
215
+ end
216
+ end
217
+ end
218
+ raise "#{item_to_check}: No such todo" if item_to_check && !tasks.has_key?(item_to_check)
219
+ tasks
220
+ end
221
+
222
+ def write_tasks(tasks)
223
+ File.open(TODO_FILE, 'w:UTF-8') do |file|
224
+ tasks.keys.sort.each { |key| file.write(JSON.generate(tasks[key]) + "\n") }
225
+ end
226
+ end
227
+
228
+ def postprocess_tags(task)
229
+ match_data = task[:title].match(DUE_DATE_TAG_PATTERN)
230
+ if match_data
231
+ task[:title] = task[:title].gsub(DUE_DATE_TAG_PATTERN, '')
232
+ task[:due] = convert_due_date(match_data[2])
233
+ end
234
+ raise 'title must not be empty' if task[:title].empty?
235
+ end
236
+
237
+ def add(text)
238
+ task = { state: 'new', title: text, modified: @today.strftime(DATE_FORMAT) }
239
+ postprocess_tags(task)
240
+ File.open(TODO_FILE, 'a:UTF-8') { |file| file.write(JSON.generate(task) + "\n") }
241
+ list
242
+ end
243
+
244
+ def update_task(item, post_action, update_function)
245
+ tasks = load_tasks(item)
246
+ update_function.call(tasks[item])
247
+ tasks[item][:modified] = @today.strftime(DATE_FORMAT)
248
+ write_tasks(tasks)
249
+ case post_action
250
+ when :show then show(item, tasks)
251
+ when :list then list(tasks)
252
+ end
253
+ end
254
+
255
+ def append(item, text)
256
+ update_task(item, :list, lambda do |task|
257
+ task[:title] = [task[:title], text].join(' ')
258
+ postprocess_tags(task)
259
+ end)
260
+ end
261
+
262
+ def rename(item, text)
263
+ update_task(item, :list, lambda do |task|
264
+ task[:title] = text
265
+ postprocess_tags(task)
266
+ end)
267
+ end
268
+
269
+ def delete(item)
270
+ tasks = load_tasks(item)
271
+ tasks.delete(item)
272
+ write_tasks(tasks)
273
+ list
274
+ end
275
+
276
+ def change_state(item, state, note = nil)
277
+ update_task(item, :list, lambda do |task|
278
+ task[:state] = state
279
+ if !note.nil? && !note.empty?
280
+ task[:note] ||= []
281
+ task[:note].push(note)
282
+ end
283
+ end)
284
+ end
285
+
286
+ def set_priority(item, note = nil)
287
+ update_task(item, :list, lambda do |task|
288
+ task[:priority] = !task[:priority]
289
+ task.delete(:priority) if !task[:priority]
290
+ if !note.nil? && !note.empty?
291
+ task[:note] ||= []
292
+ task[:note].push(note)
293
+ end
294
+ end)
295
+ end
296
+
297
+ def due_date(item, date = '')
298
+ update_task(item, :list, lambda do |task|
299
+ task[:due] = convert_due_date(date)
300
+ task.delete(:due) if task[:due].nil?
301
+ end)
302
+ end
303
+
304
+ def list(tasks = nil, patterns = nil)
305
+ tasks ||= load_tasks
306
+ task_indent = [tasks.keys.max.to_s.size, 4].max
307
+ patterns ||= []
308
+ patterns += [':active'] if (patterns & [':active', ':done', ':blocked', ':started', ':new', ':all', ':waiting']).empty?
309
+ items = filter_tasks(tasks, patterns).sort_by do |num, task|
310
+ [
311
+ task[:priority] && task[:state] != 'done' ? 0 : 1,
312
+ ORDER[task[:state] || 'default'] || ORDER['default'],
313
+ task[:state] != 'done' ? task[:due] || 'n/a' : task[:modified],
314
+ num
315
+ ]
316
+ end
317
+ items.each do |num, task|
318
+ state = task[:state] || 'default'
319
+ display_state = colorize(STATES[state], COLORS[state])
320
+ title = task[:title].gsub(CONTEXT_TAG_PATTERN) do |tag|
321
+ (tag.start_with?(' ') ? ' ' : '') + colorize(tag.strip, :cyan)
322
+ end
323
+ priority_flag = task[:priority] && state != 'done' ? colorize(PRIORITY_FLAG, :red) : ' '
324
+ due_date = ''
325
+ if task[:due] && state != 'done'
326
+ date_diff = (Date.strptime(task[:due], DATE_FORMAT) - @today).to_i
327
+ if date_diff < 0
328
+ due_date = colorize("(#{date_diff.abs}d overdue)", :red)
329
+ elsif date_diff == 0 || date_diff == 1
330
+ due_date = colorize("(#{DUE_DATE_DAYS_SIMPLE[date_diff]})", :yellow)
331
+ else
332
+ due_date = colorize("(#{@due_date_days[date_diff] || task[:due]})", :magenta) if date_diff > 1
333
+ end
334
+ due_date = ' ' + due_date
335
+ end
336
+ puts "#{num.to_s.rjust(task_indent)}:#{priority_flag}#{display_state} #{title}#{due_date}"
337
+ end
338
+ puts 'No todos found' if items.empty?
339
+ end
340
+
341
+ def add_note(item, text)
342
+ update_task(item, :show, lambda do |task|
343
+ task[:note] ||= []
344
+ task[:note].push(text)
345
+ end)
346
+ end
347
+
348
+ def delete_note(item, num = nil)
349
+ update_task(item, :show, lambda do |task|
350
+ if num.to_s.empty?
351
+ task.delete(:note)
352
+ else
353
+ raise "#{num.to_i}: Note does not exist" if num.to_i <= 0 || task[:note].to_a.size < num.to_i
354
+ task[:note].delete_at(num.to_i - 1)
355
+ task.delete(:note) if task[:note].empty?
356
+ end
357
+ end)
358
+ end
359
+
360
+ def show(item, tasks = nil)
361
+ tasks ||= load_tasks(item)
362
+ tasks[item].each do |k, v|
363
+ v = "\n" + v.each_with_index.
364
+ map { |n, i| v.size > 1 ? "#{(i + 1).to_s.rjust(v.size.to_s.size)}: #{n}" : n }.
365
+ join("\n") if v.is_a?(Array)
366
+ puts "#{colorize(k.to_s.rjust(10) + ':', :cyan)} #{v}"
367
+ end
368
+ end
369
+
370
+ def start_repl
371
+ command = ''
372
+ while !['exit', 'quit'].include?(command)
373
+ if ['clear', 'cls'].include?(command)
374
+ print "\e[H\e[2J"
375
+ else
376
+ execute(command == 'repl' ? [] : command.split(/\s+/))
377
+ end
378
+ print "\ntodo> "
379
+ command = STDIN.gets.chomp.strip
380
+ end
381
+ end
382
+
383
+ def cleanup(patterns)
384
+ tasks = load_tasks
385
+ patterns = [':done'] + patterns.to_a
386
+ items = filter_tasks(tasks, patterns)
387
+ items.each_key { |num| tasks.delete(num) }
388
+ write_tasks(tasks)
389
+ puts "Deleted #{items.size} todo(s)"
390
+ end
391
+
392
+ def filter_tasks(tasks, patterns)
393
+ patterns = patterns.uniq
394
+ tasks.select do |num, task|
395
+ patterns.all? do |pattern|
396
+ @queries[pattern] ? @queries[pattern].call(task) : /#{pattern}/ix.match(task[:title])
397
+ end
398
+ end
399
+ end
400
+
401
+ def colorize(text, color)
402
+ "\e[#{COLOR_CODES[color] || 37}m#{text}\e[0m"
403
+ end
404
+
405
+ def convert_due_date(date)
406
+ day_index = @due_date_days.index(date.to_s.downcase) ||
407
+ DUE_DATE_DAYS_SIMPLE.index(date.to_s.downcase) ||
408
+ @due_date_days.map { |day| day[0..2] }.index(date.to_s.downcase)
409
+ return (@today + day_index).strftime(DATE_FORMAT) if day_index
410
+ date.nil? || date.empty? ? nil : Date.strptime(date, DATE_FORMAT).strftime(DATE_FORMAT)
411
+ end
412
+ end
413
+
414
+ Todo.new.execute(ARGV)
metadata CHANGED
@@ -1,17 +1,17 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: todo-jsonl
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.7
4
+ version: 1.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gabor Bata
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-03-26 00:00:00.000000000 Z
11
+ date: 2021-08-19 00:00:00.000000000 Z
12
12
  dependencies: []
13
- description:
14
- email:
13
+ description:
14
+ email:
15
15
  executables:
16
16
  - todo.rb
17
17
  - todo
@@ -24,7 +24,7 @@ homepage: https://github.com/gaborbata/todo
24
24
  licenses:
25
25
  - MIT
26
26
  metadata: {}
27
- post_install_message:
27
+ post_install_message:
28
28
  rdoc_options: []
29
29
  require_paths:
30
30
  - lib
@@ -39,8 +39,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
41
  requirements: []
42
- rubygems_version: 3.0.3
43
- signing_key:
42
+ rubygems_version: 3.1.6
43
+ signing_key:
44
44
  specification_version: 4
45
45
  summary: todo list manager on the command-line inspired by todo.txt using the jsonl
46
46
  format