doing 2.1.39 → 2.1.40

Sign up to get free protection for your applications and to get access to all the features.
Files changed (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +1 -1
  5. data/bin/commands/config.rb +43 -34
  6. data/bin/commands/done.rb +1 -18
  7. data/bin/commands/finish.rb +30 -25
  8. data/bin/commands/grep.rb +3 -14
  9. data/bin/commands/last.rb +2 -8
  10. data/bin/commands/meanwhile.rb +13 -6
  11. data/bin/commands/on.rb +3 -16
  12. data/bin/commands/recent.rb +2 -8
  13. data/bin/commands/reset.rb +24 -1
  14. data/bin/commands/select.rb +1 -1
  15. data/bin/commands/show.rb +6 -17
  16. data/bin/commands/since.rb +1 -12
  17. data/bin/commands/today.rb +2 -13
  18. data/bin/commands/view.rb +1 -1
  19. data/bin/commands/yesterday.rb +2 -13
  20. data/bin/doing +15 -8
  21. data/docs/doc/Array.html +1 -1
  22. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  23. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  24. data/docs/doc/BooleanTermParser/Query.html +1 -1
  25. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  26. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  27. data/docs/doc/BooleanTermParser.html +1 -1
  28. data/docs/doc/Doing/Color.html +166 -20
  29. data/docs/doc/Doing/Completion.html +1 -1
  30. data/docs/doc/Doing/Configuration.html +1 -1
  31. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  32. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  33. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  34. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  35. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  36. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  37. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  38. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  39. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  40. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  41. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  42. data/docs/doc/Doing/Errors.html +9 -9
  43. data/docs/doc/Doing/Hooks.html +1 -1
  44. data/docs/doc/Doing/Item.html +90 -1615
  45. data/docs/doc/Doing/Items.html +121 -5
  46. data/docs/doc/Doing/Logger.html +1 -1
  47. data/docs/doc/Doing/Note.html +1 -1
  48. data/docs/doc/Doing/Pager.html +1 -1
  49. data/docs/doc/Doing/Plugins.html +1 -1
  50. data/docs/doc/Doing/Prompt.html +2 -2
  51. data/docs/doc/Doing/Section.html +1 -1
  52. data/docs/doc/Doing/TemplateString.html +2 -2
  53. data/docs/doc/Doing/Types.html +1 -1
  54. data/docs/doc/Doing/Util/Backup.html +5 -5
  55. data/docs/doc/Doing/Util.html +1 -1
  56. data/docs/doc/Doing/WWID.html +197 -4033
  57. data/docs/doc/Doing.html +2 -2
  58. data/docs/doc/FalseClass.html +1 -1
  59. data/docs/doc/GLI/Commands/Help.html +1 -1
  60. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  61. data/docs/doc/GLI/Commands.html +1 -1
  62. data/docs/doc/GLI.html +1 -1
  63. data/docs/doc/Hash.html +1 -1
  64. data/docs/doc/Object.html +1 -1
  65. data/docs/doc/PhraseParser/Operator.html +1 -1
  66. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  67. data/docs/doc/PhraseParser/Query.html +1 -1
  68. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  69. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  70. data/docs/doc/PhraseParser/TermClause.html +1 -1
  71. data/docs/doc/PhraseParser.html +1 -1
  72. data/docs/doc/Status.html +1 -1
  73. data/docs/doc/String.html +1 -1
  74. data/docs/doc/Symbol.html +1 -1
  75. data/docs/doc/Time.html +1 -1
  76. data/docs/doc/TrueClass.html +1 -1
  77. data/docs/doc/_index.html +26 -5
  78. data/docs/doc/class_list.html +1 -1
  79. data/docs/doc/file.README.html +2 -2
  80. data/docs/doc/index.html +2 -2
  81. data/docs/doc/method_list.html +293 -773
  82. data/docs/doc/top-level-namespace.html +3 -3
  83. data/docs/index.md +1 -1
  84. data/doing.rdoc +49 -7
  85. data/lib/completion/_doing.zsh +5 -5
  86. data/lib/completion/doing.bash +8 -8
  87. data/lib/completion/doing.fish +7 -2
  88. data/lib/doing/add_options.rb +31 -1
  89. data/lib/doing/chronify/array.rb +64 -22
  90. data/lib/doing/colors.rb +77 -30
  91. data/lib/doing/completion.rb +4 -5
  92. data/lib/doing/errors.rb +51 -35
  93. data/lib/doing/hooks.rb +3 -3
  94. data/lib/doing/item/dates.rb +112 -0
  95. data/lib/doing/item/query.rb +433 -0
  96. data/lib/doing/item/state.rb +59 -0
  97. data/lib/doing/item/tags.rb +87 -0
  98. data/lib/doing/item.rb +6 -667
  99. data/lib/doing/items.rb +38 -13
  100. data/lib/doing/plugin_manager.rb +3 -3
  101. data/lib/doing/plugins/export/template_export.rb +4 -4
  102. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  103. data/lib/doing/util_backup.rb +6 -8
  104. data/lib/doing/version.rb +1 -1
  105. data/lib/doing/wwid/display.rb +399 -0
  106. data/lib/doing/wwid/editor.rb +214 -0
  107. data/lib/doing/wwid/filetools.rb +186 -0
  108. data/lib/doing/wwid/filter.rb +218 -0
  109. data/lib/doing/wwid/guess.rb +87 -0
  110. data/lib/doing/wwid/interactive.rb +385 -0
  111. data/lib/doing/wwid/modify.rb +618 -0
  112. data/lib/doing/wwid/tags.rb +54 -0
  113. data/lib/doing/wwid/timers.rb +345 -0
  114. data/lib/doing/wwid/wwidutil.rb +104 -0
  115. data/lib/doing/wwid.rb +31 -2317
  116. metadata +19 -2
data/lib/doing/wwid.rb CHANGED
@@ -7,16 +7,37 @@ require 'pp'
7
7
  require 'shellwords'
8
8
  require 'erb'
9
9
 
10
+ require_relative 'wwid/display'
11
+ require_relative 'wwid/editor'
12
+ require_relative 'wwid/filetools'
13
+ require_relative 'wwid/filter'
14
+ require_relative 'wwid/guess'
15
+ require_relative 'wwid/interactive'
16
+ require_relative 'wwid/modify'
17
+ require_relative 'wwid/tags'
18
+ require_relative 'wwid/timers'
19
+ require_relative 'wwid/wwidutil'
20
+
10
21
  module Doing
11
22
  ##
12
23
  ## Main "What Was I Doing" methods
13
24
  ##
14
25
  class WWID
15
- attr_reader :additional_configs, :current_section, :doing_file, :content
26
+ attr_reader :additional_configs, :current_section, :doing_file, :content, :initial_content
16
27
 
17
28
  attr_accessor :config, :config_file, :default_option
18
29
 
19
30
  include Color
31
+ include Display
32
+ include Editor
33
+ include FileTools
34
+ include Filter
35
+ include Guess
36
+ include Interactive
37
+ include Modify
38
+ include Tags
39
+ include Timers
40
+ include WWIDUtil
20
41
  # include Util
21
42
 
22
43
  ##
@@ -47,183 +68,6 @@ module Doing
47
68
  @logger ||= Doing.logger
48
69
  end
49
70
 
50
- ##
51
- ## Initializes the doing file.
52
- ##
53
- ## @param path [String] Override path to a doing file, optional
54
- ##
55
- def init_doing_file(path = nil)
56
- @doing_file = File.expand_path(Doing.setting('doing_file'))
57
-
58
- if path.nil?
59
- create(@doing_file) unless File.exist?(@doing_file)
60
- input = IO.read(@doing_file)
61
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
62
- logger.debug('Read:', "read file #{@doing_file}")
63
- elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
64
- @doing_file = File.expand_path(path)
65
- input = IO.read(File.expand_path(path))
66
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
67
- logger.debug('Read:', "read file #{File.expand_path(path)}")
68
- elsif path.length < 256
69
- @doing_file = File.expand_path(path)
70
- create(path)
71
- input = IO.read(File.expand_path(path))
72
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
73
- logger.debug('Read:', "read file #{File.expand_path(path)}")
74
- end
75
-
76
- @other_content_top = []
77
- @other_content_bottom = []
78
-
79
- section = nil
80
- lines = input.split(/[\n\r]/)
81
-
82
- lines.each do |line|
83
- next if line =~ /^\s*$/
84
-
85
- if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
86
- section = Regexp.last_match(1)
87
- @content.add_section(Section.new(section, original: line), log: false)
88
- elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
89
- if section.nil?
90
- section = 'Uncategorized'
91
- @content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
92
- end
93
-
94
- date = Regexp.last_match(1).strip
95
- title = Regexp.last_match(2).strip
96
- item = Item.new(date, title, section)
97
- @content.push(item)
98
- elsif @content.count.zero?
99
- # if content[section].items.length - 1 == current
100
- @other_content_top.push(line)
101
- elsif line =~ /^\S/
102
- @other_content_bottom.push(line)
103
- else
104
- prev_item = @content.last
105
- prev_item.note = Note.new unless prev_item.note
106
-
107
- prev_item.note.add(line)
108
- # end
109
- end
110
- end
111
-
112
- Hooks.trigger :post_read, self
113
- end
114
-
115
- ##
116
- ## Create a new doing file
117
- ##
118
- def create(filename = nil)
119
- filename = @doing_file if filename.nil?
120
- return if File.exist?(filename) && File.stat(filename).size.positive?
121
-
122
- FileUtils.mkdir_p(File.dirname(filename)) unless File.directory?(File.dirname(filename))
123
-
124
- File.open(filename, 'w+') do |f|
125
- f.puts "#{Doing.setting('current_section')}:"
126
- end
127
- end
128
-
129
- ##
130
- ## Create a process for an editor and wait for the file handle to return
131
- ##
132
- ## @param input [String] Text input for editor
133
- ##
134
- def fork_editor(input = '', message: :default)
135
- # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
136
-
137
- raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
138
-
139
- tmpfile = Tempfile.new(['doing', '.md'])
140
-
141
- File.open(tmpfile.path, 'w+') do |f|
142
- f.puts input
143
- unless message.nil?
144
- f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message
145
- end
146
- end
147
-
148
- pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
149
-
150
- trap('INT') do
151
- begin
152
- Process.kill(9, pid)
153
- rescue StandardError
154
- Errno::ESRCH
155
- end
156
- tmpfile.unlink
157
- tmpfile.close!
158
- exit 0
159
- end
160
-
161
- Process.wait(pid)
162
-
163
- begin
164
- if $?.exitstatus == 0
165
- input = IO.read(tmpfile.path)
166
- else
167
- exit_now! 'Cancelled'
168
- end
169
- ensure
170
- tmpfile.close
171
- tmpfile.unlink
172
- end
173
-
174
- input.split(/\n/).delete_if(&:ignore?).join("\n")
175
- end
176
-
177
- ##
178
- ## Takes a multi-line string and formats it as an entry
179
- ##
180
- ## @param input [String] The string to parse
181
- ##
182
- ## @return [Array] [[String]title, [Note]note]
183
- ##
184
- def format_input(input)
185
- raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
186
-
187
- input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
188
- title = input_lines[0]&.strip
189
- raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
190
-
191
- date = nil
192
- iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
193
- date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
194
-
195
- raise EmptyInput, 'No content' if title.sub(/^.*?\| */, '').strip.empty?
196
-
197
- title.expand_date_tags(Doing.setting('date_tags'))
198
-
199
- if title =~ date_rx
200
- m = title.match(date_rx)
201
- d = m['date']
202
- date = if d =~ iso_rx
203
- Time.parse(d)
204
- else
205
- d.chronify(guess: :begin)
206
- end
207
- title.sub!(date_rx, '').strip!
208
- end
209
-
210
- note = Note.new
211
- note.add(input_lines[1..-1]) if input_lines.length > 1
212
- # If title line ends in a parenthetical, use that as the note
213
- if note.empty? && title =~ /\s+\(.*?\)$/
214
- title.sub!(/\s+\((?<note>.*?)\)$/) do
215
- m = Regexp.last_match
216
- note.add(m['note'])
217
- ''
218
- end
219
- end
220
-
221
- note.strip_lines!
222
- note.compress
223
-
224
- [date, title, note]
225
- end
226
-
227
71
  ##
228
72
  ## List sections
229
73
  ##
@@ -234,2157 +78,27 @@ module Doing
234
78
  end
235
79
 
236
80
  ##
237
- ## Attempt to match a string with an existing section
238
- ##
239
- ## @param frag [String] The user-provided string
240
- ## @param guessed [Boolean] already guessed and failed
241
- ##
242
- def guess_section(frag, guessed: false, suggest: false)
243
- return 'All' if frag =~ /^all$/i
244
- frag ||= Doing.setting('current_section')
245
-
246
- return frag.cap_first if @content.section?(frag)
247
-
248
- section = nil
249
- re = frag.to_rx(distance: 2, case_type: :ignore)
250
- sections.each do |sect|
251
- next unless sect =~ /#{re}/i
252
-
253
- logger.debug('Match:', %(Assuming "#{sect}" from "#{frag}"))
254
- section = sect
255
- break
256
- end
257
-
258
- return section if suggest
259
-
260
- unless section || guessed
261
- alt = guess_view(frag, guessed: true, suggest: true)
262
- if alt
263
- meant_view = Prompt.yn("#{boldwhite("Did you mean")} `#{yellow("doing view #{alt}")}#{boldwhite}`?", default_response: 'n')
264
-
265
- raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view
266
-
267
- end
268
-
269
- res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
270
-
271
- if res
272
- @content.add_section(frag.cap_first, log: true)
273
- write(@doing_file)
274
- return frag.cap_first
275
- end
276
-
277
- raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
278
- end
279
- section ? section.cap_first : guessed
280
- end
281
-
282
- ##
283
- ## Attempt to match a string with an existing view
284
- ##
285
- ## @param frag [String] The user-provided string
286
- ## @param guessed [Boolean] already guessed
287
- ##
288
- def guess_view(frag, guessed: false, suggest: false)
289
- views.each { |view| return view if frag.downcase == view.downcase }
290
- view = false
291
- re = frag.to_rx(distance: 2, case_type: :ignore)
292
- views.each do |v|
293
- next unless v =~ /#{re}/i
294
-
295
- logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
296
- view = v
297
- break
298
- end
299
- unless view || guessed
300
- alt = guess_section(frag, guessed: true, suggest: true)
301
-
302
- raise InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
303
-
304
- meant_view = Prompt.yn("Did you mean `doing show #{alt}`?", default_response: 'n')
305
-
306
- raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view
307
-
308
- raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
309
- end
310
- view
311
- end
312
-
313
- def add_with_editor(**options)
314
- raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
315
-
316
- input = options[:date].strftime('%F %R | ')
317
- input += options[:title]
318
- input += "\n#{options[:note]}" if options[:note]
319
- input = fork_editor(input).strip
320
-
321
- d, title, note = format_input(input)
322
- raise EmptyInput, 'No content' if title.empty?
323
-
324
- if options[:ask]
325
- ask_note = Doing::Prompt.read_lines(prompt: 'Add a note')
326
- note.add(ask_note) unless ask_note.empty?
327
- end
328
-
329
- date = d.nil? ? options[:date] : d
330
- finish = options[:finish_last] || false
331
- add_item(title.cap_first, options[:section], { note: note, back: date, timed: finish })
332
- write(@doing_file)
333
- end
334
-
335
- ##
336
- ## Adds an entry
337
- ##
338
- ## @param title [String] The entry title
339
- ## @param section [String] The section to add to
340
- ## @param opt [Hash] Additional Options
341
- ##
342
- ## @option opt :date [Date] item start date
343
- ## @option opt :note [Array] item note (will be converted if value is String)
344
- ## @option opt :back [Date] backdate
345
- ## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
346
- ## @option opt :done [Date] If set, adds a @done tag to new entry
347
- ##
348
- def add_item(title, section = nil, opt)
349
- opt ||= {}
350
- section ||= Doing.setting('current_section')
351
- @content.add_section(section, log: false)
352
- opt[:back] ||= opt[:date] ? opt[:date] : Time.now
353
- opt[:date] ||= Time.now
354
- note = Note.new
355
- opt[:timed] ||= false
356
-
357
- note.add(opt[:note]) if opt[:note]
358
-
359
- title = [title.strip.cap_first]
360
- title = title.join(' ')
361
-
362
- if Doing.auto_tag
363
- title = autotag(title)
364
- title.add_tags!(Doing.setting('default_tags')) if Doing.setting('default_tags').good?
365
- end
366
-
367
- title.compress!
368
- entry = Item.new(opt[:back], title.strip, section)
369
-
370
- if opt[:done] && entry.should_finish?
371
- if entry.should_time?
372
- entry.tag('done', value: opt[:done])
373
- else
374
- entry.tag('done')
375
- end
376
- end
377
-
378
- entry.note = note
379
-
380
- items = @content.clone
381
- if opt[:timed]
382
- items.reverse!
383
- items.each_with_index do |i, x|
384
- next if i.title =~ / @done/
385
-
386
- finish_date = verify_duration(i.date, opt[:back], title: i.title)
387
- items[x].tag('done', value: finish_date.strftime('%F %R'))
388
- break
389
- end
390
- end
391
-
392
- Hooks.trigger :pre_entry_add, self, entry
393
-
394
- @content.push(entry)
395
- # logger.count(:added, level: :debug)
396
- logger.info('New entry:', %(added "#{entry.date.relative_date}: #{entry.title}" to #{section}))
397
-
398
- Hooks.trigger :post_entry_added, self, entry
399
- entry
400
- end
401
-
402
- ##
403
- ## Remove items from an array that already exist in
404
- ## @content based on start and end times
405
- ##
406
- ## @param items [Array] The items to
407
- ## deduplicate
408
- ## @param no_overlap [Boolean] Remove items with
409
- ## overlapping time spans
410
- ##
411
- def dedup(items, no_overlap: false)
412
- items.delete_if do |item|
413
- duped = false
414
- @content.each do |comp|
415
- duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
416
- break if duped
417
- end
418
- logger.count(:skipped, level: :debug, message: '%count overlapping %items') if duped
419
- # logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped
420
- duped
421
- end
422
- end
423
-
424
- ##
425
- ## Imports external entries
426
- ##
427
- ## @param paths [String] Path to JSON report file
428
- ## @param opt [Hash] Additional Options
429
- ##
430
- def import(paths, opt)
431
- opt ||= {}
432
- Plugins.plugins[:import].each do |_, options|
433
- next unless opt[:type] =~ /^(#{options[:trigger].normalize_trigger})$/i
434
-
435
- if paths.count.positive?
436
- paths.each do |path|
437
- options[:class].import(self, path, options: opt)
438
- end
439
- else
440
- options[:class].import(self, nil, options: opt)
441
- end
442
- break
443
- end
444
- end
445
-
446
- ##
447
- ## Return the content of the last note for a given section
448
- ##
449
- ## @param section [String] The section to retrieve from, default
450
- ## All
451
- ##
452
- def last_note(section = 'All')
453
- section = guess_section(section)
454
-
455
- last_item = last_entry({ section: section })
456
-
457
- raise NoEntryError, 'No entry found' unless last_item
458
-
459
- logger.log_now(:info, 'Edit note:', last_item.title)
460
-
461
- note = last_item.note&.to_s || ''
462
- "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
463
- end
464
-
465
- # Reset start date to current time, optionally remove
466
- # done tag (resume)
467
- #
468
- # @param item [Item] the item to reset/resume
469
- # @param resume [Boolean] removing @done tag if true
470
- #
471
- def reset_item(item, date: nil, resume: false)
472
- date ||= Time.now
473
- item.date = date
474
- item.tag('done', remove: true) if resume
475
- logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
476
- item
477
- end
478
-
479
- # Duplicate an item and add it as a new item
480
- #
481
- # @param item [Item] the item to duplicate
482
- # @param opt [Hash] additional options
483
- #
484
- # @option opt :editor [Boolean] open new item in editor
485
- # @option opt :date [String] set start date
486
- # @option opt :in [String] add new item to section :in
487
- # @option opt :note [Note] add note to new item
488
- #
489
- # @return nothing
490
- #
491
- def repeat_item(item, opt)
492
- opt ||= {}
493
- old_item = item.clone
494
- if item.should_finish?
495
- if item.should_time?
496
- finish_date = verify_duration(item.date, Time.now, title: item.title)
497
- item.title.tag!('done', value: finish_date.strftime('%F %R'))
498
- else
499
- item.title.tag!('done')
500
- end
501
- Hooks.trigger :post_entry_updated, self, item, old_item
502
- end
503
-
504
- # Remove @done tag
505
- title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
506
- section = opt[:in].nil? ? item.section : guess_section(opt[:in])
507
- Doing.auto_tag = false
508
-
509
- note = opt[:note] || Note.new
510
-
511
- if opt[:editor]
512
- start = opt[:date] ? opt[:date] : Time.now
513
- to_edit = "#{start.strftime('%F %R')} | #{title}"
514
- to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
515
- new_item = fork_editor(to_edit)
516
- date, title, note = format_input(new_item)
517
-
518
- opt[:date] = date unless date.nil?
519
-
520
- if title.nil? || title.empty?
521
- logger.warn('Skipped:', 'No content provided')
522
- return
523
- end
524
- end
525
-
526
- # @content.update_item(original, item)
527
- add_item(title, section, { note: note, back: opt[:date], timed: false })
528
- end
529
-
530
- ##
531
- ## Restart the last entry
532
- ##
533
- ## @param opt [Hash] Additional Options
534
- ##
535
- def repeat_last(opt)
536
- opt ||= {}
537
- opt[:section] ||= 'all'
538
- opt[:section] = guess_section(opt[:section])
539
- opt[:note] ||= []
540
- opt[:tag] ||= []
541
- opt[:tag_bool] ||= :and
542
-
543
- last = last_entry(opt)
544
- if last.nil?
545
- logger.warn('Skipped:', 'No previous entry found')
546
- return
547
- end
548
-
549
- repeat_item(last, opt)
550
- write(@doing_file)
551
- end
552
-
553
- ##
554
- ## Get the last entry
555
- ##
556
- ## @param opt [Hash] Additional Options
557
- ##
558
- def last_entry(opt)
559
- opt ||= {}
560
- opt[:tag_bool] ||= :and
561
- opt[:section] ||= Doing.setting('current_section')
562
-
563
- items = filter_items(Items.new, opt: opt)
564
-
565
- logger.debug('Filtered:', "Parameters matched #{items.count} entries")
566
-
567
- if opt[:interactive]
568
- last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i,
569
- menu: true,
570
- header: '',
571
- prompt: 'Select an entry > ',
572
- multiple: false,
573
- sort: false,
574
- show_if_single: true
575
- )
576
- else
577
- last_entry = items.max_by { |item| item.date }
578
- end
579
-
580
- last_entry
581
- end
582
-
583
- def all_tags(items, opt: {}, counts: false)
584
- if counts
585
- all_tags = {}
586
- items.each do |item|
587
- item.tags.each do |tag|
588
- if all_tags.key?(tag.downcase)
589
- all_tags[tag.downcase] += 1
590
- else
591
- all_tags[tag.downcase] = 1
592
- end
593
- end
594
- end
595
-
596
- all_tags.sort_by { |tag, count| count }
597
- else
598
- all_tags = []
599
- items.each { |item| all_tags.concat(item.tags.map(&:downcase)).uniq! }
600
- all_tags.sort
601
- end
602
- end
603
-
604
- def tag_groups(items, opt: {})
605
- all_items = filter_items(items, opt: opt)
606
- tags = all_tags(all_items, opt: {})
607
- tag_groups = {}
608
- tags.each do |tag|
609
- tag_groups[tag] ||= []
610
- tag_groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
611
- end
612
-
613
- tag_groups
614
- end
615
-
616
- def fuzzy_filter_items(items, opt: {})
617
- scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n")
618
-
619
- fzf_args = [
620
- '--multi',
621
- %(--filter="#{opt[:search].sub(/^'?/, "'")}"),
622
- '--no-sort',
623
- '-d "\|"',
624
- '--nth=1'
625
- ]
626
- if opt[:case]
627
- fzf_args << case opt[:case].normalize_case
628
- when :sensitive
629
- '+i'
630
- when :ignore
631
- '-i'
632
- end
633
- end
634
- # fzf_args << '-e' if opt[:exact]
635
- # puts fzf_args.join(' ')
636
- res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
637
- selected = Items.new
638
- res.split(/\n/).each do |item|
639
- idx = item.match(/\|(\d+)$/)[1].to_i
640
- selected.push(items[idx])
641
- end
642
- selected
643
- end
644
-
645
- ##
646
- ## Filter items based on search criteria
647
- ##
648
- ## @param items [Array] The items to filter (if empty, filters all items)
649
- ## @param opt [Hash] The filter parameters
650
- ##
651
- ## @option opt [String] :section ('all')
652
- ## @option opt [Boolean] :unfinished (false)
653
- ## @option opt [Array or String] :tag ([]) Array or comma-separated string
654
- ## @option opt [Symbol] :tag_bool (:and) :and, :or, :not
655
- ## @option opt [String] :search ('') string, optional regex with `/string/`
656
- ## @option opt [Array] :date_filter (nil) [[Time]start, [Time]end]
657
- ## @option opt [Boolean] :only_timed (false)
658
- ## @option opt [String] :before (nil) Date/Time string, unparsed
659
- ## @option opt [String] :after (nil) Date/Time string, unparsed
660
- ## @option opt [Boolean] :today (false) limit to entries from today
661
- ## @option opt [Boolean] :yesterday (false) limit to entries from yesterday
662
- ## @option opt [Number] :count (0) max entries to return
663
- ## @option opt [String] :age (new) 'old' or 'new'
664
- ## @option opt [Array] :val (nil) Array of tag value queries
665
- ##
666
- def filter_items(items = Items.new, opt: {})
667
- logger.benchmark(:filter_items, :start)
668
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i
669
-
670
- if items.nil? || items.empty?
671
- section = opt[:section] ? guess_section(opt[:section]) : 'All'
672
- items = section =~ /^all$/i ? @content.clone : @content.in_section(section)
673
- end
674
-
675
- if !opt[:time_filter]
676
- opt[:time_filter] = [nil, nil]
677
- if opt[:from] && !opt[:date_filter]
678
- if opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
679
- opt[:time_filter] = opt[:from]
680
- elsif opt[:from][0].is_a?(Time)
681
- opt[:date_filter] = opt[:from]
682
- end
683
- end
684
- end
685
-
686
- if opt[:before].is_a?(String) && opt[:before] =~ time_rx
687
- opt[:time_filter][1] = opt[:before]
688
- opt[:before] = nil
689
- end
690
-
691
- if opt[:after].is_a?(String) && opt[:after] =~ time_rx
692
- opt[:time_filter][0] = opt[:after]
693
- opt[:after] = nil
694
- end
695
-
696
- items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
697
-
698
- filtered_items = items.select do |item|
699
- keep = true
700
- if opt[:unfinished]
701
- finished = item.tags?('done', :and)
702
- finished = opt[:not] ? !finished : finished
703
- keep = false if finished
704
- end
705
-
706
- if keep && opt[:val]&.count&.positive?
707
- bool = opt[:bool].normalize_bool if opt[:bool]
708
- bool ||= :and
709
- bool = :and if bool == :pattern
710
-
711
- val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
712
- keep = false unless val_match
713
- keep = opt[:not] ? !keep : keep
714
- end
715
-
716
- if keep && opt[:tag]
717
- opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
718
- opt[:tag_bool] ||= :and
719
- tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
720
- keep = false unless tag_match
721
- keep = opt[:not] ? !keep : keep
722
- end
723
-
724
- if keep && opt[:search]
725
- search_match = if opt[:search].nil? || opt[:search].empty?
726
- true
727
- else
728
- item.search(opt[:search], case_type: opt[:case].normalize_case)
729
- end
730
-
731
- keep = false unless search_match
732
- keep = opt[:not] ? !keep : keep
733
- end
734
-
735
- if keep && opt[:date_filter]&.length == 2
736
- start_date = opt[:date_filter][0]
737
- end_date = opt[:date_filter][1]
738
-
739
- in_date_range = if end_date
740
- item.date >= start_date && item.date <= end_date
741
- else
742
- item.date.strftime('%F') == start_date.strftime('%F')
743
- end
744
- keep = false unless in_date_range
745
- keep = opt[:not] ? !keep : keep
746
- end
747
-
748
- if keep && opt[:time_filter][0] || opt[:time_filter][1]
749
- start_string = if opt[:time_filter][0].nil?
750
- "#{item.date.strftime('%Y-%m-%d')} 12am"
751
- else
752
- "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
753
- end
754
- start_time = start_string.chronify(guess: :begin)
755
-
756
- end_string = if opt[:time_filter][1].nil?
757
- "#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 12am"
758
- else
759
- "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
760
- end
761
- end_time = end_string.chronify(guess: :end)
762
-
763
- in_time_range = item.date >= start_time && item.date <= end_time
764
- keep = false unless in_time_range
765
- keep = opt[:not] ? !keep : keep
766
- end
767
-
768
- keep = false if keep && opt[:only_timed] && !item.interval
769
-
770
- if keep && opt[:tag_filter]
771
- keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
772
- keep = opt[:not] ? !keep : keep
773
- end
774
-
775
- if keep && opt[:before]
776
- before = opt[:before]
777
- if before =~ time_rx
778
- cutoff = "#{item.date.strftime('%Y-%m-%d')} #{before}".chronify(guess: :begin)
779
- elsif before.is_a?(String)
780
- cutoff = before.chronify(guess: :begin)
781
- else
782
- cutoff = before
783
- end
784
- keep = cutoff && item.date <= cutoff
785
- keep = opt[:not] ? !keep : keep
786
- end
787
-
788
- if keep && opt[:after]
789
- after = opt[:after]
790
- if after =~ time_rx
791
- cutoff = "#{item.date.strftime('%Y-%m-%d')} #{after}".chronify(guess: :end)
792
- elsif after.is_a?(String)
793
- cutoff = after.chronify(guess: :end)
794
- else
795
- cutoff = after
796
- end
797
- keep = cutoff && item.date >= cutoff
798
- keep = opt[:not] ? !keep : keep
799
- end
800
-
801
- if keep && opt[:today]
802
- keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
803
- keep = opt[:not] ? !keep : keep
804
- elsif keep && opt[:yesterday]
805
- keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
806
- keep = opt[:not] ? !keep : keep
807
- end
808
-
809
- keep
810
- end
811
- count = opt[:count].to_i&.positive? ? opt[:count].to_i : filtered_items.count
812
-
813
- output = Items.new
814
-
815
- if opt[:age] && opt[:age].normalize_age == :oldest
816
- output.concat(filtered_items.slice(0, count).reverse)
817
- else
818
- output.concat(filtered_items.reverse.slice(0, count))
819
- end
820
-
821
- logger.benchmark(:filter_items, :finish)
822
-
823
- output
824
- end
825
-
826
- def delete_items(items, force: false)
827
- items.slice(0, 5).each { |i| puts i.to_pretty } unless force
828
- puts softpurple("+ #{items.size - 5} additional #{'item'.to_p(items.size - 5)}") if items.size > 5 && !force
829
-
830
- res = force ? true : Prompt.yn("Delete #{items.size} #{'item'.to_p(items.size)}?", default_response: 'y')
831
- return unless res
832
-
833
- items.each { |i| Hooks.trigger :post_entry_removed, self, @content.delete_item(i, single: items.count == 1) }
834
- write(@doing_file)
835
- end
836
-
837
- def edit_items(items)
838
- items.sort_by! { |i| i.date }
839
- editable_items = []
840
-
841
- items.each do |i|
842
- editable = "#{i.date.strftime('%F %R')} | #{i.title}"
843
- old_note = i.note ? i.note.strip_lines.join("\n") : nil
844
- editable += "\n#{old_note}" unless old_note.nil?
845
- editable_items << editable
846
- end
847
- divider = "-----------"
848
- notice =<<~EONOTICE
849
- # - You may delete entries, but leave all divider lines (---) in place.
850
- # - Start and @done dates replaced with a time string (yesterday 3pm) will
851
- # be parsed automatically. Do not delete the pipe (|) between start date
852
- # and entry title.
853
- EONOTICE
854
- input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n\n#{notice}"
855
-
856
- new_items = fork_editor(input).split(/^#{divider}/).map(&:strip)
857
-
858
- new_items.each_with_index do |new_item, i|
859
- input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
860
- first_line = input_lines[0]&.strip
861
-
862
- if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
863
- deleted = @content.delete_item(items[i], single: new_items.count == 1)
864
- Hooks.trigger :post_entry_removed, self, deleted
865
- Doing.logger.info('Deleted:', deleted.title)
866
- else
867
- date, title, note = format_input(new_item)
868
-
869
- note.map!(&:strip)
870
- note.delete_if(&:ignore?)
871
- item = items[i]
872
- old_item = item.clone
873
- item.date = date || items[i].date
874
- item.title = title
875
- item.note = note
876
- if (item.equal?(old_item))
877
- Doing.logger.count(:skipped, level: :debug)
878
- else
879
- Doing.logger.count(:updated)
880
- Hooks.trigger :post_entry_updated, self, item, old_item
881
- end
882
- end
883
- end
884
- end
885
-
886
- ##
887
- ## Display an interactive menu of entries
888
- ##
889
- ## @param opt [Hash] Additional options
81
+ ## List available views
890
82
  ##
891
- ## Options hash is shared with #filter_items and #act_on
83
+ ## @return [Array] View names
892
84
  ##
893
- def interactive(opt)
894
- opt ||= {}
895
- opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
896
-
897
- search = nil
898
-
899
- if opt[:search]
900
- search = opt[:search]
901
- search.sub!(/^'?/, "'") if opt[:exact]
902
- opt[:search] = search
903
- end
904
-
905
- # opt[:query] = opt[:search] if opt[:search] && !opt[:query]
906
- opt[:query] = "!#{opt[:query]}" if opt[:query] && opt[:not]
907
- opt[:multiple] = true
908
- opt[:show_if_single] = true
909
- filter_options = %i[after before case date_filter from fuzzy not search section val].each_with_object({}) {
910
- |k, hsh| hsh[k] = opt[k]
911
- }
912
- items = filter_items(Items.new, opt: filter_options)
913
-
914
- menu_options = %i[search query exact multiple show_if_single menu sort case].each_with_object({}) {
915
- |k, hsh| hsh[k] = opt[k]
916
- }
917
-
918
- selection = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **menu_options)
919
-
920
- raise NoResults, 'no items selected' if selection.nil? || selection.empty?
921
-
922
- act_on(selection, opt)
85
+ def views
86
+ Doing.setting('views') ? Doing.setting('views').keys : []
923
87
  end
924
88
 
925
89
  ##
926
- ## Perform actions on a set of entries. If
927
- ## no valid action is included in the opt
928
- ## hash and the terminal is a TTY, a menu
929
- ## will be presented
930
- ##
931
- ## @param items [Array] Array of Items to affect
932
- ## @param opt [Hash] Options and actions to perform
90
+ ## Gets a view from configuration
933
91
  ##
934
- ## @option opt [Boolean] :editor
935
- ## @option opt [Boolean] :delete
936
- ## @option opt [String] :tag
937
- ## @option opt [Boolean] :flag
938
- ## @option opt [Boolean] :finish
939
- ## @option opt [Boolean] :cancel
940
- ## @option opt [Boolean] :archive
941
- ## @option opt [String] :output
942
- ## @option opt [String] :save_to
943
- ## @option opt [Boolean] :again
944
- ## @option opt [Boolean] :resume
92
+ ## @param title [String] The title of the view to retrieve
945
93
  ##
946
- def act_on(items, opt)
947
- opt ||= {}
948
- actions = %i[editor delete tag flag finish cancel archive output save_to again resume]
949
- has_action = false
950
- single = items.count == 1
951
-
952
- actions.each do |a|
953
- if opt[a]
954
- has_action = true
955
- break
956
- end
957
- end
958
-
959
- unless has_action
960
- actions = [
961
- 'add tag',
962
- 'remove tag',
963
- 'autotag',
964
- 'cancel',
965
- 'delete',
966
- 'finish',
967
- 'flag',
968
- 'archive',
969
- 'move',
970
- 'edit',
971
- 'output formatted'
972
- ]
973
-
974
- actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
975
-
976
- choice = Prompt.choose_from(actions,
977
- prompt: 'What do you want to do with the selected items? > ',
978
- multiple: true,
979
- sorted: false,
980
- fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
981
- return unless choice
982
-
983
- to_do = choice.strip.split(/\n/)
984
- to_do.each do |action|
985
- case action
986
- when /resume/
987
- opt[:resume] = true
988
- when /reset/
989
- opt[:reset] = true
990
- when /autotag/
991
- opt[:autotag] = true
992
- when /(add|remove) tag/
993
- type = action =~ /^add/ ? 'add' : 'remove'
994
- raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
995
-
996
- tags = type == 'add' ? all_tags(@content) : all_tags(items)
997
-
998
- puts "#{yellow}Separate multiple tags with spaces, hit tab to complete known tags#{type == 'add' ? ', include values with tag(value)' : ''}"
999
- puts "#{boldgreen}Available tags: #{boldwhite}#{tags.sort.map(&:add_at).join(', ')}" if type == 'remove'
1000
- tag = Prompt.read_line(prompt: "Tags to #{type}", completions: tags)
1001
-
1002
- # print "#{yellow("Tag to #{type}: ")}#{reset}"
1003
- # tag = $stdin.gets
1004
- next if tag =~ /^ *$/
1005
-
1006
- opt[:tag] = tag.strip.sub(/^@/, '')
1007
- opt[:remove] = true if type == 'remove'
1008
- when /output formatted/
1009
- plugins = Plugins.available_plugins(type: :export).sort
1010
- output_format = Prompt.choose_from(plugins,
1011
- prompt: 'Which output format? > ',
1012
- fzf_args: [
1013
- "--height=#{plugins.count + 3}",
1014
- '--tac',
1015
- '--no-sort',
1016
- '--info=hidden'
1017
- ])
1018
- next if output_format =~ /^ *$/
1019
-
1020
- raise UserCancelled unless output_format
1021
-
1022
- opt[:output] = output_format.strip
1023
- res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
1024
- if res
1025
- # print "#{yellow('File path/name: ')}#{reset}"
1026
- # filename = $stdin.gets.strip
1027
- filename = Prompt.read_line(prompt: 'File path/name')
1028
- next if filename.empty?
1029
-
1030
- opt[:save_to] = filename
1031
- end
1032
- when /archive/
1033
- opt[:archive] = true
1034
- when /delete/
1035
- opt[:delete] = true
1036
- when /edit/
1037
- opt[:editor] = true
1038
- when /finish/
1039
- opt[:finish] = true
1040
- when /cancel/
1041
- opt[:cancel] = true
1042
- when /move/
1043
- section = choose_section.strip
1044
- opt[:move] = section.strip unless section =~ /^ *$/
1045
- when /flag/
1046
- opt[:flag] = true
1047
- end
1048
- end
1049
- end
1050
-
1051
- if opt[:resume] || opt[:reset]
1052
- raise InvalidArgument, 'resume and restart can only be used on a single entry' if items.count > 1
1053
-
1054
- item = items[0]
1055
- if opt[:resume] && !opt[:reset]
1056
- repeat_item(item, { editor: opt[:editor] }) # hooked
1057
- elsif opt[:reset]
1058
- res = Prompt.enter_text('Start date (blank for current time)', default_response: '')
1059
- if res =~ /^ *$/
1060
- date = Time.now
1061
- else
1062
- date = res.chronify(guess: :begin)
1063
- end
1064
-
1065
- res = if item.tags?('done', :and) && !opt[:resume]
1066
- opt[:force] ? true : Prompt.yn('Remove @done tag?', default_response: 'y')
1067
- else
1068
- opt[:resume]
1069
- end
1070
- old_item = item.clone
1071
- new_entry = reset_item(item, date: date, resume: res)
1072
- @content.update_item(item, new_entry)
1073
- Hooks.trigger :post_entry_updated, self, new_entry, old_item
1074
- end
1075
- write(@doing_file)
1076
-
1077
- return
1078
- end
1079
-
1080
- if opt[:delete]
1081
- delete_items(items, force: opt[:force]) # hooked
1082
- return
1083
- end
1084
-
1085
- if opt[:flag]
1086
- tag = Doing.setting('marker_tag', 'flagged')
1087
- items.map! do |i|
1088
- old_item = i.clone
1089
- i.tag(tag, date: false, remove: opt[:remove], single: single)
1090
- Hooks.trigger :post_entry_updated, self, i, old_item
1091
- end
1092
- end
1093
-
1094
- if opt[:finish] || opt[:cancel]
1095
- tag = 'done'
1096
- items.map! do |i|
1097
- if i.should_finish?
1098
- old_item = i.clone
1099
- should_date = !opt[:cancel] && i.should_time?
1100
- i.tag(tag, date: should_date, remove: opt[:remove], single: single)
1101
- Hooks.trigger :post_entry_updated, self, i, old_item
1102
- end
1103
- end
1104
- end
1105
-
1106
- if opt[:autotag]
1107
- items.map! do |i|
1108
- new_title = autotag(i.title)
1109
- if new_title == i.title
1110
- logger.count(:skipped, level: :debug, message: '%count unchaged %items')
1111
- # logger.debug('Autotag:', 'No changes')
1112
- else
1113
- logger.count(:added_tags)
1114
- logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
1115
- old_item = i.clone
1116
- i.title = new_title
1117
- Hooks.trigger :post_entry_updated, self, i, old_item
1118
- end
1119
- end
1120
- end
1121
-
1122
- if opt[:tag]
1123
- tag = opt[:tag]
1124
- items.map! do |i|
1125
- old_item = i.clone
1126
- i.tag(tag, date: false, remove: opt[:remove], single: single)
1127
- i.expand_date_tags(Doing.setting('date_tags'))
1128
- Hooks.trigger :post_entry_updated, self, i, old_item
1129
- end
1130
- end
1131
-
1132
- if opt[:archive] || opt[:move]
1133
- section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
1134
- items.map! do |i|
1135
- old_item = i.clone
1136
- i.move_to(section, label: true)
1137
- Hooks.trigger :post_entry_updated, self, i, old_item
1138
- end
1139
- end
1140
-
1141
- write(@doing_file)
1142
-
1143
- if opt[:editor]
1144
- edit_items(items) # hooked
1145
-
1146
- write(@doing_file)
1147
- end
1148
-
1149
- return unless opt[:output]
1150
-
1151
- items.each { |i| i.title = "#{i.title} @section(#{i.section})" }
1152
-
1153
- export_items = Items.new
1154
- export_items.concat(items)
1155
- export_items.add_section(Section.new('Export'), log: false)
1156
- options = { section: 'All' }
1157
-
1158
- if opt[:output] =~ /doing/
1159
- options[:output] = 'template'
1160
- options[:template] = '- %date | %title%note'
1161
- else
1162
- options[:output] = opt[:output]
1163
- options[:template] = opt[:template] || nil
1164
- end
1165
-
1166
- output = list_section(options, items: export_items) # hooked
1167
-
1168
- if opt[:save_to]
1169
- file = File.expand_path(opt[:save_to])
1170
- if File.exist?(file)
1171
- # Create a backup copy for the undo command
1172
- FileUtils.cp(file, "#{file}~")
1173
- end
1174
-
1175
- File.open(file, 'w+') do |f|
1176
- f.puts output
1177
- end
1178
-
1179
- logger.warn('File written:', file)
1180
- else
1181
- Doing::Pager.page output
1182
- end
1183
- end
1184
-
1185
- def verify_duration(date, finish_date, title: nil)
1186
- max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
1187
- max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
1188
- date = date.chronify(guess: :end, context: :today) if finish_date.is_a?(String)
1189
-
1190
- elapsed = finish_date - date
1191
-
1192
- if max_elapsed.positive? && (elapsed > max_elapsed)
1193
- puts boldwhite(title) if title
1194
- human = elapsed.time_string(format: :natural)
1195
- res = Prompt.yn(yellow("Did this entry actually take #{human}"), default_response: true)
1196
- unless res
1197
- new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
1198
- raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed.positive?
1199
-
1200
- finish_date = date + new_elapsed if new_elapsed
1201
- end
1202
- end
94
+ def get_view(title)
95
+ return Doing.setting(['views', title], nil)
1203
96
 
1204
- finish_date
1205
- end
1206
-
1207
- ##
1208
- ## Tag the last entry or X entries
1209
- ##
1210
- ## @param opt [Hash] Additional Options (see
1211
- ## #filter_items for filtering
1212
- ## options)
1213
- ##
1214
- ## @see #filter_items
1215
- ##
1216
- def tag_last(opt) # hooked
1217
- opt ||= {}
1218
- opt[:count] ||= 1
1219
- opt[:archive] ||= false
1220
- opt[:tags] ||= ['done']
1221
- opt[:sequential] ||= false
1222
- opt[:date] ||= false
1223
- opt[:remove] ||= false
1224
- opt[:update] ||= false
1225
- opt[:autotag] ||= false
1226
- opt[:back] ||= false
1227
- opt[:unfinished] ||= false
1228
- opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
1229
-
1230
- items = filter_items(Items.new, opt: opt)
1231
-
1232
- if opt[:interactive]
1233
- items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
1234
- header: '',
1235
- prompt: 'Select entries to tag > ',
1236
- multiple: true,
1237
- sort: true,
1238
- show_if_single: true)
1239
-
1240
- raise NoResults, 'no items selected' if items.empty?
1241
-
1242
- end
1243
-
1244
- raise NoResults, 'no items matched your search' if items.empty?
1245
-
1246
- if opt[:tags].empty? && !opt[:autotag]
1247
- completions = opt[:remove] ? all_tags(items) : all_tags(@content)
1248
- if opt[:remove]
1249
- puts "#{yellow}Available tags: #{boldwhite}#{completions.map(&:add_at).join(', ')}"
1250
- else
1251
- puts "#{yellow}Use tab to complete known tags"
1252
- end
1253
- opt[:tags] = Doing::Prompt.read_line(prompt: "Enter tag(s) to #{opt[:remove] ? 'remove' : 'add'}",
1254
- completions: completions,
1255
- default_response: '').to_tags
1256
- raise UserCancelled, 'No tags provided' if opt[:tags].empty?
1257
- end
1258
-
1259
- items.each do |item|
1260
- old_item = item.clone
1261
- added = []
1262
- removed = []
1263
-
1264
- if opt[:autotag]
1265
- new_title = autotag(item.title) if Doing.auto_tag
1266
- if new_title == item.title
1267
- logger.count(:skipped, level: :debug, message: '%count unchaged %items')
1268
- # logger.debug('Autotag:', 'No changes')
1269
- else
1270
- logger.count(:added_tags)
1271
- logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
1272
- item.title = new_title
1273
- end
1274
- else
1275
- if opt[:sequential]
1276
- next_entry = next_item(item)
1277
-
1278
- done_date = if next_entry.nil?
1279
- Time.now
1280
- else
1281
- next_entry.date - 60
1282
- end
1283
- else
1284
- done_date = item.calculate_end_date(opt)
1285
- end
1286
-
1287
- opt[:tags].each do |tag|
1288
- if tag == 'done' && !item.should_finish?
1289
-
1290
- Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
1291
- logger.count(:skipped, level: :debug)
1292
- next
1293
- end
1294
-
1295
- tag = tag.strip
1296
-
1297
- if tag =~ /^(\S+)\((.*?)\)$/
1298
- m = Regexp.last_match
1299
- tag = m[1]
1300
- opt[:value] ||= m[2]
1301
- end
1302
-
1303
- if tag =~ /^done$/ && opt[:date] && item.should_time?
1304
- max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
1305
- max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
1306
- elapsed = done_date - item.date
1307
-
1308
- if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
1309
- puts boldwhite(item.title)
1310
- human = elapsed.time_string(format: :natural)
1311
- res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
1312
- unless res
1313
- new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
1314
- raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
1315
-
1316
- opt[:took] = new_elapsed
1317
- done_date = item.calculate_end_date(opt) if opt[:took]
1318
- end
1319
- end
1320
- end
1321
-
1322
- if opt[:remove] || opt[:rename] || opt[:value]
1323
- rename_to = nil
1324
- if opt[:value]
1325
- rename_to = tag
1326
- elsif opt[:rename]
1327
- rename_to = tag
1328
- tag = opt[:rename]
1329
- end
1330
- old_title = item.title.dup
1331
- force = opt[:value].nil? ? false : true
1332
- item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
1333
- if old_title != item.title
1334
- removed << tag
1335
- added << rename_to if rename_to
1336
- else
1337
- logger.count(:skipped, level: :debug)
1338
- end
1339
- else
1340
- old_title = item.title.dup
1341
- should_date = opt[:date] && item.should_time?
1342
- item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
1343
- item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
1344
- added << tag if old_title != item.title
1345
- end
1346
- end
1347
- end
1348
-
1349
- logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
1350
-
1351
- item.note.add(opt[:note]) if opt[:note]
1352
-
1353
- if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1354
- item.move_to('Archive', label: true)
1355
- elsif opt[:archive] && opt[:count].zero?
1356
- logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1357
- end
1358
-
1359
- item.expand_date_tags(Doing.setting('date_tags'))
1360
- Hooks.trigger :post_entry_updated, self, item, old_item
1361
- end
1362
-
1363
- write(@doing_file)
1364
- end
1365
-
1366
- ##
1367
- ## Get next item in the index
1368
- ##
1369
- ## @param item [Item] target item
1370
- ## @param options [Hash] additional options
1371
- ## @see #filter_items
1372
- ##
1373
- ## @return [Item] the next chronological item in the index
1374
- ##
1375
- def next_item(item, options = {})
1376
- options ||= {}
1377
- items = filter_items(Items.new, opt: options)
1378
-
1379
- idx = items.index(item)
1380
-
1381
- idx.positive? ? items[idx - 1] : nil
1382
- end
1383
-
1384
- ##
1385
- ## Edit the last entry
1386
- ##
1387
- ## @param section [String] The section, default "All"
1388
- ##
1389
- def edit_last(section: 'All', options: {})
1390
- options[:section] = guess_section(section)
1391
-
1392
- item = last_entry(options)
1393
-
1394
- if item.nil?
1395
- logger.debug('Skipped:', 'No entries found')
1396
- return
1397
- end
1398
-
1399
- old_item = item.clone
1400
- content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
1401
- content << item.note.strip_lines.join("\n") unless item.note.empty?
1402
- new_item = fork_editor(content.join("\n"))
1403
- date, title, note = format_input(new_item)
1404
- date ||= item.date
1405
-
1406
- if title.nil? || title.empty?
1407
- logger.debug('Skipped:', 'No content provided')
1408
- elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
1409
- logger.debug('Skipped:', 'No change in content')
1410
- else
1411
- item.date = date unless date.nil?
1412
- item.title = title
1413
- item.note.add(note, replace: true)
1414
- logger.info('Edited:', item.title)
1415
- Hooks.trigger :post_entry_updated, self, item, old_item
1416
-
1417
- write(@doing_file)
1418
- end
1419
- end
1420
-
1421
- ##
1422
- ## Accepts one tag and the raw text of a new item if the
1423
- ## passed tag is on any item, it's replaced with @done.
1424
- ## if new_item is not nil, it's tagged with the passed
1425
- ## tag and inserted. This is for use where only one
1426
- ## instance of a given tag should exist (@meanwhile)
1427
- ##
1428
- ## @param target_tag [String] Tag to replace
1429
- ## @param opt [Hash] Additional Options
1430
- ##
1431
- ## @option opt :section [String] target section
1432
- ## @option opt :archive [Boolean] archive old item
1433
- ## @option opt :back [Date] backdate new item
1434
- ## @option opt :new_item [String] content to use for new item
1435
- ## @option opt :note [Array] note content for new item
1436
- def stop_start(target_tag, opt)
1437
- opt ||= {}
1438
- tag = target_tag.dup
1439
- opt[:section] ||= Doing.setting('current_section')
1440
- opt[:archive] ||= false
1441
- opt[:back] ||= Time.now
1442
- opt[:new_item] ||= false
1443
- opt[:note] ||= false
1444
-
1445
- opt[:section] = guess_section(opt[:section])
1446
-
1447
- tag.sub!(/^@/, '')
1448
-
1449
- found_items = 0
1450
-
1451
- @content.each_with_index do |item, i|
1452
- old_item = i.clone
1453
- next unless item.section == opt[:section] || opt[:section] =~ /all/i
1454
-
1455
- next unless item.title =~ /@#{tag}/
1456
-
1457
- item.title.add_tags!([tag, 'done'], remove: true)
1458
- item.tag('done', value: opt[:back].strftime('%F %R'))
1459
-
1460
- found_items += 1
1461
-
1462
- if opt[:archive] && opt[:section] != 'Archive'
1463
- item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1464
- item.move_to('Archive', label: false, log: false)
1465
- logger.count(:completed_archived)
1466
- logger.info('Completed/archived:', item.title)
1467
- else
1468
- logger.count(:completed)
1469
- logger.info('Completed:', item.title)
1470
- end
1471
- Hooks.trigger :post_entry_updated, self, item, old_item
1472
- end
1473
-
1474
-
1475
- logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1476
-
1477
- if opt[:new_item]
1478
- date, title, note = format_input(opt[:new_item])
1479
- opt[:back] = date unless date.nil?
1480
- note.add(opt[:note]) if opt[:note]
1481
- title.tag!(tag)
1482
- add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
1483
- end
1484
-
1485
- write(@doing_file)
1486
- end
1487
-
1488
- ##
1489
- ## Write content to file or STDOUT
1490
- ##
1491
- ## @param file [String] The filepath to write to
1492
- ##
1493
- def write(file = nil, backup: true)
1494
- Hooks.trigger :pre_write, self, file
1495
- output = combined_content
1496
- if file.nil?
1497
- $stdout.puts output
1498
- else
1499
- Util.write_to_file(file, output, backup: backup)
1500
- run_after if Doing.setting('run_after')
1501
- end
1502
- end
1503
-
1504
- ##
1505
- ## Rename doing file with date and start fresh one
1506
- ##
1507
- def rotate(opt)
1508
- opt ||= {}
1509
- keep = opt[:keep] || 0
1510
- tags = []
1511
- tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
1512
- bool = opt[:bool] || :and
1513
- sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1514
-
1515
- section = guess_section(sect)
1516
-
1517
- section_items = @content.in_section(section)
1518
- max = section_items.count - keep.to_i
1519
-
1520
- counter = 0
1521
- new_content = Items.new
1522
-
1523
- section_items.each do |item|
1524
- break if counter >= max
1525
- if opt[:before]
1526
- time_string = opt[:before]
1527
- cutoff = time_string.chronify(guess: :begin)
1528
- end
1529
-
1530
- unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
1531
- new_item = @content.delete(item)
1532
- Hooks.trigger :post_entry_removed, self, item.clone
1533
- raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
1534
-
1535
- new_content.add_section(new_item.section, log: false)
1536
- new_content.push(new_item)
1537
- counter += 1
1538
- end
1539
- end
1540
-
1541
- if counter.positive?
1542
- logger.count(:rotated,
1543
- level: :info,
1544
- count: counter,
1545
- message: "Rotated %count %items")
1546
- else
1547
- logger.info('Skipped:', 'No items were rotated')
1548
- end
1549
-
1550
- write(@doing_file)
1551
-
1552
- file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1553
- if File.exist?(file)
1554
- init_doing_file(file)
1555
- @content.concat(new_content).uniq!
1556
- logger.warn('File update:', "added entries to existing file: #{file}")
1557
- else
1558
- @content = new_content
1559
- logger.warn('File update:', "created new file: #{file}")
1560
- end
1561
-
1562
- write(file, backup: false)
1563
- end
1564
-
1565
- ##
1566
- ## Generate a menu of sections and allow user selection
1567
- ##
1568
- ## @return [String] The selected section name
1569
- ##
1570
- def choose_section(include_all: false)
1571
- options = @content.section_titles.sort
1572
- options.unshift('All') if include_all
1573
- choice = Prompt.choose_from(options, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1574
- choice ? choice.strip : choice
1575
- end
1576
-
1577
- ##
1578
- ## Generate a menu of tags and allow user selection
1579
- ##
1580
- ## @return [String] The selected tag name
1581
- ##
1582
- def choose_tag(section = 'All', items: nil, include_all: false)
1583
- items ||= @content.in_section(section)
1584
- tags = all_tags(items, counts: true).map { |t, c| "@#{t} (#{c})" }
1585
- tags.unshift('No tag filter') if include_all
1586
- choice = Prompt.choose_from(tags, sorted: false, multiple: true, prompt: 'Choose tag(s) > ', fzf_args: ['--height=60%'])
1587
- choice ? choice.split(/\n/).map { |t| t.strip.sub(/ \(.*?\)$/, '')}.join(' ') : choice
1588
- end
1589
-
1590
- ##
1591
- ## Generate a menu of sections and tags and allow user selection
1592
- ##
1593
- ## @return [String] The selected section or tag name
1594
- ##
1595
- def choose_section_tag
1596
- options = @content.section_titles.sort
1597
- options.concat(@content.all_tags.sort.map { |t| "@#{t}" })
1598
- choice = Prompt.choose_from(options, prompt: 'Choose a section or tag > ', fzf_args: ['--height=60%'])
1599
- choice ? choice.strip : choice
1600
- end
1601
-
1602
- ##
1603
- ## List available views
1604
- ##
1605
- ## @return [Array] View names
1606
- ##
1607
- def views
1608
- Doing.setting('views') ? Doing.setting('views').keys : []
1609
- end
1610
-
1611
- ##
1612
- ## Generate a menu of views and allow user selection
1613
- ##
1614
- ## @return [String] The selected view name
1615
- ##
1616
- def choose_view
1617
- choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1618
- choice ? choice.strip : choice
1619
- end
1620
-
1621
- ##
1622
- ## Gets a view from configuration
1623
- ##
1624
- ## @param title [String] The title of the view to retrieve
1625
- ##
1626
- def get_view(title)
1627
- return Doing.setting(['views', title], nil)
1628
-
1629
- false
1630
- end
1631
-
1632
- ##
1633
- ## Display contents of a section based on options
1634
- ##
1635
- ## @param opt [Hash] Additional Options
1636
- ##
1637
- def list_section(opt, items: Items.new)
1638
- logger.benchmark(:list_section, :start)
1639
- opt[:config_template] ||= 'default'
1640
-
1641
- tpl_cfg = Doing.setting(['templates', opt[:config_template]])
1642
-
1643
- cfg = if opt[:view_template]
1644
- Doing.setting(['views', opt[:view_template]]).deep_merge(tpl_cfg, { extend_existing_arrays: true, sort_merged_arrays: true })
1645
- else
1646
- tpl_cfg
1647
- end
1648
-
1649
- cfg.deep_merge({
1650
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1651
- 'date_format' => Doing.setting('default_date_format'),
1652
- 'order' => Doing.setting('order') || :asc,
1653
- 'tags_color' => Doing.setting('tags_color'),
1654
- 'duration' => Doing.setting('duration'),
1655
- 'interval_format' => Doing.setting('interval_format')
1656
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1657
-
1658
- opt[:duration] ||= cfg['duration'] || false
1659
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1660
- opt[:count] ||= 0
1661
- opt[:age] ||= :newest
1662
- opt[:age] = opt[:age].normalize_age
1663
- opt[:format] ||= cfg['date_format']
1664
- opt[:order] ||= cfg['order'] || :asc
1665
- opt[:tag_order] ||= :asc
1666
- opt[:tags_color] = cfg['tags_color'] || false if opt[:tags_color].nil?
1667
- opt[:template] ||= cfg['template']
1668
- opt[:sort_tags] ||= opt[:tag_sort]
1669
-
1670
- # opt[:highlight] ||= true
1671
- title = ''
1672
- is_single = true
1673
- if opt[:section].nil?
1674
- opt[:section] = choose_section
1675
- title = opt[:section]
1676
- elsif opt[:section].instance_of?(String)
1677
- title = if opt[:section] =~ /^all$/i
1678
- if opt[:page_title]
1679
- opt[:page_title]
1680
- elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
1681
- opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
1682
- else
1683
- 'doing'
1684
- end
1685
- else
1686
- guess_section(opt[:section])
1687
- end
1688
- end
1689
-
1690
- items = filter_items(items, opt: opt)
1691
-
1692
- items.reverse! unless opt[:order].normalize_order == :desc
1693
-
1694
- if opt[:delete]
1695
- delete_items(items, force: opt[:force])
1696
-
1697
- write(@doing_file)
1698
- return
1699
- elsif opt[:editor]
1700
- edit_items(items)
1701
-
1702
- write(@doing_file)
1703
- return
1704
- elsif opt[:interactive]
1705
- opt[:menu] = !opt[:force]
1706
- opt[:query] = '' # opt[:search]
1707
- opt[:multiple] = true
1708
- selected = Prompt.choose_from_items(items.reverse, include_section: opt[:section] =~ /^all$/i, **opt)
1709
-
1710
- raise NoResults, 'no items selected' if selected.nil? || selected.empty?
1711
-
1712
- act_on(selected, opt)
1713
- return
1714
- end
1715
-
1716
- opt[:output] ||= 'template'
1717
- opt[:wrap_width] ||= Doing.setting('templates.default.wrap_width', 0)
1718
-
1719
- logger.benchmark(:list_section, :finish)
1720
- output(items, title, is_single, opt)
1721
- end
1722
-
1723
- ##
1724
- ## Move entries from a section to Archive or other specified
1725
- ## section
1726
- ##
1727
- ## @param section [String] The source section
1728
- ## @param options [Hash] Options
1729
- ##
1730
- def archive(section = Doing.setting('current_section'), options)
1731
- options ||= {}
1732
- count = options[:keep] || 0
1733
- destination = options[:destination] || 'Archive'
1734
- tags = options[:tags] || []
1735
- bool = options[:bool] || :and
1736
-
1737
- section = choose_section if section.nil? || section =~ /choose/i
1738
- archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1739
- section = guess_section(section) unless archive_all
1740
-
1741
- @content.add_section(destination, log: true)
1742
- # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
1743
-
1744
- destination = guess_section(destination)
1745
-
1746
- if @content.section?(destination) && (@content.section?(section) || archive_all)
1747
- do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before], after: options[:after], from: options[:from] })
1748
- write(doing_file)
1749
- else
1750
- raise InvalidArgument, 'Either source or destination does not exist'
1751
- end
1752
- end
1753
-
1754
- ##
1755
- ## Show all entries from the current day
1756
- ##
1757
- ## @param times [Boolean] show times
1758
- ## @param output [String] output format
1759
- ## @param opt [Hash] Options
1760
- ##
1761
- def today(times = true, output = nil, opt)
1762
- opt ||= {}
1763
- opt[:totals] ||= false
1764
- opt[:sort_tags] ||= false
1765
-
1766
- cfg = Doing.setting('templates').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1767
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1768
- 'date_format' => Doing.setting('default_date_format'),
1769
- 'order' => Doing.setting('order') || :asc,
1770
- 'tags_color' => Doing.setting('tags_color'),
1771
- 'duration' => Doing.setting('duration'),
1772
- 'interval_format' => Doing.setting('interval_format')
1773
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1774
-
1775
- template = opt[:template] || cfg['template']
1776
-
1777
- opt[:duration] ||= cfg['duration'] || false
1778
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1779
-
1780
- options = {
1781
- after: opt[:after],
1782
- before: opt[:before],
1783
- count: 0,
1784
- duration: opt[:duration],
1785
- from: opt[:from],
1786
- format: cfg['date_format'],
1787
- interval_format: opt[:interval_format],
1788
- only_timed: opt[:only_timed],
1789
- order: cfg['order'] || :asc,
1790
- output: output,
1791
- section: opt[:section],
1792
- sort_tags: opt[:sort_tags],
1793
- template: template,
1794
- times: times,
1795
- today: true,
1796
- totals: opt[:totals],
1797
- wrap_width: cfg['wrap_width'],
1798
- tags_color: cfg['tags_color'],
1799
- config_template: opt[:config_template]
1800
- }
1801
- list_section(options)
1802
- end
1803
-
1804
- ##
1805
- ## Display entries within a date range
1806
- ##
1807
- ## @param dates [Array] [start, end]
1808
- ## @param section [String] The section
1809
- ## @param times (Bool) Show times
1810
- ## @param output [String] Output format
1811
- ## @param opt [Hash] Additional Options
1812
- ##
1813
- def list_date(dates, section, times = nil, output = nil, opt)
1814
- opt ||= {}
1815
- opt[:totals] ||= false
1816
- opt[:sort_tags] ||= false
1817
- section = guess_section(section)
1818
- # :date_filter expects an array with start and end date
1819
- dates = dates.split_date_range if dates.instance_of?(String)
1820
-
1821
- opt[:section] = section
1822
- opt[:count] = 0
1823
- opt[:order] = :asc
1824
- opt[:date_filter] = dates
1825
- opt[:times] = times
1826
- opt[:output] = output
1827
-
1828
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
1829
- if opt[:from] && opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
1830
- opt[:time_filter] = opt[:from]
1831
- end
1832
-
1833
- list_section(opt)
1834
- end
1835
-
1836
- ##
1837
- ## Show entries from the previous day
1838
- ##
1839
- ## @param section [String] The section
1840
- ## @param times (Bool) Show times
1841
- ## @param output [String] Output format
1842
- ## @param opt [Hash] Additional Options
1843
- ##
1844
- def yesterday(section, times = nil, output = nil, opt)
1845
- opt ||= {}
1846
- opt[:totals] ||= false
1847
- opt[:sort_tags] ||= false
1848
- opt[:config_template] ||= 'today'
1849
- opt[:yesterday] = true
1850
-
1851
- section = guess_section(section)
1852
- y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
1853
- opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
1854
- opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
1855
-
1856
- opt[:output] = output
1857
- opt[:section] = section
1858
- opt[:times] = times
1859
- opt[:count] = 0
1860
-
1861
- list_section(opt)
1862
- end
1863
-
1864
- ##
1865
- ## Show recent entries
1866
- ##
1867
- ## @param count [Integer] The number to show
1868
- ## @param section [String] The section to show from, default Currently
1869
- ## @param opt [Hash] Additional Options
1870
- ##
1871
- def recent(count = 10, section = nil, opt)
1872
- opt ||= {}
1873
- times = opt[:t] || true
1874
- opt[:totals] ||= false
1875
- opt[:sort_tags] ||= false
1876
-
1877
- cfg = Doing.setting('templates.recent').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1878
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1879
- 'date_format' => Doing.setting('default_date_format'),
1880
- 'order' => Doing.setting('order') || :asc,
1881
- 'tags_color' => Doing.setting('tags_color'),
1882
- 'duration' => Doing.setting('duration'),
1883
- 'interval_format' => Doing.setting('interval_format')
1884
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1885
- opt[:duration] ||= cfg['duration'] || false
1886
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1887
-
1888
- section ||= Doing.setting('current_section')
1889
- section = guess_section(section)
1890
-
1891
- opt[:section] = section
1892
- opt[:wrap_width] = cfg['wrap_width']
1893
- opt[:count] = count
1894
- opt[:format] = cfg['date_format']
1895
- opt[:template] = opt[:template] || cfg['template']
1896
- opt[:order] = :asc
1897
- opt[:times] = times
1898
-
1899
- list_section(opt)
1900
- end
1901
-
1902
- ##
1903
- ## Show the last entry
1904
- ##
1905
- ## @param times (Bool) Show times
1906
- ## @param section [String] Section to pull from, default Currently
1907
- ##
1908
- def last(times: true, section: nil, options: {})
1909
- section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
1910
- cfg = Doing.setting(['templates', options[:config_template]]).deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1911
- 'wrap_width' => Doing.setting('wrap_width', 0),
1912
- 'date_format' => Doing.setting('default_date_format'),
1913
- 'order' => Doing.setting('order', :asc),
1914
- 'tags_color' => Doing.setting('tags_color'),
1915
- 'duration' => Doing.setting('duration'),
1916
- 'interval_format' => Doing.setting('interval_format')
1917
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1918
- options[:duration] ||= cfg['duration'] || false
1919
- options[:interval_format] ||= cfg['interval_format'] || 'text'
1920
-
1921
- opts = {
1922
- case: options[:case],
1923
- config_template: 'last',
1924
- count: 1,
1925
- delete: options[:delete],
1926
- duration: options[:duration],
1927
- format: cfg['date_format'],
1928
- interval_format: options[:interval_format],
1929
- not: options[:negate],
1930
- section: section,
1931
- template: options[:template] || cfg['template'],
1932
- times: times,
1933
- val: options[:val],
1934
- wrap_width: cfg['wrap_width']
1935
- }
1936
-
1937
- if options[:tag]
1938
- opts[:tag_filter] = {
1939
- 'tags' => options[:tag],
1940
- 'bool' => options[:tag_bool]
1941
- }
1942
- end
1943
-
1944
- opts[:search] = options[:search] if options[:search]
1945
-
1946
- list_section(opts)
1947
- end
1948
-
1949
- ##
1950
- ## Uses 'autotag' configuration to turn keywords into tags for time tracking.
1951
- ## Does not repeat tags in a title, and only converts the first instance of an
1952
- ## untagged keyword
1953
- ##
1954
- ## @param string [String] The text to tag
1955
- ##
1956
- def autotag(string)
1957
- return unless string
1958
- return string unless Doing.auto_tag
1959
-
1960
- original = string.dup
1961
- text = string.dup
1962
-
1963
- current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
1964
- tagged = {
1965
- whitelisted: [],
1966
- synonyms: [],
1967
- transformed: [],
1968
- replaced: []
1969
- }
1970
-
1971
- Doing.setting('autotag.whitelist').each do |tag|
1972
- next if text =~ /@#{tag}\b/i
1973
-
1974
- text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
1975
- m.downcase! unless tag =~ /[A-Z]/
1976
- tagged[:whitelisted].push(m)
1977
- "@#{m}"
1978
- end
1979
- end
1980
-
1981
- Doing.setting('autotag.synonyms').each do |tag, v|
1982
- v.each do |word|
1983
- word = word.wildcard_to_rx
1984
- next unless text =~ /\b#{word}\b/i
1985
-
1986
- unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
1987
- tagged[:synonyms].push(tag)
1988
- tagged[:synonyms] = tagged[:synonyms].uniq
1989
- end
1990
- end
1991
- end
1992
-
1993
- if Doing.setting('autotag.transform')
1994
- Doing.setting('autotag.transform').each do |tag|
1995
- next unless tag =~ /\S+:\S+/
1996
-
1997
- if tag =~ /::/
1998
- rx, r = tag.split(/::/)
1999
- else
2000
- rx, r = tag.split(/:/)
2001
- end
2002
-
2003
- flag_rx = %r{/([r]+)$}
2004
- if r =~ flag_rx
2005
- flags = r.match(flag_rx)[1].split(//)
2006
- r.sub!(flag_rx, '')
2007
- end
2008
- r.gsub!(/\$/, '\\')
2009
- rx.sub!(/^@?/, '@')
2010
- regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
2011
-
2012
- text.sub!(regex) do
2013
- m = Regexp.last_match
2014
- new_tag = r
2015
-
2016
- m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
2017
- new_tag.gsub!("\\#{idx + 1}", v)
2018
- end
2019
- # Replace original tag if /r
2020
- if flags&.include?('r')
2021
- tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
2022
- new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
2023
- else
2024
- tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
2025
- tagged[:transformed] = tagged[:transformed].uniq
2026
- m[0]
2027
- end
2028
- end
2029
- end
2030
- end
2031
-
2032
- logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
2033
- logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
2034
- logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
2035
- logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
2036
-
2037
- tail_tags = tagged[:synonyms].concat(tagged[:transformed])
2038
- tail_tags.sort!
2039
- tail_tags.uniq!
2040
-
2041
- text.add_tags!(tail_tags) unless tail_tags.empty?
2042
-
2043
- if text == original
2044
- logger.debug('Autotag:', "no change to \"#{text.strip}\"")
2045
- else
2046
- new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
2047
- logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
2048
- logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
2049
- end
2050
-
2051
- text.dedup_tags
2052
- end
2053
-
2054
- ##
2055
- ## Get total elapsed time for all tags in
2056
- ## selection
2057
- ##
2058
- ## @param format [String] return format (html,
2059
- ## json, or text)
2060
- ## @param sort_by [Symbol] Sort by :name or :time
2061
- ## @param sort_order [Symbol] The sort order (:asc or :desc)
2062
- ##
2063
- def tag_times(format: :text, sort_by: :time, sort_order: :asc)
2064
- return '' if @timers.empty?
2065
-
2066
- max = @timers.keys.sort_by(&:length).reverse[0].length + 1
2067
-
2068
- total = @timers.delete('All')
2069
-
2070
- tags_data = @timers.delete_if { |_k, v| v.zero? }
2071
- sorted_tags_data = if sort_by.normalize_tag_sort == :name
2072
- tags_data.sort_by { |k, _v| k }
2073
- else
2074
- tags_data.sort_by { |_k, v| v }
2075
- end
2076
-
2077
- sorted_tags_data.reverse! if sort_order.normalize_order == :asc
2078
- case format
2079
- when :html
2080
-
2081
- output = <<EOHEAD
2082
- <table>
2083
- <caption id="tagtotals">Tag Totals</caption>
2084
- <colgroup>
2085
- <col style="text-align:left;"/>
2086
- <col style="text-align:left;"/>
2087
- </colgroup>
2088
- <thead>
2089
- <tr>
2090
- <th style="text-align:left;">project</th>
2091
- <th style="text-align:left;">time</th>
2092
- </tr>
2093
- </thead>
2094
- <tbody>
2095
- EOHEAD
2096
- sorted_tags_data.reverse.each do |k, v|
2097
- if v.positive?
2098
- output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{v.time_string(format: :clock)}</td></tr>\n"
2099
- end
2100
- end
2101
- tail = <<EOTAIL
2102
- <tr>
2103
- <td style="text-align:left;" colspan="2"></td>
2104
- </tr>
2105
- </tbody>
2106
- <tfoot>
2107
- <tr>
2108
- <td style="text-align:left;"><strong>Total</strong></td>
2109
- <td style="text-align:left;">#{total.time_string(format: :clock)}</td>
2110
- </tr>
2111
- </tfoot>
2112
- </table>
2113
- EOTAIL
2114
- output + tail
2115
- when :markdown
2116
- pad = sorted_tags_data.map { |k, _| k }.group_by(&:size).max.last[0].length
2117
- pad = 7 if pad < 7
2118
- output = <<~EOHEADER
2119
- | #{' ' * (pad - 7)}project | time |
2120
- | #{'-' * (pad - 1)}: | :------- |
2121
- EOHEADER
2122
- sorted_tags_data.reverse.each do |k, v|
2123
- if v.positive?
2124
- output += "| #{' ' * (pad - k.length)}#{k} | #{v.time_string(format: :clock)} |\n"
2125
- end
2126
- end
2127
- tail = '[Tag Totals]'
2128
- output + tail
2129
- when :json
2130
- output = []
2131
- sorted_tags_data.reverse.each do |k, v|
2132
- output << {
2133
- 'tag' => k,
2134
- 'seconds' => v,
2135
- 'formatted' => v.time_string(format: :clock)
2136
- }
2137
- end
2138
- output
2139
- when :human
2140
- output = []
2141
- sorted_tags_data.reverse.each do |k, v|
2142
- spacer = ''
2143
- (max - k.length).times do
2144
- spacer += ' '
2145
- end
2146
- output.push("┃ #{spacer}#{k}:#{v.time_string(format: :hm)} ┃")
2147
- end
2148
-
2149
- header = '┏━━ Tag Totals '
2150
- (max - 2).times { header += '━' }
2151
- header += '┓'
2152
- footer = '┗'
2153
- (max + 12).times { footer += '━' }
2154
- footer += '┛'
2155
- divider = '┣'
2156
- (max + 12).times { divider += '━' }
2157
- divider += '┫'
2158
- output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
2159
- output += "\n#{divider}"
2160
- spacer = ''
2161
- (max - 6).times do
2162
- spacer += ' '
2163
- end
2164
- total_time = total.time_string(format: :hm)
2165
- total = "┃ #{spacer}total: "
2166
- total += total_time
2167
- total += ' ┃'
2168
- output += "\n#{total}"
2169
- output += "\n#{footer}"
2170
- output
2171
- else
2172
- output = []
2173
- sorted_tags_data.reverse.each do |k, v|
2174
- spacer = ''
2175
- (max - k.length).times do
2176
- spacer += ' '
2177
- end
2178
- output.push("#{k}:#{spacer}#{v.time_string(format: :clock)}")
2179
- end
2180
-
2181
- output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
2182
- output += "\n\nTotal tracked: #{total.time_string(format: :clock)}\n"
2183
- output
2184
- end
2185
- end
2186
-
2187
- ##
2188
- ## Gets the interval between entry's start
2189
- ## date and @done date
2190
- ##
2191
- ## @param item [Item] The entry
2192
- ## @param formatted [Boolean] Return human readable
2193
- ## time (default seconds)
2194
- ## @param record [Boolean] Add the interval to the
2195
- ## total for each tag
2196
- ##
2197
- ## @return Interval in seconds, or [d, h, m] array if
2198
- ## formatted is true. False if no end date or
2199
- ## interval is 0
2200
- ##
2201
- def get_interval(item, formatted: true, record: true)
2202
- if item.interval
2203
- seconds = item.interval
2204
- record_tag_times(item, seconds) if record
2205
- return seconds.positive? ? seconds : false unless formatted
2206
-
2207
- return seconds.positive? ? seconds.time_string(format: :clock) : false
2208
- end
2209
-
2210
- false
2211
- end
2212
-
2213
- ##
2214
- ## Load configuration files and updated the @settings
2215
- ## attribute with a Doing::Configuration object
2216
- ##
2217
- ## @param filename [String] (optional) path to
2218
- ## alternative config file
2219
- ##
2220
- def configure(filename = nil)
2221
- logger.benchmark(:configure, :start)
2222
-
2223
- if filename
2224
- Doing.config_with(filename, { ignore_local: true })
2225
- elsif ENV['DOING_CONFIG']
2226
- Doing.config_with(ENV['DOING_CONFIG'], { ignore_local: true })
2227
- end
2228
-
2229
- logger.benchmark(:configure, :finish)
2230
-
2231
- Doing.set('backup_dir', ENV['DOING_BACKUP_DIR']) if ENV['DOING_BACKUP_DIR']
2232
- end
2233
-
2234
- def get_diff(filename = nil)
2235
- configure if Doing.settings.nil?
2236
-
2237
- filename ||= Doing.setting('doing_file')
2238
- init_doing_file(filename)
2239
- current_content = @content.clone
2240
- backup_file = Util::Backup.last_backup(filename, count: 1)
2241
- raise DoingRuntimeError, 'No undo history to diff' if backup_file.nil?
2242
-
2243
- backup = WWID.new
2244
- backup.config = Doing.settings
2245
- backup.init_doing_file(backup_file)
2246
- current_content.diff(backup.content)
97
+ false
2247
98
  end
2248
99
 
2249
100
  private
2250
101
 
2251
- ##
2252
- ## Wraps doing file content with additional
2253
- ## header/footer content
2254
- ##
2255
- ## @return [String] concatenated content
2256
- ##
2257
- def combined_content
2258
- output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
2259
- was_color = Color.coloring?
2260
- Color.coloring = false
2261
- @content.dedup!(match_section: true)
2262
- output += @content.to_s
2263
- output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
2264
- # Just strip all ANSI colors from the content before writing to doing file
2265
- Color.coloring = was_color
2266
-
2267
- output.uncolor
2268
- end
2269
-
2270
- ##
2271
- ## Generate output using available export plugins
2272
- ##
2273
- ## @param items [Array] The items
2274
- ## @param title [String] Page title
2275
- ## @param is_single [Boolean] Indicates if single
2276
- ## section
2277
- ## @param opt [Hash] Additional options
2278
- ##
2279
- ## @return [String] formatted output based on opt[:output]
2280
- ## template trigger
2281
- ##
2282
- def output(items, title, is_single, opt)
2283
- logger.benchmark(:output, :start)
2284
- opt ||= {}
2285
- out = nil
2286
-
2287
- raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
2288
-
2289
- export_options = { page_title: title, is_single: is_single, options: opt }
2290
-
2291
- Hooks.trigger :pre_export, self, opt[:output], items
2292
-
2293
- Plugins.plugins[:export].each do |_, options|
2294
- next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
2295
-
2296
- out = options[:class].render(self, items, variables: export_options)
2297
- break
2298
- end
2299
-
2300
- logger.debug('Output:', "#{items.count} #{items.count == 1 ? 'item' : 'items'} shown")
2301
- logger.benchmark(:output, :finish)
2302
- out
2303
- end
2304
-
2305
- ##
2306
- ## Record times for item tags
2307
- ##
2308
- ## @param item [Item] The item to record
2309
- ##
2310
- def record_tag_times(item, seconds)
2311
- item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
2312
- return if @recorded_items.include?(item_hash)
2313
- item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2314
- k = m[0] == 'done' ? 'All' : m[0].downcase
2315
- if @timers.key?(k)
2316
- @timers[k] += seconds
2317
- else
2318
- @timers[k] = seconds
2319
- end
2320
- @recorded_items.push(item_hash)
2321
- end
2322
- end
2323
-
2324
- ##
2325
- ## Helper function, performs the actual archiving
2326
- ##
2327
- ## @param section [String] The source section
2328
- ## @param destination [String] The destination
2329
- ## section
2330
- ## @param opt [Hash] Additional Options
2331
- ##
2332
- def do_archive(section, destination, opt)
2333
- opt ||= {}
2334
- count = opt[:count] || 0
2335
- tags = opt[:tags] || []
2336
- bool = opt[:bool] || :and
2337
- label = opt[:label] || true
2338
-
2339
- section = guess_section(section)
2340
- destination = guess_section(destination)
2341
-
2342
- section_items = @content.in_section(section)
2343
- max = section_items.count - count.to_i
2344
-
2345
- opt[:after] = opt[:from][0] if opt[:from]
2346
- opt[:before] = opt[:from][1] if opt[:from]
2347
-
2348
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
2349
-
2350
- if opt[:before].is_a?(String) && opt[:before] =~ time_rx
2351
- opt[:before] = opt[:before].chronify(guess: :end, future: false)
2352
- end
2353
-
2354
- if opt[:after].is_a?(String) && opt[:after] =~ time_rx
2355
- opt[:after] = opt[:after].chronify(guess: :begin, future: false)
2356
- end
2357
-
2358
- counter = 0
2359
-
2360
- @content.map do |item|
2361
- break if counter >= max
2362
-
2363
- next if item.section.downcase == destination.downcase
2364
-
2365
- next if item.section.downcase != section.downcase && section != /^all$/i
2366
-
2367
- next if (opt[:before] && item.date > opt[:before]) || (opt[:after] && item.date < opt[:after])
2368
-
2369
- next if (!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s))
2370
-
2371
- counter += 1
2372
- old_item = item.clone
2373
- item.move_to(destination, label: label, log: false)
2374
- Hooks.trigger :post_entry_updated, self, item, old_item
2375
- item
2376
- end
2377
-
2378
- if counter.positive?
2379
- logger.count(destination == 'Archive' ? :archived : :moved,
2380
- level: :info,
2381
- count: counter,
2382
- message: "%count %items from #{section} to #{destination}")
2383
- else
2384
- logger.info('Skipped:', 'No items were moved')
2385
- end
2386
- end
2387
-
2388
102
  def run_after
2389
103
  return unless Doing.setting('run_after')
2390
104