doing 2.0.23 → 2.1.1pre

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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +19 -15
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/CHANGELOG.md +40 -1
  6. data/Gemfile.lock +8 -1
  7. data/README.md +7 -1
  8. data/Rakefile +23 -4
  9. data/bin/doing +431 -256
  10. data/doc/Array.html +354 -1
  11. data/doc/Doing/Color.html +104 -92
  12. data/doc/Doing/Completion.html +216 -0
  13. data/doc/Doing/Configuration.html +340 -5
  14. data/doc/Doing/Content.html +229 -0
  15. data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  16. data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  17. data/doc/Doing/Errors/DoingStandardError.html +1 -1
  18. data/doc/Doing/Errors/EmptyInput.html +1 -1
  19. data/doc/Doing/Errors/NoResults.html +1 -1
  20. data/doc/Doing/Errors/PluginException.html +1 -1
  21. data/doc/Doing/Errors/UserCancelled.html +1 -1
  22. data/doc/Doing/Errors/WrongCommand.html +1 -1
  23. data/doc/Doing/Errors.html +1 -1
  24. data/doc/Doing/Hooks.html +1 -1
  25. data/doc/Doing/Item.html +337 -49
  26. data/doc/Doing/Items.html +444 -35
  27. data/doc/Doing/LogAdapter.html +139 -51
  28. data/doc/Doing/Note.html +253 -22
  29. data/doc/Doing/Pager.html +74 -36
  30. data/doc/Doing/Plugins.html +1 -1
  31. data/doc/Doing/Prompt.html +674 -0
  32. data/doc/Doing/Section.html +354 -0
  33. data/doc/Doing/Util.html +57 -1
  34. data/doc/Doing/WWID.html +517 -890
  35. data/doc/Doing/WWIDFile.html +398 -0
  36. data/doc/Doing.html +5 -5
  37. data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  38. data/doc/GLI/Commands.html +1 -1
  39. data/doc/GLI.html +1 -1
  40. data/doc/Hash.html +97 -1
  41. data/doc/Status.html +37 -3
  42. data/doc/String.html +833 -53
  43. data/doc/Symbol.html +3 -3
  44. data/doc/Time.html +1 -1
  45. data/doc/_index.html +22 -1
  46. data/doc/class_list.html +1 -1
  47. data/doc/file.README.html +8 -2
  48. data/doc/index.html +8 -2
  49. data/doc/method_list.html +460 -180
  50. data/doc/top-level-namespace.html +1 -1
  51. data/doing.gemspec +3 -0
  52. data/doing.rdoc +163 -44
  53. data/example_plugin.rb +5 -5
  54. data/lib/completion/_doing.zsh +42 -42
  55. data/lib/completion/doing.bash +21 -21
  56. data/lib/completion/doing.fish +1 -280
  57. data/lib/doing/array.rb +36 -0
  58. data/lib/doing/colors.rb +70 -66
  59. data/lib/doing/completion/bash_completion.rb +1 -2
  60. data/lib/doing/completion/fish_completion.rb +1 -1
  61. data/lib/doing/completion/zsh_completion.rb +1 -1
  62. data/lib/doing/completion.rb +6 -0
  63. data/lib/doing/configuration.rb +134 -23
  64. data/lib/doing/hash.rb +37 -0
  65. data/lib/doing/item.rb +77 -12
  66. data/lib/doing/items.rb +125 -0
  67. data/lib/doing/log_adapter.rb +58 -4
  68. data/lib/doing/note.rb +53 -1
  69. data/lib/doing/pager.rb +49 -38
  70. data/lib/doing/plugins/export/markdown_export.rb +4 -4
  71. data/lib/doing/plugins/export/template_export.rb +2 -2
  72. data/lib/doing/plugins/import/calendar_import.rb +4 -4
  73. data/lib/doing/plugins/import/doing_import.rb +5 -7
  74. data/lib/doing/plugins/import/timing_import.rb +3 -3
  75. data/lib/doing/prompt.rb +206 -0
  76. data/lib/doing/section.rb +30 -0
  77. data/lib/doing/string.rb +123 -35
  78. data/lib/doing/string_chronify.rb +81 -0
  79. data/lib/doing/util.rb +14 -6
  80. data/lib/doing/version.rb +1 -1
  81. data/lib/doing/wwid.rb +387 -685
  82. data/lib/doing.rb +7 -2
  83. data/lib/examples/plugins/capture_thing_import.rb +162 -0
  84. data/rdoc_to_mmd.rb +14 -8
  85. data/scripts/generate_bash_completions.rb +1 -1
  86. data/scripts/generate_fish_completions.rb +1 -1
  87. data/scripts/generate_zsh_completions.rb +1 -1
  88. metadata +74 -5
  89. data/lib/doing/wwidfile.rb +0 -117
data/lib/doing/wwid.rb CHANGED
@@ -16,6 +16,7 @@ module Doing
16
16
 
17
17
  attr_accessor :config, :config_file, :auto_tag, :default_option
18
18
 
19
+ include Color
19
20
  # include Util
20
21
 
21
22
  ##
@@ -24,11 +25,8 @@ module Doing
24
25
  def initialize
25
26
  @timers = {}
26
27
  @recorded_items = []
27
- @content = {}
28
- @doingrc_needs_update = false
29
- @default_config_file = '.doingrc'
28
+ @content = Items.new
30
29
  @auto_tag = true
31
- @user_home = Util.user_home
32
30
  end
33
31
 
34
32
  ##
@@ -56,52 +54,56 @@ module Doing
56
54
  create(@doing_file) unless File.exist?(@doing_file)
57
55
  input = IO.read(@doing_file)
58
56
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
57
+ logger.debug('Read:', "read file #{@doing_file}")
59
58
  elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
60
59
  @doing_file = File.expand_path(path)
61
60
  input = IO.read(File.expand_path(path))
62
61
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
62
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
63
63
  elsif path.length < 256
64
64
  @doing_file = File.expand_path(path)
65
65
  create(path)
66
66
  input = IO.read(File.expand_path(path))
67
67
  input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
68
+ logger.debug('Read:', "read file #{File.expand_path(path)}")
68
69
  end
69
70
 
70
71
  @other_content_top = []
71
72
  @other_content_bottom = []
72
73
 
73
- section = 'Uncategorized'
74
+ section = nil
74
75
  lines = input.split(/[\n\r]/)
75
- current = 0
76
76
 
77
77
  lines.each do |line|
78
78
  next if line =~ /^\s*$/
79
79
 
80
80
  if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
81
81
  section = Regexp.last_match(1)
82
- @content[section] = {}
83
- @content[section][:original] = line
84
- @content[section][:items] = []
85
- current = 0
82
+ @content.add_section(Section.new(section, original: line), log: false)
86
83
  elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
84
+ if section.nil?
85
+ section = 'Uncategorized'
86
+ @content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
87
+ end
88
+
87
89
  date = Regexp.last_match(1).strip
88
90
  title = Regexp.last_match(2).strip
89
91
  item = Item.new(date, title, section)
90
- @content[section][:items].push(item)
91
- current += 1
92
- elsif current.zero?
93
- # if content[section][:items].length - 1 == current
92
+ @content.push(item)
93
+ elsif @content.count.zero?
94
+ # if content[section].items.length - 1 == current
94
95
  @other_content_top.push(line)
95
96
  elsif line =~ /^\S/
96
97
  @other_content_bottom.push(line)
97
98
  else
98
- prev_item = @content[section][:items][current - 1]
99
+ prev_item = @content.last
99
100
  prev_item.note = Note.new unless prev_item.note
100
101
 
101
102
  prev_item.note.add(line)
102
103
  # end
103
104
  end
104
105
  end
106
+
105
107
  Hooks.trigger :post_read, self
106
108
  end
107
109
 
@@ -122,7 +124,7 @@ module Doing
122
124
  ##
123
125
  ## @param input [String] Text input for editor
124
126
  ##
125
- def fork_editor(input = '')
127
+ def fork_editor(input = '', message: :default)
126
128
  # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
127
129
 
128
130
  raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
@@ -131,7 +133,9 @@ module Doing
131
133
 
132
134
  File.open(tmpfile.path, 'w+') do |f|
133
135
  f.puts input
134
- f.puts "\n# The first line is the entry title, any lines after that are added as a note"
136
+ unless message.nil?
137
+ f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message
138
+ end
135
139
  end
136
140
 
137
141
  pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
@@ -177,13 +181,37 @@ module Doing
177
181
  title = input_lines[0]&.strip
178
182
  raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
179
183
 
184
+ date = nil
185
+ iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
186
+ done_rx = /(?<=^| )@(?<tag>done|finished|completed?)\((?<date>.*?)\)/i
187
+ date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
188
+
189
+ title.gsub!(done_rx) do
190
+ m = Regexp.last_match
191
+ t = m['tag']
192
+ d = m['date']
193
+ parsed_date = d =~ date_rx ? Time.parse(d) : d.chronify(guess: :begin)
194
+ parsed_date.nil? ? m[0] : "@#{t}(#{parsed_date.strftime('%F %R')})"
195
+ end
196
+
197
+ if title =~ date_rx
198
+ m = title.match(date_rx)
199
+ d = m['date']
200
+ date = if d =~ iso_rx
201
+ Time.parse(d)
202
+ else
203
+ d.chronify(guess: :begin)
204
+ end
205
+ title.sub!(date_rx, '').strip!
206
+ end
207
+
180
208
  note = Note.new
181
209
  note.add(input_lines[1..-1]) if input_lines.length > 1
182
210
  # If title line ends in a parenthetical, use that as the note
183
211
  if note.empty? && title =~ /\s+\(.*?\)$/
184
- title.sub!(/\s+\((.*?)\)$/) do
212
+ title.sub!(/\s+\((?<note>.*?)\)$/) do
185
213
  m = Regexp.last_match
186
- note.add(m[1])
214
+ note.add(m['note'])
187
215
  ''
188
216
  end
189
217
  end
@@ -191,72 +219,7 @@ module Doing
191
219
  note.strip_lines!
192
220
  note.compress
193
221
 
194
- [title, note]
195
- end
196
-
197
- ##
198
- ## Converts input string into a Time object when input takes on the
199
- ## following formats:
200
- ## - interval format e.g. '1d2h30m', '45m' etc.
201
- ## - a semantic phrase e.g. 'yesterday 5:30pm'
202
- ## - a strftime e.g. '2016-03-15 15:32:04 PDT'
203
- ##
204
- ## @param input [String] String to chronify
205
- ##
206
- ## @return [DateTime] result
207
- ##
208
- def chronify(input, future: false, guess: :begin)
209
- now = Time.now
210
- raise InvalidTimeExpression, "Invalid time expression #{input.inspect}" if input.to_s.strip == ''
211
-
212
- secs_ago = if input.match(/^(\d+)$/)
213
- # plain number, assume minutes
214
- Regexp.last_match(1).to_i * 60
215
- elsif (m = input.match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
216
- # day/hour/minute format e.g. 1d2h30m
217
- [[m['day'], 24 * 3600],
218
- [m['hour'], 3600],
219
- [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
220
- end
221
-
222
- if secs_ago
223
- now - secs_ago
224
- else
225
- Chronic.parse(input, { guess: guess, context: future ? :future : :past, ambiguous_time_range: 8 })
226
- end
227
- end
228
-
229
- ##
230
- ## Converts simple strings into seconds that can be added to a Time
231
- ## object
232
- ##
233
- ## @param qty [String] HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
234
- ## 1.5d, 1h20m, etc.)
235
- ##
236
- ## @return [Integer] seconds
237
- ##
238
- def chronify_qty(qty)
239
- minutes = 0
240
- case qty.strip
241
- when /^(\d+):(\d\d)$/
242
- minutes += Regexp.last_match(1).to_i * 60
243
- minutes += Regexp.last_match(2).to_i
244
- when /^(\d+(?:\.\d+)?)([hmd])?$/
245
- amt = Regexp.last_match(1)
246
- type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
247
-
248
- minutes = case type.downcase
249
- when 'm'
250
- amt.to_i
251
- when 'h'
252
- (amt.to_f * 60).round
253
- when 'd'
254
- (amt.to_f * 60 * 24).round
255
- else
256
- minutes
257
- end
258
- end
259
- minutes * 60
222
+ [date, title, note]
260
223
  end
261
224
 
262
225
  ##
@@ -265,21 +228,7 @@ module Doing
265
228
  ## @return [Array] section titles
266
229
  ##
267
230
  def sections
268
- @content.keys
269
- end
270
-
271
- ##
272
- ## Adds a section.
273
- ##
274
- ## @param title [String] The new section title
275
- ##
276
- def add_section(title)
277
- if @content.key?(title.cap_first)
278
- raise InvalidSection, %(section "#{title.cap_first}" already exists)
279
- end
280
-
281
- @content[title.cap_first] = { :original => "#{title}:", :items => [] }
282
- logger.info('New section:', %("#{title.cap_first}"))
231
+ @content.section_titles
283
232
  end
284
233
 
285
234
  ##
@@ -292,8 +241,9 @@ module Doing
292
241
  return 'All' if frag =~ /^all$/i
293
242
  frag ||= @config['current_section']
294
243
 
295
- sections.each { |sect| return sect.cap_first if frag.downcase == sect.downcase }
296
- section = false
244
+ return frag.cap_first if @content.section?(frag)
245
+
246
+ section = nil
297
247
  re = frag.split('').join('.*?')
298
248
  sections.each do |sect|
299
249
  next unless sect =~ /#{re}/i
@@ -308,79 +258,25 @@ module Doing
308
258
  unless section || guessed
309
259
  alt = guess_view(frag, guessed: true, suggest: true)
310
260
  if alt
311
- meant_view = yn("#{Color.boldwhite}Did you mean `#{Color.yellow}doing view #{alt}#{Color.boldwhite}`?", default_response: 'n')
261
+ meant_view = Prompt.yn("#{boldwhite("Did you mean")} `#{yellow("doing view #{alt}")}#{boldwhite}`?", default_response: 'n')
312
262
 
313
263
  raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view
314
264
 
315
265
  end
316
266
 
317
- res = yn("#{Color.boldwhite}Section #{frag.yellow}#{Color.boldwhite} not found, create it", default_response: 'n')
267
+ res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
318
268
 
319
269
  if res
320
- add_section(frag.cap_first)
270
+ @content.add_section(frag.cap_first, log: true)
321
271
  write(@doing_file)
322
272
  return frag.cap_first
323
273
  end
324
274
 
325
- raise InvalidSection.new("unknown section #{frag.yellow}", topic: 'Missing:')
275
+ raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
326
276
  end
327
277
  section ? section.cap_first : guessed
328
278
  end
329
279
 
330
- ##
331
- ## Ask a yes or no question in the terminal
332
- ##
333
- ## @param question [String] The question
334
- ## to ask
335
- ## @param default_response (Bool) default
336
- ## response if no input
337
- ##
338
- ## @return (Bool) yes or no
339
- ##
340
- def yn(question, default_response: false)
341
- if default_response.is_a?(String)
342
- default = default_response =~ /y/i ? true : false
343
- else
344
- default = default_response
345
- end
346
-
347
- # if global --default is set, answer default
348
- return default if @default_option
349
-
350
- # if this isn't an interactive shell, answer default
351
- return default unless $stdout.isatty
352
-
353
- # clear the buffer
354
- if ARGV&.length
355
- ARGV.length.times do
356
- ARGV.shift
357
- end
358
- end
359
- system 'stty cbreak'
360
-
361
- cw = Color.white
362
- cbw = Color.boldwhite
363
- cbg = Color.boldgreen
364
- cd = Color.default
365
-
366
- options = unless default.nil?
367
- "#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
368
- else
369
- "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
370
- end
371
- $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
372
- res = $stdin.sysread 1
373
- puts
374
- system 'stty cooked'
375
-
376
- res.chomp!
377
- res.downcase!
378
-
379
- return default if res.empty?
380
-
381
- res =~ /y/i ? true : false
382
- end
383
-
384
280
  ##
385
281
  ## Attempt to match a string with an existing view
386
282
  ##
@@ -400,11 +296,14 @@ module Doing
400
296
  end
401
297
  unless view || guessed
402
298
  alt = guess_section(frag, guessed: true, suggest: true)
403
- meant_view = yn("Did you mean `doing show #{alt}`?", default_response: 'n')
299
+
300
+ raise InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
301
+
302
+ meant_view = Prompt.yn("Did you mean `doing show #{alt}`?", default_response: 'n')
404
303
 
405
304
  raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view
406
305
 
407
- raise InvalidView.new(%(unkown view #{alt.yellow}), topic: 'Missing:')
306
+ raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
408
307
  end
409
308
  view
410
309
  end
@@ -414,17 +313,22 @@ module Doing
414
313
  ##
415
314
  ## @param title [String] The entry title
416
315
  ## @param section [String] The section to add to
417
- ## @param opt [Hash] Additional Options: :date, :note, :back, :timed
316
+ ## @param opt [Hash] Additional Options
317
+ ##
318
+ ## @option opt :date [Date] item start date
319
+ ## @option opt :note [Array] item note (will be converted if value is String)
320
+ ## @option opt :back [Date] backdate
321
+ ## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
418
322
  ##
419
323
  def add_item(title, section = nil, opt = {})
420
324
  section ||= @config['current_section']
421
- add_section(section) unless @content.key?(section)
325
+ @content.add_section(section, log: false)
326
+ opt[:back] ||= opt[:date] ? opt[:date] : Time.now
422
327
  opt[:date] ||= Time.now
423
- opt[:note] ||= []
424
- opt[:back] ||= Time.now
328
+ note = Note.new
425
329
  opt[:timed] ||= false
426
330
 
427
- opt[:note] = opt[:note].lines if opt[:note].is_a?(String)
331
+ note.add(opt[:note]) if opt[:note]
428
332
 
429
333
  title = [title.strip.cap_first]
430
334
  title = title.join(' ')
@@ -434,10 +338,11 @@ module Doing
434
338
  title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty?
435
339
  end
436
340
 
437
- title.gsub!(/ +/, ' ')
341
+ title.compress!
438
342
  entry = Item.new(opt[:back], title.strip, section)
439
- entry.note = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
440
- items = @content[section][:items]
343
+ entry.note = note
344
+
345
+ items = @content.dup
441
346
  if opt[:timed]
442
347
  items.reverse!
443
348
  items.each_with_index do |i, x|
@@ -446,10 +351,9 @@ module Doing
446
351
  items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
447
352
  break
448
353
  end
449
- items.reverse!
450
354
  end
451
355
 
452
- items.push(entry)
356
+ @content.push(entry)
453
357
  # logger.count(:added, level: :debug)
454
358
  logger.info('New entry:', %(added "#{entry.title}" to #{section}))
455
359
  end
@@ -460,16 +364,10 @@ module Doing
460
364
  ## @param items [Array] The items to deduplicate
461
365
  ## @param no_overlap [Boolean] Remove items with overlapping time spans
462
366
  ##
463
- def dedup(items, no_overlap = false)
464
-
465
- combined = []
466
- @content.each do |_k, v|
467
- combined += v[:items]
468
- end
469
-
367
+ def dedup(items, no_overlap: false)
470
368
  items.delete_if do |item|
471
369
  duped = false
472
- combined.each do |comp|
370
+ @content.each do |comp|
473
371
  duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
474
372
  break if duped
475
373
  end
@@ -519,17 +417,32 @@ module Doing
519
417
  "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
520
418
  end
521
419
 
420
+ # Reset start date to current time, optionally remove
421
+ # done tag (resume)
422
+ #
423
+ # @param item [Item] the item to reset/resume
424
+ # @param resume [Boolean] removing @done tag if true
425
+ #
522
426
  def reset_item(item, resume: false)
523
427
  item.date = Time.now
524
- if resume
525
- item.tag('done', remove: true)
526
- end
428
+ item.tag('done', remove: true) if resume
527
429
  logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
528
430
  item
529
431
  end
530
432
 
433
+ # Duplicate an item and add it as a new item
434
+ #
435
+ # @param item [Item] the item to duplicate
436
+ # @param opt [Hash] additional options
437
+ #
438
+ # @option opt :editor [Boolean] open new item in editor
439
+ # @option opt :date [String] set start date
440
+ # @option opt :in [String] add new item to section :in
441
+ # @option opt :note [Note] add note to new item
442
+ #
443
+ # @return nothing
444
+ #
531
445
  def repeat_item(item, opt = {})
532
- original = item.dup
533
446
  if item.should_finish?
534
447
  if item.should_time?
535
448
  item.title.tag!('done', value: Time.now.strftime('%F %R'))
@@ -546,10 +459,13 @@ module Doing
546
459
  note = opt[:note] || Note.new
547
460
 
548
461
  if opt[:editor]
549
- to_edit = title
550
- to_edit += "\n#{note.to_s}" unless note.empty?
462
+ start = opt[:date] ? opt[:date] : Time.now
463
+ to_edit = "#{start.strftime('%F %R')} | #{title}"
464
+ to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
551
465
  new_item = fork_editor(to_edit)
552
- title, note = format_input(new_item)
466
+ date, title, note = format_input(new_item)
467
+
468
+ opt[:date] = date unless date.nil?
553
469
 
554
470
  if title.nil? || title.empty?
555
471
  logger.warn('Skipped:', 'No content provided')
@@ -557,9 +473,8 @@ module Doing
557
473
  end
558
474
  end
559
475
 
560
- update_item(original, item)
476
+ # @content.update_item(original, item)
561
477
  add_item(title, section, { note: note, back: opt[:date], timed: true })
562
- write(@doing_file)
563
478
  end
564
479
 
565
480
  ##
@@ -569,6 +484,7 @@ module Doing
569
484
  ##
570
485
  def repeat_last(opt = {})
571
486
  opt[:section] ||= 'all'
487
+ opt[:section] = guess_section(opt[:section])
572
488
  opt[:note] ||= []
573
489
  opt[:tag] ||= []
574
490
  opt[:tag_bool] ||= :and
@@ -580,6 +496,7 @@ module Doing
580
496
  end
581
497
 
582
498
  repeat_item(last, opt)
499
+ write(@doing_file)
583
500
  end
584
501
 
585
502
  ##
@@ -591,19 +508,19 @@ module Doing
591
508
  opt[:tag_bool] ||= :and
592
509
  opt[:section] ||= @config['current_section']
593
510
 
594
- items = filter_items([], opt: opt)
511
+ items = filter_items(Items.new, opt: opt)
595
512
 
596
513
  logger.debug('Filtered:', "Parameters matched #{items.count} entries")
597
514
 
598
515
  if opt[:interactive]
599
- last_entry = choose_from_items(items, {
516
+ last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i,
600
517
  menu: true,
601
518
  header: '',
602
519
  prompt: 'Select an entry > ',
603
520
  multiple: false,
604
521
  sort: false,
605
522
  show_if_single: true
606
- }, include_section: opt[:section] =~ /^all$/i )
523
+ )
607
524
  else
608
525
  last_entry = items.max_by { |item| item.date }
609
526
  end
@@ -611,45 +528,6 @@ module Doing
611
528
  last_entry
612
529
  end
613
530
 
614
- def fzf
615
- fzf_dir = File.join(File.dirname(__FILE__), '../helpers/fzf')
616
- FileUtils.mkdir_p(fzf_dir) unless File.directory?(fzf_dir)
617
- fzf_bin = File.join(fzf_dir, 'bin/fzf')
618
- return fzf_bin if File.exist?(fzf_bin)
619
-
620
- Doing.logger.log_now(:warn, 'Compiling and installing FZF -- this will only happen once')
621
- Doing.logger.log_now(:warn, 'fzf is copyright Junegunn Choi <https://github.com/junegunn/fzf/blob/master/LICENSE>')
622
-
623
- res = system("#{fzf_dir}/install --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish")
624
- unless res
625
- Doing.logger.log_now(:warn, 'Error installing, trying again as root')
626
- system("sudo #{fzf_dir}/install --bin --no-key-bindings --no-completion --no-update-rc --no-bash --no-zsh --no-fish")
627
- end
628
- raise RuntimeError.new('Error installing fzf, please report at https://github.com/ttscoff/doing/issues') unless File.exist?(fzf_bin)
629
-
630
- fzf_bin
631
- end
632
-
633
- ##
634
- ## Generate a menu of options and allow user selection
635
- ##
636
- ## @return [String] The selected option
637
- ##
638
- def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
639
- return nil unless $stdout.isatty
640
-
641
- # fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
642
- fzf_args << %(--prompt "#{prompt}")
643
- fzf_args << '--multi' if multiple
644
- header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
645
- fzf_args << %(--header "#{header}")
646
- options.sort! if sorted
647
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
648
- return false if res.strip.size.zero?
649
-
650
- res
651
- end
652
-
653
531
  def all_tags(items, opt: {})
654
532
  all_tags = []
655
533
  items.each { |item| all_tags.concat(item.tags).uniq! }
@@ -688,8 +566,8 @@ module Doing
688
566
  end
689
567
  # fzf_args << '-e' if opt[:exact]
690
568
  # puts fzf_args.join(' ')
691
- res = `echo #{Shellwords.escape(scannable)}|#{fzf} #{fzf_args.join(' ')}`
692
- selected = []
569
+ res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
570
+ selected = Items.new
693
571
  res.split(/\n/).each do |item|
694
572
  idx = item.match(/\|(\d+)$/)[1].to_i
695
573
  selected.push(items[idx])
@@ -717,15 +595,54 @@ module Doing
717
595
  ## @option opt [Number] :count (Number to return)
718
596
  ## @option opt [String] :age ('old' or 'new')
719
597
  ##
720
- def filter_items(items = [], opt: {})
598
+ def filter_items(items = Items.new, opt: {})
599
+ time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
600
+
721
601
  if items.nil? || items.empty?
722
602
  section = opt[:section] ? guess_section(opt[:section]) : 'All'
603
+ items = section =~ /^all$/i ? @content.dup : @content.in_section(section)
604
+ end
605
+
606
+ opt[:time_filter] = [nil, nil]
607
+ if opt[:from] && !opt[:date_filter]
608
+ date_string = opt[:from]
609
+ case date_string
610
+ when / (to|through|thru|(un)?til|-+) /
611
+ dates = date_string.split(/ (?:to|through|thru|(?:un)?til|-+) /)
612
+ if dates[0].strip =~ time_rx && dates[-1].strip =~ time_rx
613
+ time_start = dates[0].strip
614
+ time_end = dates[-1].strip
615
+ else
616
+ start = dates[0].chronify(guess: :begin)
617
+ finish = dates[-1].chronify(guess: :end)
618
+ end
619
+ when time_rx
620
+ time_start = date_string
621
+ time_end = nil
622
+ else
623
+ start = date_string.chronify(guess: :begin)
624
+ finish = false
625
+ end
723
626
 
724
- items = if section =~ /^all$/i
725
- @content.each_with_object([]) { |(_k, v), arr| arr.concat(v[:items].dup) }
726
- else
727
- @content[section][:items].dup
728
- end
627
+ if time_start
628
+ opt[:time_filter] = [time_start, time_end]
629
+ Doing.logger.debug('Parser:', "--from string interpreted as time span, from #{time_start ? time_start : '12am'} to #{time_end ? time_end : '11:59pm'}")
630
+ else
631
+ raise InvalidTimeExpression, 'Unrecognized date string' unless start
632
+
633
+ opt[:date_filter] = [start, finish]
634
+ Doing.logger.debug('Parser:', "--from string interpreted as #{start.strftime('%F %R')} -- #{finish ? finish.strftime('%F %R') : 'now'}")
635
+ end
636
+ end
637
+
638
+ if opt[:before] =~ time_rx
639
+ opt[:time_filter][1] = opt[:before]
640
+ opt[:before] = nil
641
+ end
642
+
643
+ if opt[:after] =~ time_rx
644
+ opt[:time_filter][0] = opt[:after]
645
+ opt[:after] = nil
729
646
  end
730
647
 
731
648
  items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
@@ -769,6 +686,26 @@ module Doing
769
686
  keep = opt[:not] ? !keep : keep
770
687
  end
771
688
 
689
+ if keep && opt[:time_filter][0] || opt[:time_filter][1]
690
+ start_string = if opt[:time_filter][0].nil?
691
+ "#{item.date.strftime('%Y-%m-%d')} 12am"
692
+ else
693
+ "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
694
+ end
695
+ start_time = start_string.chronify(guess: :begin)
696
+
697
+ end_string = if opt[:time_filter][1].nil?
698
+ "#{item.date.next_day.strftime('%Y-%m-%d')} 12am"
699
+ else
700
+ "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
701
+ end
702
+ end_time = end_string.chronify(guess: :end)
703
+
704
+ in_time_range = item.date >= start_time && item.date <= end_time
705
+ keep = false unless in_time_range
706
+ keep = opt[:not] ? !keep : keep
707
+ end
708
+
772
709
  keep = false if keep && opt[:only_timed] && !item.interval
773
710
 
774
711
  if keep && opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
@@ -778,14 +715,22 @@ module Doing
778
715
 
779
716
  if keep && opt[:before]
780
717
  time_string = opt[:before]
781
- cutoff = chronify(time_string, guess: :begin)
718
+ if time_string =~ time_rx
719
+ cutoff = "#{item.date.strftime('%Y-%m-%d')} #{time_string}".chronify(guess: :begin)
720
+ else
721
+ cutoff = time_string.chronify(guess: :begin)
722
+ end
782
723
  keep = cutoff && item.date <= cutoff
783
724
  keep = opt[:not] ? !keep : keep
784
725
  end
785
726
 
786
727
  if keep && opt[:after]
787
728
  time_string = opt[:after]
788
- cutoff = chronify(time_string, guess: :end)
729
+ if time_string =~ time_rx
730
+ cutoff = "#{item.date.strftime('%Y-%m-%d')} #{time_string}".chronify(guess: :end)
731
+ else
732
+ cutoff = time_string.chronify(guess: :end)
733
+ end
789
734
  keep = cutoff && item.date >= cutoff
790
735
  keep = opt[:not] ? !keep : keep
791
736
  end
@@ -800,14 +745,17 @@ module Doing
800
745
 
801
746
  keep
802
747
  end
803
- count = opt[:count] && opt[:count].positive? ? opt[:count] : filtered_items.length
748
+ count = opt[:count]&.positive? ? opt[:count] : filtered_items.length
749
+
750
+ output = Items.new
804
751
 
805
752
  if opt[:age] =~ /^o/i
806
- filtered_items.slice(0, count).reverse
753
+ output.concat(filtered_items.slice(0, count).reverse)
807
754
  else
808
- filtered_items.reverse.slice(0, count)
755
+ output.concat(filtered_items.reverse.slice(0, count))
809
756
  end
810
757
 
758
+ output
811
759
  end
812
760
 
813
761
  ##
@@ -818,7 +766,7 @@ module Doing
818
766
  ## Options hash is shared with #filter_items and #act_on
819
767
  ##
820
768
  def interactive(opt = {})
821
- section = opt[:section] ? guess_section(opt[:section]) : 'All'
769
+ opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
822
770
 
823
771
  search = nil
824
772
 
@@ -832,94 +780,18 @@ module Doing
832
780
  opt[:query] = "!#{opt[:query]}" if opt[:not]
833
781
  opt[:multiple] = true
834
782
  opt[:show_if_single] = true
835
- items = filter_items([], opt: { section: section, search: opt[:search], fuzzy: opt[:fuzzy], case: opt[:case], not: opt[:not] })
783
+ filter_options = %i[after before case date_filter from fuzzy not search section].each_with_object({}) {
784
+ |k, hsh| hsh[k] = opt[k]
785
+ }
786
+ items = filter_items(Items.new, opt: filter_options)
836
787
 
837
- selection = choose_from_items(items, opt, include_section: section =~ /^all$/i)
788
+ selection = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **opt)
838
789
 
839
790
  raise NoResults, 'no items selected' if selection.nil? || selection.empty?
840
791
 
841
792
  act_on(selection, opt)
842
793
  end
843
794
 
844
- ##
845
- ## Create an interactive menu to select from a set of Items
846
- ##
847
- ## @param items [Array] list of items
848
- ## @param opt [Hash] options
849
- ## @param include_section [Boolean] include section
850
- ##
851
- ## @option opt [String] :header
852
- ## @option opt [String] :prompt
853
- ## @option opt [String] :query
854
- ## @option opt [Boolean] :show_if_single
855
- ## @option opt [Boolean] :menu
856
- ## @option opt [Boolean] :sort
857
- ## @option opt [Boolean] :multiple
858
- ## @option opt [Symbol] :case (:sensitive, :ignore, :smart)
859
- ##
860
- def choose_from_items(items, opt = {}, include_section: false)
861
- return items unless $stdout.isatty
862
-
863
- return nil unless items.count.positive?
864
-
865
- opt[:case] ||= :smart
866
- opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
867
- opt[:prompt] ||= "Select entries to act on > "
868
-
869
- pad = items.length.to_s.length
870
- options = items.map.with_index do |item, i|
871
- out = [
872
- format("%#{pad}d", i),
873
- ') ',
874
- format('%13s', item.date.relative_date),
875
- ' | ',
876
- item.title
877
- ]
878
- if include_section
879
- out.concat([
880
- ' (',
881
- item.section,
882
- ') '
883
- ])
884
- end
885
- out.join('')
886
- end
887
-
888
- fzf_args = [
889
- %(--header="#{opt[:header]}"),
890
- %(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"),
891
- opt[:multiple] ? '--multi' : '--no-multi',
892
- '-0',
893
- '--bind ctrl-a:select-all',
894
- %(-q "#{opt[:query]}"),
895
- '--info=inline'
896
- ]
897
- fzf_args.push('-1') unless opt[:show_if_single]
898
- fzf_args << case opt[:case].normalize_case
899
- when :sensitive
900
- '+i'
901
- when :ignore
902
- '-i'
903
- end
904
- fzf_args << '-e' if opt[:exact]
905
-
906
-
907
- unless opt[:menu]
908
- raise InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query] && !opt[:query].empty?
909
-
910
- fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort'])
911
- end
912
-
913
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
914
- selected = []
915
- res.split(/\n/).each do |item|
916
- idx = item.match(/^ *(\d+)\)/)[1].to_i
917
- selected.push(items[idx])
918
- end
919
-
920
- opt[:multiple] ? selected : selected[0]
921
- end
922
-
923
795
  ##
924
796
  ## Perform actions on a set of entries. If
925
797
  ## no valid action is included in the opt
@@ -969,11 +841,11 @@ module Doing
969
841
 
970
842
  actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
971
843
 
972
- choice = choose_from(actions,
973
- prompt: 'What do you want to do with the selected items? > ',
974
- multiple: true,
975
- sorted: false,
976
- fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
844
+ choice = Prompt.choose_from(actions,
845
+ prompt: 'What do you want to do with the selected items? > ',
846
+ multiple: true,
847
+ sorted: false,
848
+ fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
977
849
  return unless choice
978
850
 
979
851
  to_do = choice.strip.split(/\n/)
@@ -987,7 +859,7 @@ module Doing
987
859
  type = action =~ /^add/ ? 'add' : 'remove'
988
860
  raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
989
861
 
990
- print "#{Color.yellow}Tag to #{type}: #{Color.reset}"
862
+ print "#{yellow("Tag to #{type}: ")}#{reset}"
991
863
  tag = $stdin.gets
992
864
  next if tag =~ /^ *$/
993
865
 
@@ -995,17 +867,22 @@ module Doing
995
867
  opt[:remove] = true if type == 'remove'
996
868
  when /output formatted/
997
869
  plugins = Plugins.available_plugins(type: :export).sort
998
- output_format = choose_from(plugins,
999
- prompt: 'Which output format? > ',
1000
- fzf_args: ["--height=#{plugins.count + 3}", '--tac', '--no-sort', '--info=hidden'])
870
+ output_format = Prompt.choose_from(plugins,
871
+ prompt: 'Which output format? > ',
872
+ fzf_args: [
873
+ "--height=#{plugins.count + 3}",
874
+ '--tac',
875
+ '--no-sort',
876
+ '--info=hidden'
877
+ ])
1001
878
  next if tag =~ /^ *$/
1002
879
 
1003
880
  raise UserCancelled unless output_format
1004
881
 
1005
882
  opt[:output] = output_format.strip
1006
- res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
883
+ res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
1007
884
  if res
1008
- print "#{Color.yellow}File path/name: #{Color.reset}"
885
+ print "#{yellow('File path/name: ')}#{reset}"
1009
886
  filename = $stdin.gets.strip
1010
887
  next if filename.empty?
1011
888
 
@@ -1031,29 +908,28 @@ module Doing
1031
908
  end
1032
909
 
1033
910
  if opt[:resume] || opt[:reset]
1034
- if items.count > 1
1035
- raise InvalidArgument, 'resume and restart can only be used on a single entry'
1036
- else
1037
- item = items[0]
1038
- if opt[:resume] && !opt[:reset]
1039
- repeat_item(item, { editor: opt[:editor] })
1040
- elsif opt[:reset]
1041
- if item.tags?('done', :and) && !opt[:resume]
1042
- res = opt[:force] ? true : yn('Remove @done tag?', default_response: 'y')
1043
- else
1044
- res = opt[:resume]
1045
- end
1046
- update_item(item, reset_item(item, resume: res))
1047
- end
1048
- write(@doing_file)
911
+ raise InvalidArgument, 'resume and restart can only be used on a single entry' if items.count > 1
912
+
913
+ item = items[0]
914
+ if opt[:resume] && !opt[:reset]
915
+ repeat_item(item, { editor: opt[:editor] })
916
+ elsif opt[:reset]
917
+ res = if item.tags?('done', :and) && !opt[:resume]
918
+ opt[:force] ? true : Prompt.yn('Remove @done tag?', default_response: 'y')
919
+ else
920
+ opt[:resume]
921
+ end
922
+ @content.update_item(item, reset_item(item, resume: res))
1049
923
  end
924
+ write(@doing_file)
925
+
1050
926
  return
1051
927
  end
1052
928
 
1053
929
  if opt[:delete]
1054
- res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y')
930
+ res = opt[:force] ? true : Prompt.yn("Delete #{items.size} items?", default_response: 'y')
1055
931
  if res
1056
- items.each { |item| delete_item(item, single: items.count == 1) }
932
+ items.each { |i| @content.delete_item(i, single: items.count == 1) }
1057
933
  write(@doing_file)
1058
934
  end
1059
935
  return
@@ -1061,31 +937,31 @@ module Doing
1061
937
 
1062
938
  if opt[:flag]
1063
939
  tag = @config['marker_tag'] || 'flagged'
1064
- items.map! do |item|
1065
- tag_item(item, tag, date: false, remove: opt[:remove], single: single)
940
+ items.map! do |i|
941
+ i.tag(tag, date: false, remove: opt[:remove], single: single)
1066
942
  end
1067
943
  end
1068
944
 
1069
945
  if opt[:finish] || opt[:cancel]
1070
946
  tag = 'done'
1071
- items.map! do |item|
1072
- if item.should_finish?
1073
- should_date = !opt[:cancel] && item.should_time?
1074
- tag_item(item, tag, date: should_date, remove: opt[:remove], single: single)
947
+ items.map! do |i|
948
+ if i.should_finish?
949
+ should_date = !opt[:cancel] && i.should_time?
950
+ i.tag(tag, date: should_date, remove: opt[:remove], single: single)
1075
951
  end
1076
952
  end
1077
953
  end
1078
954
 
1079
955
  if opt[:tag]
1080
956
  tag = opt[:tag]
1081
- items.map! do |item|
1082
- tag_item(item, tag, date: false, remove: opt[:remove], single: single)
957
+ items.map! do |i|
958
+ i.tag(tag, date: false, remove: opt[:remove], single: single)
1083
959
  end
1084
960
  end
1085
961
 
1086
962
  if opt[:archive] || opt[:move]
1087
963
  section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
1088
- items.map! {|item| move_item(item, section) }
964
+ items.map! { |i| i.move_to(section, label: true) }
1089
965
  end
1090
966
 
1091
967
  write(@doing_file)
@@ -1094,111 +970,88 @@ module Doing
1094
970
 
1095
971
  editable_items = []
1096
972
 
1097
- items.each do |item|
1098
- editable = "#{item.date} | #{item.title}"
1099
- old_note = item.note ? item.note.to_s : nil
973
+ items.each do |i|
974
+ editable = "#{i.date.strftime('%F %R')} | #{i.title}"
975
+ old_note = i.note ? i.note.strip_lines.join("\n") : nil
1100
976
  editable += "\n#{old_note}" unless old_note.nil?
1101
977
  editable_items << editable
1102
978
  end
1103
979
  divider = "\n-----------\n"
1104
- input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place"
980
+ notice =<<~EONOTICE
981
+ # - You may delete entries, but leave all divider lines (---) in place.
982
+ # - Start and @done dates replaced with a time string (yesterday 3pm) will
983
+ # be parsed automatically. Do not delete the pipe (|) between start date
984
+ # and entry title.
985
+ EONOTICE
986
+ input = "#{editable_items.map(&:strip).join(divider)}\n\n#{notice}"
1105
987
 
1106
988
  new_items = fork_editor(input).split(/#{divider}/)
1107
989
 
1108
990
  new_items.each_with_index do |new_item, i|
1109
-
1110
991
  input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
1111
- title = input_lines[0]&.strip
992
+ first_line = input_lines[0]&.strip
1112
993
 
1113
- if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
1114
- delete_item(items[i], single: new_items.count == 1)
994
+ if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
995
+ @content.delete_item(items[i], single: new_items.count == 1)
996
+ Doing.logger.count(:deleted)
1115
997
  else
1116
- note = input_lines.length > 1 ? input_lines[1..-1] : []
998
+ date, title, note = format_input(new_item)
1117
999
 
1118
1000
  note.map!(&:strip)
1119
1001
  note.delete_if(&:ignore?)
1120
-
1121
- date = title.match(/^([\d\-: ]+) \| /)[1]
1122
- title.sub!(/^([\d\-: ]+) \| /, '')
1123
-
1124
1002
  item = items[i]
1003
+ old_item = item.dup
1004
+ item.date = date || items[i].date
1125
1005
  item.title = title
1126
1006
  item.note = note
1127
- item.date = Time.parse(date) || items[i].date
1007
+ if (item.equal?(old_item))
1008
+ Doing.logger.count(:skipped, level: :debug)
1009
+ else
1010
+ Doing.logger.count(:updated)
1011
+ end
1128
1012
  end
1129
1013
  end
1130
1014
 
1131
1015
  write(@doing_file)
1132
1016
  end
1133
1017
 
1134
- if opt[:output]
1135
- items.map! do |item|
1136
- item.title = "#{item.title} @project(#{item.section})"
1137
- item
1138
- end
1139
-
1140
- @content = { 'Export' => { :original => 'Export:', :items => items } }
1141
- options = { section: 'Export' }
1018
+ return unless opt[:output]
1142
1019
 
1143
-
1144
- if opt[:output] =~ /doing/
1145
- options[:output] = 'template'
1146
- options[:template] = '- %date | %title%note'
1147
- else
1148
- options[:output] = opt[:output]
1149
- options[:template] = opt[:template] || nil
1150
- end
1151
-
1152
- output = list_section(options)
1153
-
1154
- if opt[:save_to]
1155
- file = File.expand_path(opt[:save_to])
1156
- if File.exist?(file)
1157
- # Create a backup copy for the undo command
1158
- FileUtils.cp(file, "#{file}~")
1159
- end
1160
-
1161
- File.open(file, 'w+') do |f|
1162
- f.puts output
1163
- end
1164
-
1165
- logger.warn('File written:', file)
1166
- else
1167
- Doing::Pager.page output
1168
- end
1020
+ items.map! do |i|
1021
+ i.title = "#{i.title} @project(#{i.section})"
1022
+ i
1169
1023
  end
1170
- end
1171
1024
 
1172
- ##
1173
- ## Tag an item from the index
1174
- ##
1175
- ## @param item [Item] The item to tag
1176
- ## @param tags [String] The tag to apply
1177
- ## @param remove [Boolean] remove tags?
1178
- ## @param date [Boolean] Include timestamp?
1179
- ## @param single [Boolean] Log as a single change?
1180
- ##
1181
- ## @return [Item] updated item
1182
- ##
1183
- def tag_item(item, tags, remove: false, date: false, single: false)
1184
- added = []
1185
- removed = []
1025
+ @content = Items.new
1026
+ @content.concat(items)
1027
+ @content.add_section(Section.new('Export'), log: false)
1028
+ options = { section: 'Export' }
1186
1029
 
1187
- tags = tags.to_tags if tags.is_a? ::String
1030
+ if opt[:output] =~ /doing/
1031
+ options[:output] = 'template'
1032
+ options[:template] = '- %date | %title%note'
1033
+ else
1034
+ options[:output] = opt[:output]
1035
+ options[:template] = opt[:template] || nil
1036
+ end
1188
1037
 
1189
- done_date = Time.now
1038
+ output = list_section(options)
1190
1039
 
1191
- tags.each do |tag|
1192
- bool = remove ? :and : :not
1193
- if item.tags?(tag, bool)
1194
- item.tag(tag, remove: remove, value: date ? done_date.strftime('%F %R') : nil)
1195
- remove ? removed.push(tag) : added.push(tag)
1040
+ if opt[:save_to]
1041
+ file = File.expand_path(opt[:save_to])
1042
+ if File.exist?(file)
1043
+ # Create a backup copy for the undo command
1044
+ FileUtils.cp(file, "#{file}~")
1196
1045
  end
1197
- end
1198
1046
 
1199
- log_change(tags_added: added, tags_removed: removed, count: 1, item: item, single: single)
1047
+ File.open(file, 'w+') do |f|
1048
+ f.puts output
1049
+ end
1200
1050
 
1201
- item
1051
+ logger.warn('File written:', file)
1052
+ else
1053
+ Doing::Pager.page output
1054
+ end
1202
1055
  end
1203
1056
 
1204
1057
  ##
@@ -1222,17 +1075,15 @@ module Doing
1222
1075
  opt[:unfinished] ||= false
1223
1076
  opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
1224
1077
 
1225
- items = filter_items([], opt: opt)
1078
+ items = filter_items(Items.new, opt: opt)
1226
1079
 
1227
1080
  if opt[:interactive]
1228
- items = choose_from_items(items, {
1229
- menu: true,
1081
+ items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
1230
1082
  header: '',
1231
1083
  prompt: 'Select entries to tag > ',
1232
1084
  multiple: true,
1233
1085
  sort: true,
1234
- show_if_single: true
1235
- }, include_section: opt[:section] =~ /^all$/i)
1086
+ show_if_single: true)
1236
1087
 
1237
1088
  raise NoResults, 'no items selected' if items.empty?
1238
1089
 
@@ -1313,12 +1164,12 @@ module Doing
1313
1164
  end
1314
1165
  end
1315
1166
 
1316
- log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
1167
+ logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
1317
1168
 
1318
1169
  item.note.add(opt[:note]) if opt[:note]
1319
1170
 
1320
1171
  if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1321
- move_item(item, 'Archive', label: true)
1172
+ item.move_to('Archive', label: true)
1322
1173
  elsif opt[:archive] && opt[:count].zero?
1323
1174
  logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1324
1175
  end
@@ -1327,29 +1178,6 @@ module Doing
1327
1178
  write(@doing_file)
1328
1179
  end
1329
1180
 
1330
- ##
1331
- ## Move item from current section to
1332
- ## destination section
1333
- ##
1334
- ## @param item [Item] The item to move
1335
- ## @param section [String] The destination section
1336
- ##
1337
- ## @return [Item] Updated item
1338
- ##
1339
- def move_item(item, section, label: true)
1340
- from = item.section
1341
- new_item = @content[item.section][:items].delete(item)
1342
- new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
1343
- new_item.section = section
1344
-
1345
- @content[section][:items].concat([new_item])
1346
-
1347
- logger.count(section == 'Archive' ? :archived : :moved)
1348
- logger.debug("#{section == 'Archive' ? 'Archived' : 'Moved'}:",
1349
- "#{new_item.title.truncate(60)} from #{from} to #{section}")
1350
- new_item
1351
- end
1352
-
1353
1181
  ##
1354
1182
  ## Get next item in the index
1355
1183
  ##
@@ -1360,49 +1188,13 @@ module Doing
1360
1188
  ## @return [Item] the next chronological item in the index
1361
1189
  ##
1362
1190
  def next_item(item, options = {})
1363
- items = filter_items([], opt: options)
1191
+ items = filter_items(Items.new, opt: options)
1364
1192
 
1365
1193
  idx = items.index(item)
1366
1194
 
1367
1195
  idx.positive? ? items[idx - 1] : nil
1368
1196
  end
1369
1197
 
1370
- ##
1371
- ## Delete an item from the index
1372
- ##
1373
- ## @param item The item
1374
- ##
1375
- def delete_item(item, single: false)
1376
- section = item.section
1377
-
1378
- section_items = @content[section][:items]
1379
- deleted = section_items.delete(item)
1380
- logger.count(:deleted)
1381
- logger.info('Entry deleted:', deleted.title) if single
1382
- end
1383
-
1384
- ##
1385
- ## Update an item in the index with a modified item
1386
- ##
1387
- ## @param old_item The old item
1388
- ## @param new_item The new item
1389
- ##
1390
- def update_item(old_item, new_item)
1391
- section = old_item.section
1392
-
1393
- section_items = @content[section][:items]
1394
- s_idx = section_items.index { |item| item.equal?(old_item) }
1395
-
1396
- raise ItemNotFound, 'Unable to find item in index, did it mutate?' unless s_idx
1397
-
1398
- return if section_items[s_idx].equal?(new_item)
1399
-
1400
- section_items[s_idx] = new_item
1401
- logger.count(:updated)
1402
- logger.info('Entry updated:', section_items[s_idx].title.truncate(60))
1403
- new_item
1404
- end
1405
-
1406
1198
  ##
1407
1199
  ## Edit the last entry
1408
1200
  ##
@@ -1418,16 +1210,18 @@ module Doing
1418
1210
  return
1419
1211
  end
1420
1212
 
1421
- content = [item.title.dup]
1422
- content << item.note.to_s unless item.note.empty?
1213
+ content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
1214
+ content << item.note.strip_lines.join("\n") unless item.note.empty?
1423
1215
  new_item = fork_editor(content.join("\n"))
1424
- title, note = format_input(new_item)
1216
+ date, title, note = format_input(new_item)
1217
+ date ||= item.date
1425
1218
 
1426
1219
  if title.nil? || title.empty?
1427
1220
  logger.debug('Skipped:', 'No content provided')
1428
- elsif title == item.title && note.equal?(item.note)
1221
+ elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
1429
1222
  logger.debug('Skipped:', 'No change in content')
1430
1223
  else
1224
+ item.date = date unless date.nil?
1431
1225
  item.title = title
1432
1226
  item.note.add(note, replace: true)
1433
1227
  logger.info('Edited:', item.title)
@@ -1446,6 +1240,11 @@ module Doing
1446
1240
  ## @param target_tag [String] Tag to replace
1447
1241
  ## @param opt [Hash] Additional Options
1448
1242
  ##
1243
+ ## @option opt :section [String] target section
1244
+ ## @option opt :archive [Boolean] archive old item
1245
+ ## @option opt :back [Date] backdate new item
1246
+ ## @option opt :new_item [String] content to use for new item
1247
+ ## @option opt :note [Array] note content for new item
1449
1248
  def stop_start(target_tag, opt = {})
1450
1249
  tag = target_tag.dup
1451
1250
  opt[:section] ||= @config['current_section']
@@ -1460,7 +1259,9 @@ module Doing
1460
1259
 
1461
1260
  found_items = 0
1462
1261
 
1463
- @content[opt[:section]][:items].each_with_index do |item, i|
1262
+ @content.each_with_index do |item, i|
1263
+ next unless item.section == opt[:section] || opt[:section] =~ /all/i
1264
+
1464
1265
  next unless item.title =~ /@#{tag}/
1465
1266
 
1466
1267
  item.title.add_tags!([tag, 'done'], remove: true)
@@ -1470,7 +1271,7 @@ module Doing
1470
1271
 
1471
1272
  if opt[:archive] && opt[:section] != 'Archive'
1472
1273
  item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1473
- move_item(item, 'Archive', label: false)
1274
+ item.move_to('Archive', label: false, log: false)
1474
1275
  logger.count(:completed_archived)
1475
1276
  logger.info('Completed/archived:', item.title)
1476
1277
  else
@@ -1482,7 +1283,8 @@ module Doing
1482
1283
  logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1483
1284
 
1484
1285
  if opt[:new_item]
1485
- title, note = format_input(opt[:new_item])
1286
+ date, title, note = format_input(opt[:new_item])
1287
+ opt[:back] = date unless date.nil?
1486
1288
  note.add(opt[:note]) if opt[:note]
1487
1289
  title.tag!(tag)
1488
1290
  add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
@@ -1499,7 +1301,6 @@ module Doing
1499
1301
  def write(file = nil, backup: true)
1500
1302
  Hooks.trigger :pre_write, self, file
1501
1303
  output = combined_content
1502
-
1503
1304
  if file.nil?
1504
1305
  $stdout.puts output
1505
1306
  else
@@ -1516,7 +1317,7 @@ module Doing
1516
1317
  def restore_backup(file)
1517
1318
  if File.exist?("#{file}~")
1518
1319
  FileUtils.cp("#{file}~", file)
1519
- logger.warn('File update:', "Restored #{file.sub(/^#{@user_home}/, '~')}")
1320
+ logger.warn('File update:', "Restored #{file.sub(/^#{Util.user_home}/, '~')}")
1520
1321
  else
1521
1322
  logger.error('Restore error:', 'No backup file found')
1522
1323
  end
@@ -1532,70 +1333,46 @@ module Doing
1532
1333
  bool = opt[:bool] || :and
1533
1334
  sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1534
1335
 
1535
- if sect =~ /^all$/i
1536
- all_sections = sections.dup
1537
- else
1538
- all_sections = [sect]
1539
- end
1336
+ section = guess_section(sect)
1540
1337
 
1541
- counter = 0
1542
- new_content = {}
1338
+ section_items = @content.in_section(section)
1339
+ max = section_items.count - keep.to_i
1543
1340
 
1341
+ counter = 0
1342
+ new_content = Items.new
1544
1343
 
1545
- all_sections.each do |section|
1546
- items = @content[section][:items].dup
1547
- new_content[section] = {}
1548
- new_content[section][:original] = @content[section][:original]
1549
- new_content[section][:items] = []
1550
-
1551
- moved_items = []
1552
- if !tags.empty? || opt[:search] || opt[:before]
1553
- if opt[:before]
1554
- time_string = opt[:before]
1555
- cutoff = chronify(time_string, guess: :begin)
1556
- end
1557
-
1558
- items.delete_if do |item|
1559
- if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1560
- moved_items.push(item)
1561
- counter += 1
1562
- true
1563
- else
1564
- false
1565
- end
1566
- end
1567
- @content[section][:items] = items
1568
- new_content[section][:items] = moved_items
1569
- logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
1570
- else
1571
- new_content[section][:items] = []
1572
- moved_items = []
1573
-
1574
- count = items.length < keep ? items.length : keep
1575
-
1576
- if items.count > count
1577
- moved_items.concat(items[count..-1])
1578
- else
1579
- moved_items.concat(items)
1580
- end
1344
+ @content.each do |item|
1345
+ break if counter >= max
1346
+ if opt[:before]
1347
+ time_string = opt[:before]
1348
+ cutoff = time_string.chronify(guess: :begin)
1349
+ end
1581
1350
 
1582
- @content[section][:items] = if count.zero?
1583
- []
1584
- else
1585
- items[0..count - 1]
1586
- end
1587
- new_content[section][:items] = moved_items
1351
+ unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
1352
+ new_item = @content.delete(item)
1353
+ raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
1588
1354
 
1589
- logger.warn('Rotated:', "#{items.length - count} items from #{section}")
1355
+ new_content.add_section(new_item.section, log: false)
1356
+ new_content.push(new_item)
1357
+ counter += 1
1590
1358
  end
1591
1359
  end
1592
1360
 
1361
+ if counter.positive?
1362
+ logger.count(:rotated,
1363
+ level: :info,
1364
+ count: counter,
1365
+ message: "Rotated %count %items")
1366
+ else
1367
+ logger.info('Skipped:', 'No items were rotated')
1368
+ end
1369
+
1593
1370
  write(@doing_file)
1594
1371
 
1595
1372
  file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1596
1373
  if File.exist?(file)
1597
1374
  init_doing_file(file)
1598
- @content.deep_merge(new_content)
1375
+ @content.concat(new_content).uniq!
1599
1376
  logger.warn('File update:', "added entries to existing file: #{file}")
1600
1377
  else
1601
1378
  @content = new_content
@@ -1611,7 +1388,7 @@ module Doing
1611
1388
  ## @return [String] The selected section name
1612
1389
  ##
1613
1390
  def choose_section
1614
- choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1391
+ choice = Prompt.choose_from(@content.section_titles.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1615
1392
  choice ? choice.strip : choice
1616
1393
  end
1617
1394
 
@@ -1630,7 +1407,7 @@ module Doing
1630
1407
  ## @return [String] The selected view name
1631
1408
  ##
1632
1409
  def choose_view
1633
- choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1410
+ choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1634
1411
  choice ? choice.strip : choice
1635
1412
  end
1636
1413
 
@@ -1688,7 +1465,7 @@ module Doing
1688
1465
  end
1689
1466
  end
1690
1467
 
1691
- items = filter_items([], opt: opt).reverse
1468
+ items = filter_items(Items.new, opt: opt).reverse
1692
1469
 
1693
1470
  items.reverse! if opt[:order] =~ /^d/i
1694
1471
 
@@ -1696,7 +1473,7 @@ module Doing
1696
1473
  opt[:menu] = !opt[:force]
1697
1474
  opt[:query] = '' # opt[:search]
1698
1475
  opt[:multiple] = true
1699
- selected = choose_from_items(items, opt, include_section: opt[:section] =~ /^all$/i )
1476
+ selected = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **opt)
1700
1477
 
1701
1478
  raise NoResults, 'no items selected' if selected.empty?
1702
1479
 
@@ -1704,11 +1481,8 @@ module Doing
1704
1481
  return
1705
1482
  end
1706
1483
 
1707
-
1708
1484
  opt[:output] ||= 'template'
1709
-
1710
1485
  opt[:wrap_width] ||= @config['templates']['default']['wrap_width'] || 0
1711
-
1712
1486
  output(items, title, is_single, opt)
1713
1487
  end
1714
1488
 
@@ -1729,11 +1503,12 @@ module Doing
1729
1503
  archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1730
1504
  section = guess_section(section) unless archive_all
1731
1505
 
1732
- add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
1506
+ @content.add_section(destination, log: true)
1507
+ # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
1733
1508
 
1734
1509
  destination = guess_section(destination)
1735
1510
 
1736
- if sections.include?(destination) && (sections.include?(section) || archive_all)
1511
+ if @content.section?(destination) && (@content.section?(section) || archive_all)
1737
1512
  do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
1738
1513
  write(doing_file)
1739
1514
  else
@@ -1758,10 +1533,12 @@ module Doing
1758
1533
  'order' => @config['order'] || 'asc',
1759
1534
  'tags_color' => @config['tags_color']
1760
1535
  })
1536
+
1761
1537
  options = {
1762
1538
  after: opt[:after],
1763
1539
  before: opt[:before],
1764
1540
  count: 0,
1541
+ from: opt[:from],
1765
1542
  format: cfg['date_format'],
1766
1543
  order: cfg['order'] || 'asc',
1767
1544
  output: output,
@@ -1818,6 +1595,7 @@ module Doing
1818
1595
  after: opt[:after],
1819
1596
  before: opt[:before],
1820
1597
  count: 0,
1598
+ from: opt[:from],
1821
1599
  order: opt[:order],
1822
1600
  output: output,
1823
1601
  section: section,
@@ -1973,7 +1751,6 @@ module Doing
1973
1751
  end
1974
1752
  end
1975
1753
 
1976
-
1977
1754
  logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
1978
1755
  logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
1979
1756
  logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
@@ -1986,10 +1763,10 @@ module Doing
1986
1763
  text.add_tags!(tail_tags) unless tail_tags.empty?
1987
1764
 
1988
1765
  if text == original
1989
- logger.debug('Autotag:', "no change to \"#{text}\"")
1766
+ logger.debug('Autotag:', "no change to \"#{text.strip}\"")
1990
1767
  else
1991
1768
  new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
1992
- logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text}\"")
1769
+ logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
1993
1770
  logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
1994
1771
  end
1995
1772
 
@@ -2166,7 +1943,7 @@ EOS
2166
1943
  def format_time(seconds, human: false)
2167
1944
  return [0, 0, 0] if seconds.nil?
2168
1945
 
2169
- if seconds.class == String && seconds =~ /(\d+):(\d+):(\d+)/
1946
+ if seconds.instance_of?(String) && seconds =~ /(\d+):(\d+):(\d+)/
2170
1947
  h = Regexp.last_match(1)
2171
1948
  m = Regexp.last_match(2)
2172
1949
  s = Regexp.last_match(3)
@@ -2195,13 +1972,13 @@ EOS
2195
1972
  ##
2196
1973
  def combined_content
2197
1974
  output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
2198
-
2199
- @content.each do |title, section|
2200
- output += "#{section[:original]}\n"
2201
- output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0, tags_color: false })
2202
- end
2203
-
1975
+ was_color = Color.coloring?
1976
+ Color.coloring = false
1977
+ output += @content.to_s
2204
1978
  output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
1979
+ # Just strip all ANSI colors from the content before writing to doing file
1980
+ Color.coloring = was_color
1981
+
2205
1982
  output.uncolor
2206
1983
  end
2207
1984
 
@@ -2256,99 +2033,50 @@ EOS
2256
2033
  ##
2257
2034
  ## Helper function, performs the actual archiving
2258
2035
  ##
2259
- ## @param sect [String] The source section
2036
+ ## @param section [String] The source section
2260
2037
  ## @param destination [String] The destination
2261
2038
  ## section
2262
2039
  ## @param opt [Hash] Additional Options
2263
2040
  ##
2264
- def do_archive(sect, destination, opt = {})
2041
+ def do_archive(section, destination, opt = {})
2265
2042
  count = opt[:count] || 0
2266
2043
  tags = opt[:tags] || []
2267
2044
  bool = opt[:bool] || :and
2268
2045
  label = opt[:label] || true
2269
2046
 
2270
- if sect =~ /^all$/i
2271
- all_sections = sections.dup
2272
- all_sections.delete(destination)
2273
- else
2274
- all_sections = [sect]
2275
- end
2276
-
2277
- counter = 0
2047
+ section = guess_section(section)
2048
+ destination = guess_section(destination)
2278
2049
 
2279
- all_sections.each do |section|
2280
- items = @content[section][:items].dup
2050
+ section_items = @content.in_section(section)
2051
+ max = section_items.count - count.to_i
2281
2052
 
2282
- moved_items = []
2283
- if !tags.empty? || opt[:search] || opt[:before]
2284
- if opt[:before]
2285
- time_string = opt[:before]
2286
- cutoff = chronify(time_string, guess: :begin)
2287
- end
2053
+ counter = 0
2288
2054
 
2289
- items.delete_if do |item|
2290
- if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
2291
- moved_items.push(item)
2292
- counter += 1
2293
- true
2294
- else
2295
- false
2296
- end
2297
- end
2298
- moved_items.each do |item|
2299
- if label
2300
- item.title = if section == @config['current_section']
2301
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2302
- else
2303
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2304
- end
2305
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2306
- end
2307
- end
2055
+ @content.map! do |item|
2056
+ break if counter >= max
2057
+ if opt[:before]
2058
+ time_string = opt[:before]
2059
+ cutoff = time_string.chronify(guess: :begin)
2060
+ end
2308
2061
 
2309
- @content[section][:items] = items
2310
- @content[destination][:items].concat(moved_items)
2311
- if moved_items.length.positive?
2312
- logger.count(destination == 'Archive' ? :archived : :moved,
2313
- level: :info,
2314
- count: moved_items.length,
2315
- message: "%count %items from #{section} to #{destination}")
2316
- else
2317
- logger.info('Skipped:', 'No items were moved')
2318
- end
2062
+ if (item.section.downcase != section.downcase && section != /^all$/i) || item.section.downcase == destination.downcase
2063
+ item
2064
+ elsif ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
2065
+ item
2319
2066
  else
2320
- count = items.length if items.length < count
2321
-
2322
- items.map! do |item|
2323
- if label
2324
- item.title = if section == @config['current_section']
2325
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
2326
- else
2327
- item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2328
- end
2329
- logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2330
- end
2331
- item
2332
- end
2333
-
2334
- if items.count > count
2335
- @content[destination][:items].concat(items[count..-1])
2336
- else
2337
- @content[destination][:items].concat(items)
2338
- end
2339
-
2340
- @content[section][:items] = if count.zero?
2341
- []
2342
- else
2343
- items[0..count - 1]
2344
- end
2345
-
2346
- logger.count(destination == 'Archive' ? :archived : :moved,
2347
- level: :info,
2348
- count: items.length - count,
2349
- message: "%count %items from #{section} to #{destination}")
2067
+ counter += 1
2068
+ item.move_to(destination, label: label, log: false)
2350
2069
  end
2351
2070
  end
2071
+
2072
+ if counter.positive?
2073
+ logger.count(destination == 'Archive' ? :archived : :moved,
2074
+ level: :info,
2075
+ count: counter,
2076
+ message: "%count %items from #{section} to #{destination}")
2077
+ else
2078
+ logger.info('Skipped:', 'No items were moved')
2079
+ end
2352
2080
  end
2353
2081
 
2354
2082
  def run_after
@@ -2360,31 +2088,5 @@ EOS
2360
2088
  logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
2361
2089
  logger.log_now(:error, 'STDERR output:', stderr)
2362
2090
  end
2363
-
2364
- def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false)
2365
- if tags_added.empty? && tags_removed.empty?
2366
- logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
2367
- else
2368
- if tags_added.empty?
2369
- logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
2370
- else
2371
- if single && item
2372
- logger.info('Tagged:', %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} to #{item.title}))
2373
- else
2374
- logger.count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items')
2375
- end
2376
- end
2377
-
2378
- if tags_removed.empty?
2379
- logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
2380
- else
2381
- if single && item
2382
- logger.info('Untagged:', %(removed #{tags_removed.count == 1 ? 'tag' : 'tags'} #{tags_added.map {|t| "@#{t}"}.join(', ')} from #{item.title}))
2383
- else
2384
- logger.count(:removed_tags, level: :info, tag: tags_removed, message: '%tags removed from %count %items')
2385
- end
2386
- end
2387
- end
2388
- end
2389
2091
  end
2390
2092
  end