doing 2.1.37 → 2.1.40

Sign up to get free protection for your applications and to get access to all the features.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +1 -1
  5. data/Rakefile +7 -1
  6. data/bin/commands/config.rb +43 -34
  7. data/bin/commands/done.rb +1 -18
  8. data/bin/commands/finish.rb +30 -25
  9. data/bin/commands/grep.rb +3 -14
  10. data/bin/commands/last.rb +2 -8
  11. data/bin/commands/meanwhile.rb +13 -6
  12. data/bin/commands/now.rb +2 -4
  13. data/bin/commands/on.rb +4 -15
  14. data/bin/commands/recent.rb +2 -8
  15. data/bin/commands/reset.rb +24 -1
  16. data/bin/commands/select.rb +1 -1
  17. data/bin/commands/show.rb +8 -16
  18. data/bin/commands/since.rb +1 -12
  19. data/bin/commands/today.rb +2 -13
  20. data/bin/commands/view.rb +1 -1
  21. data/bin/commands/yesterday.rb +2 -13
  22. data/bin/doing +41 -36
  23. data/docs/doc/Array.html +1 -1
  24. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  25. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  26. data/docs/doc/BooleanTermParser/Query.html +1 -1
  27. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  28. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  29. data/docs/doc/BooleanTermParser.html +1 -1
  30. data/docs/doc/Doing/Color.html +166 -20
  31. data/docs/doc/Doing/Completion.html +1 -1
  32. data/docs/doc/Doing/Configuration.html +1 -1
  33. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  34. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  35. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  36. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  37. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  38. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  39. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  40. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  41. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  42. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  43. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  44. data/docs/doc/Doing/Errors.html +9 -9
  45. data/docs/doc/Doing/Hooks.html +1 -1
  46. data/docs/doc/Doing/Item.html +114 -1576
  47. data/docs/doc/Doing/Items.html +121 -5
  48. data/docs/doc/Doing/Logger.html +1 -1
  49. data/docs/doc/Doing/Note.html +1 -1
  50. data/docs/doc/Doing/Pager.html +1 -1
  51. data/docs/doc/Doing/Plugins.html +1 -1
  52. data/docs/doc/Doing/Prompt.html +2 -2
  53. data/docs/doc/Doing/Section.html +1 -1
  54. data/docs/doc/Doing/TemplateString.html +2 -2
  55. data/docs/doc/Doing/Types.html +1 -1
  56. data/docs/doc/Doing/Util/Backup.html +5 -5
  57. data/docs/doc/Doing/Util.html +1 -1
  58. data/docs/doc/Doing/WWID.html +197 -4033
  59. data/docs/doc/Doing.html +2 -2
  60. data/docs/doc/FalseClass.html +1 -1
  61. data/docs/doc/GLI/Commands/Help.html +1 -1
  62. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  63. data/docs/doc/GLI/Commands.html +1 -1
  64. data/docs/doc/GLI.html +1 -1
  65. data/docs/doc/Hash.html +1 -1
  66. data/docs/doc/Object.html +1 -1
  67. data/docs/doc/PhraseParser/Operator.html +1 -1
  68. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  69. data/docs/doc/PhraseParser/Query.html +1 -1
  70. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  71. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  72. data/docs/doc/PhraseParser/TermClause.html +1 -1
  73. data/docs/doc/PhraseParser.html +1 -1
  74. data/docs/doc/Status.html +1 -1
  75. data/docs/doc/String.html +1 -1
  76. data/docs/doc/Symbol.html +1 -1
  77. data/docs/doc/Time.html +1 -1
  78. data/docs/doc/TrueClass.html +1 -1
  79. data/docs/doc/_index.html +26 -5
  80. data/docs/doc/class_list.html +1 -1
  81. data/docs/doc/file.README.html +2 -2
  82. data/docs/doc/index.html +2 -2
  83. data/docs/doc/method_list.html +237 -709
  84. data/docs/doc/top-level-namespace.html +3 -3
  85. data/docs/index.md +1 -1
  86. data/doing.rdoc +54 -7
  87. data/lib/completion/_doing.zsh +6 -6
  88. data/lib/completion/doing.bash +10 -10
  89. data/lib/completion/doing.fish +8 -2
  90. data/lib/doing/add_options.rb +31 -1
  91. data/lib/doing/chronify/array.rb +68 -18
  92. data/lib/doing/chronify/string.rb +3 -1
  93. data/lib/doing/colors.rb +77 -30
  94. data/lib/doing/completion.rb +4 -5
  95. data/lib/doing/errors.rb +51 -35
  96. data/lib/doing/hooks.rb +3 -3
  97. data/lib/doing/item/dates.rb +112 -0
  98. data/lib/doing/item/query.rb +433 -0
  99. data/lib/doing/item/state.rb +59 -0
  100. data/lib/doing/item/tags.rb +87 -0
  101. data/lib/doing/item.rb +6 -537
  102. data/lib/doing/items.rb +39 -14
  103. data/lib/doing/plugin_manager.rb +3 -3
  104. data/lib/doing/plugins/export/template_export.rb +4 -4
  105. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  106. data/lib/doing/prompt.rb +6 -8
  107. data/lib/doing/string/tags.rb +8 -2
  108. data/lib/doing/util_backup.rb +6 -8
  109. data/lib/doing/version.rb +1 -1
  110. data/lib/doing/wwid/display.rb +399 -0
  111. data/lib/doing/wwid/editor.rb +214 -0
  112. data/lib/doing/wwid/filetools.rb +186 -0
  113. data/lib/doing/wwid/filter.rb +218 -0
  114. data/lib/doing/wwid/guess.rb +87 -0
  115. data/lib/doing/wwid/interactive.rb +385 -0
  116. data/lib/doing/wwid/modify.rb +618 -0
  117. data/lib/doing/wwid/tags.rb +54 -0
  118. data/lib/doing/wwid/timers.rb +345 -0
  119. data/lib/doing/wwid/wwidutil.rb +104 -0
  120. data/lib/doing/wwid.rb +31 -2308
  121. 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,2148 +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)$/
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
-
1296
- tag = tag.strip
1297
-
1298
- if tag =~ /^done$/ && opt[:date] && item.should_time?
1299
- max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
1300
- max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
1301
- elapsed = done_date - item.date
1302
-
1303
- if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
1304
- puts boldwhite(item.title)
1305
- human = elapsed.time_string(format: :natural)
1306
- res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
1307
- unless res
1308
- new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
1309
- raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
1310
-
1311
- opt[:took] = new_elapsed
1312
- done_date = item.calculate_end_date(opt) if opt[:took]
1313
- end
1314
- end
1315
- end
1316
-
1317
- if opt[:remove] || opt[:rename] || opt[:value]
1318
- rename_to = nil
1319
- if opt[:value]
1320
- rename_to = tag
1321
- elsif opt[:rename]
1322
- rename_to = tag
1323
- tag = opt[:rename]
1324
- end
1325
- old_title = item.title.dup
1326
- force = opt[:value].nil? ? false : true
1327
- item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
1328
- if old_title != item.title
1329
- removed << tag
1330
- added << rename_to if rename_to
1331
- else
1332
- logger.count(:skipped, level: :debug)
1333
- end
1334
- else
1335
- old_title = item.title.dup
1336
- should_date = opt[:date] && item.should_time?
1337
- item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
1338
- item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
1339
- added << tag if old_title != item.title
1340
- end
1341
- end
1342
- end
1343
-
1344
- logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
1345
-
1346
- item.note.add(opt[:note]) if opt[:note]
1347
-
1348
- if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1349
- item.move_to('Archive', label: true)
1350
- elsif opt[:archive] && opt[:count].zero?
1351
- logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1352
- end
1353
-
1354
- item.expand_date_tags(Doing.setting('date_tags'))
1355
- Hooks.trigger :post_entry_updated, self, item, old_item
1356
- end
1357
-
1358
- write(@doing_file)
1359
- end
1360
-
1361
- ##
1362
- ## Get next item in the index
1363
- ##
1364
- ## @param item [Item] target item
1365
- ## @param options [Hash] additional options
1366
- ## @see #filter_items
1367
- ##
1368
- ## @return [Item] the next chronological item in the index
1369
- ##
1370
- def next_item(item, options = {})
1371
- options ||= {}
1372
- items = filter_items(Items.new, opt: options)
1373
-
1374
- idx = items.index(item)
1375
-
1376
- idx.positive? ? items[idx - 1] : nil
1377
- end
1378
-
1379
- ##
1380
- ## Edit the last entry
1381
- ##
1382
- ## @param section [String] The section, default "All"
1383
- ##
1384
- def edit_last(section: 'All', options: {})
1385
- options[:section] = guess_section(section)
1386
-
1387
- item = last_entry(options)
1388
-
1389
- if item.nil?
1390
- logger.debug('Skipped:', 'No entries found')
1391
- return
1392
- end
1393
-
1394
- old_item = item.clone
1395
- content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
1396
- content << item.note.strip_lines.join("\n") unless item.note.empty?
1397
- new_item = fork_editor(content.join("\n"))
1398
- date, title, note = format_input(new_item)
1399
- date ||= item.date
1400
-
1401
- if title.nil? || title.empty?
1402
- logger.debug('Skipped:', 'No content provided')
1403
- elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
1404
- logger.debug('Skipped:', 'No change in content')
1405
- else
1406
- item.date = date unless date.nil?
1407
- item.title = title
1408
- item.note.add(note, replace: true)
1409
- logger.info('Edited:', item.title)
1410
- Hooks.trigger :post_entry_updated, self, item, old_item
1411
-
1412
- write(@doing_file)
1413
- end
1414
- end
1415
-
1416
- ##
1417
- ## Accepts one tag and the raw text of a new item if the
1418
- ## passed tag is on any item, it's replaced with @done.
1419
- ## if new_item is not nil, it's tagged with the passed
1420
- ## tag and inserted. This is for use where only one
1421
- ## instance of a given tag should exist (@meanwhile)
1422
- ##
1423
- ## @param target_tag [String] Tag to replace
1424
- ## @param opt [Hash] Additional Options
1425
- ##
1426
- ## @option opt :section [String] target section
1427
- ## @option opt :archive [Boolean] archive old item
1428
- ## @option opt :back [Date] backdate new item
1429
- ## @option opt :new_item [String] content to use for new item
1430
- ## @option opt :note [Array] note content for new item
1431
- def stop_start(target_tag, opt)
1432
- opt ||= {}
1433
- tag = target_tag.dup
1434
- opt[:section] ||= Doing.setting('current_section')
1435
- opt[:archive] ||= false
1436
- opt[:back] ||= Time.now
1437
- opt[:new_item] ||= false
1438
- opt[:note] ||= false
1439
-
1440
- opt[:section] = guess_section(opt[:section])
1441
-
1442
- tag.sub!(/^@/, '')
1443
-
1444
- found_items = 0
1445
-
1446
- @content.each_with_index do |item, i|
1447
- old_item = i.clone
1448
- next unless item.section == opt[:section] || opt[:section] =~ /all/i
1449
-
1450
- next unless item.title =~ /@#{tag}/
1451
-
1452
- item.title.add_tags!([tag, 'done'], remove: true)
1453
- item.tag('done', value: opt[:back].strftime('%F %R'))
1454
-
1455
- found_items += 1
1456
-
1457
- if opt[:archive] && opt[:section] != 'Archive'
1458
- item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1459
- item.move_to('Archive', label: false, log: false)
1460
- logger.count(:completed_archived)
1461
- logger.info('Completed/archived:', item.title)
1462
- else
1463
- logger.count(:completed)
1464
- logger.info('Completed:', item.title)
1465
- end
1466
- Hooks.trigger :post_entry_updated, self, item, old_item
1467
- end
1468
-
1469
-
1470
- logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1471
-
1472
- if opt[:new_item]
1473
- date, title, note = format_input(opt[:new_item])
1474
- opt[:back] = date unless date.nil?
1475
- note.add(opt[:note]) if opt[:note]
1476
- title.tag!(tag)
1477
- add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
1478
- end
1479
-
1480
- write(@doing_file)
1481
- end
1482
-
1483
- ##
1484
- ## Write content to file or STDOUT
1485
- ##
1486
- ## @param file [String] The filepath to write to
1487
- ##
1488
- def write(file = nil, backup: true)
1489
- Hooks.trigger :pre_write, self, file
1490
- output = combined_content
1491
- if file.nil?
1492
- $stdout.puts output
1493
- else
1494
- Util.write_to_file(file, output, backup: backup)
1495
- run_after if Doing.setting('run_after')
1496
- end
1497
- end
1498
-
1499
- ##
1500
- ## Rename doing file with date and start fresh one
1501
- ##
1502
- def rotate(opt)
1503
- opt ||= {}
1504
- keep = opt[:keep] || 0
1505
- tags = []
1506
- tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
1507
- bool = opt[:bool] || :and
1508
- sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1509
-
1510
- section = guess_section(sect)
1511
-
1512
- section_items = @content.in_section(section)
1513
- max = section_items.count - keep.to_i
1514
-
1515
- counter = 0
1516
- new_content = Items.new
1517
-
1518
- section_items.each do |item|
1519
- break if counter >= max
1520
- if opt[:before]
1521
- time_string = opt[:before]
1522
- cutoff = time_string.chronify(guess: :begin)
1523
- end
1524
-
1525
- unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
1526
- new_item = @content.delete(item)
1527
- Hooks.trigger :post_entry_removed, self, item.clone
1528
- raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
1529
-
1530
- new_content.add_section(new_item.section, log: false)
1531
- new_content.push(new_item)
1532
- counter += 1
1533
- end
1534
- end
1535
-
1536
- if counter.positive?
1537
- logger.count(:rotated,
1538
- level: :info,
1539
- count: counter,
1540
- message: "Rotated %count %items")
1541
- else
1542
- logger.info('Skipped:', 'No items were rotated')
1543
- end
1544
-
1545
- write(@doing_file)
1546
-
1547
- file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1548
- if File.exist?(file)
1549
- init_doing_file(file)
1550
- @content.concat(new_content).uniq!
1551
- logger.warn('File update:', "added entries to existing file: #{file}")
1552
- else
1553
- @content = new_content
1554
- logger.warn('File update:', "created new file: #{file}")
1555
- end
1556
-
1557
- write(file, backup: false)
1558
- end
1559
-
1560
- ##
1561
- ## Generate a menu of sections and allow user selection
1562
- ##
1563
- ## @return [String] The selected section name
1564
- ##
1565
- def choose_section(include_all: false)
1566
- options = @content.section_titles.sort
1567
- options.unshift('All') if include_all
1568
- choice = Prompt.choose_from(options, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1569
- choice ? choice.strip : choice
1570
- end
1571
-
1572
- ##
1573
- ## Generate a menu of tags and allow user selection
1574
- ##
1575
- ## @return [String] The selected tag name
1576
- ##
1577
- def choose_tag(section = 'All', items: nil, include_all: false)
1578
- items ||= @content.in_section(section)
1579
- tags = all_tags(items, counts: true).map { |t, c| "@#{t} (#{c})" }
1580
- tags.unshift('No tag filter') if include_all
1581
- choice = Prompt.choose_from(tags, sorted: false, multiple: true, prompt: 'Choose tag(s) > ', fzf_args: ['--height=60%'])
1582
- choice ? choice.split(/\n/).map { |t| t.strip.sub(/ \(.*?\)$/, '')}.join(' ') : choice
1583
- end
1584
-
1585
- ##
1586
- ## Generate a menu of sections and tags and allow user selection
1587
- ##
1588
- ## @return [String] The selected section or tag name
1589
- ##
1590
- def choose_section_tag
1591
- options = @content.section_titles.sort
1592
- options.concat(@content.all_tags.sort.map { |t| "@#{t}" })
1593
- choice = Prompt.choose_from(options, prompt: 'Choose a section or tag > ', fzf_args: ['--height=60%'])
1594
- choice ? choice.strip : choice
1595
- end
1596
-
1597
- ##
1598
- ## List available views
1599
- ##
1600
- ## @return [Array] View names
1601
- ##
1602
- def views
1603
- Doing.setting('views') ? Doing.setting('views').keys : []
1604
- end
1605
-
1606
- ##
1607
- ## Generate a menu of views and allow user selection
1608
- ##
1609
- ## @return [String] The selected view name
1610
- ##
1611
- def choose_view
1612
- choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1613
- choice ? choice.strip : choice
1614
- end
1615
-
1616
- ##
1617
- ## Gets a view from configuration
1618
- ##
1619
- ## @param title [String] The title of the view to retrieve
1620
- ##
1621
- def get_view(title)
1622
- return Doing.setting(['views', title], nil)
1623
-
1624
- false
1625
- end
1626
-
1627
- ##
1628
- ## Display contents of a section based on options
1629
- ##
1630
- ## @param opt [Hash] Additional Options
1631
- ##
1632
- def list_section(opt, items: Items.new)
1633
- logger.benchmark(:list_section, :start)
1634
- opt[:config_template] ||= 'default'
1635
-
1636
- tpl_cfg = Doing.setting(['templates', opt[:config_template]])
1637
-
1638
- cfg = if opt[:view_template]
1639
- Doing.setting(['views', opt[:view_template]]).deep_merge(tpl_cfg, { extend_existing_arrays: true, sort_merged_arrays: true })
1640
- else
1641
- tpl_cfg
1642
- end
1643
-
1644
- cfg.deep_merge({
1645
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1646
- 'date_format' => Doing.setting('default_date_format'),
1647
- 'order' => Doing.setting('order') || :asc,
1648
- 'tags_color' => Doing.setting('tags_color'),
1649
- 'duration' => Doing.setting('duration'),
1650
- 'interval_format' => Doing.setting('interval_format')
1651
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1652
-
1653
- opt[:duration] ||= cfg['duration'] || false
1654
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1655
- opt[:count] ||= 0
1656
- opt[:age] ||= :newest
1657
- opt[:age] = opt[:age].normalize_age
1658
- opt[:format] ||= cfg['date_format']
1659
- opt[:order] ||= cfg['order'] || :asc
1660
- opt[:tag_order] ||= :asc
1661
- opt[:tags_color] = cfg['tags_color'] || false if opt[:tags_color].nil?
1662
- opt[:template] ||= cfg['template']
1663
- opt[:sort_tags] ||= opt[:tag_sort]
1664
-
1665
- # opt[:highlight] ||= true
1666
- title = ''
1667
- is_single = true
1668
- if opt[:section].nil?
1669
- opt[:section] = choose_section
1670
- title = opt[:section]
1671
- elsif opt[:section].instance_of?(String)
1672
- title = if opt[:section] =~ /^all$/i
1673
- if opt[:page_title]
1674
- opt[:page_title]
1675
- elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
1676
- opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
1677
- else
1678
- 'doing'
1679
- end
1680
- else
1681
- guess_section(opt[:section])
1682
- end
1683
- end
1684
-
1685
- items = filter_items(items, opt: opt)
1686
-
1687
- items.reverse! unless opt[:order].normalize_order == :desc
1688
-
1689
- if opt[:delete]
1690
- delete_items(items, force: opt[:force])
1691
- return
1692
- elsif opt[:editor]
1693
- edit_items(items)
1694
- return
1695
- elsif opt[:interactive]
1696
- opt[:menu] = !opt[:force]
1697
- opt[:query] = '' # opt[:search]
1698
- opt[:multiple] = true
1699
- selected = Prompt.choose_from_items(items.reverse, include_section: opt[:section] =~ /^all$/i, **opt)
1700
-
1701
- raise NoResults, 'no items selected' if selected.nil? || selected.empty?
1702
-
1703
- act_on(selected, opt)
1704
- return
1705
- end
1706
-
1707
- opt[:output] ||= 'template'
1708
- opt[:wrap_width] ||= Doing.setting('templates.default.wrap_width', 0)
1709
-
1710
- logger.benchmark(:list_section, :finish)
1711
- output(items, title, is_single, opt)
1712
- end
1713
-
1714
- ##
1715
- ## Move entries from a section to Archive or other specified
1716
- ## section
1717
- ##
1718
- ## @param section [String] The source section
1719
- ## @param options [Hash] Options
1720
- ##
1721
- def archive(section = Doing.setting('current_section'), options)
1722
- options ||= {}
1723
- count = options[:keep] || 0
1724
- destination = options[:destination] || 'Archive'
1725
- tags = options[:tags] || []
1726
- bool = options[:bool] || :and
1727
-
1728
- section = choose_section if section.nil? || section =~ /choose/i
1729
- archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1730
- section = guess_section(section) unless archive_all
1731
-
1732
- @content.add_section(destination, log: true)
1733
- # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
1734
-
1735
- destination = guess_section(destination)
1736
-
1737
- if @content.section?(destination) && (@content.section?(section) || archive_all)
1738
- 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] })
1739
- write(doing_file)
1740
- else
1741
- raise InvalidArgument, 'Either source or destination does not exist'
1742
- end
1743
- end
1744
-
1745
- ##
1746
- ## Show all entries from the current day
1747
- ##
1748
- ## @param times [Boolean] show times
1749
- ## @param output [String] output format
1750
- ## @param opt [Hash] Options
1751
- ##
1752
- def today(times = true, output = nil, opt)
1753
- opt ||= {}
1754
- opt[:totals] ||= false
1755
- opt[:sort_tags] ||= false
1756
-
1757
- cfg = Doing.setting('templates').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1758
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1759
- 'date_format' => Doing.setting('default_date_format'),
1760
- 'order' => Doing.setting('order') || :asc,
1761
- 'tags_color' => Doing.setting('tags_color'),
1762
- 'duration' => Doing.setting('duration'),
1763
- 'interval_format' => Doing.setting('interval_format')
1764
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1765
-
1766
- template = opt[:template] || cfg['template']
1767
-
1768
- opt[:duration] ||= cfg['duration'] || false
1769
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1770
-
1771
- options = {
1772
- after: opt[:after],
1773
- before: opt[:before],
1774
- count: 0,
1775
- duration: opt[:duration],
1776
- from: opt[:from],
1777
- format: cfg['date_format'],
1778
- interval_format: opt[:interval_format],
1779
- only_timed: opt[:only_timed],
1780
- order: cfg['order'] || :asc,
1781
- output: output,
1782
- section: opt[:section],
1783
- sort_tags: opt[:sort_tags],
1784
- template: template,
1785
- times: times,
1786
- today: true,
1787
- totals: opt[:totals],
1788
- wrap_width: cfg['wrap_width'],
1789
- tags_color: cfg['tags_color'],
1790
- config_template: opt[:config_template]
1791
- }
1792
- list_section(options)
1793
- end
1794
-
1795
- ##
1796
- ## Display entries within a date range
1797
- ##
1798
- ## @param dates [Array] [start, end]
1799
- ## @param section [String] The section
1800
- ## @param times (Bool) Show times
1801
- ## @param output [String] Output format
1802
- ## @param opt [Hash] Additional Options
1803
- ##
1804
- def list_date(dates, section, times = nil, output = nil, opt)
1805
- opt ||= {}
1806
- opt[:totals] ||= false
1807
- opt[:sort_tags] ||= false
1808
- section = guess_section(section)
1809
- # :date_filter expects an array with start and end date
1810
- dates = dates.split_date_range if dates.instance_of?(String)
1811
-
1812
- opt[:section] = section
1813
- opt[:count] = 0
1814
- opt[:order] = :asc
1815
- opt[:date_filter] = dates
1816
- opt[:times] = times
1817
- opt[:output] = output
1818
-
1819
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
1820
- if opt[:from] && opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
1821
- opt[:time_filter] = opt[:from]
1822
- end
1823
-
1824
- list_section(opt)
1825
- end
1826
-
1827
- ##
1828
- ## Show entries from the previous day
1829
- ##
1830
- ## @param section [String] The section
1831
- ## @param times (Bool) Show times
1832
- ## @param output [String] Output format
1833
- ## @param opt [Hash] Additional Options
1834
- ##
1835
- def yesterday(section, times = nil, output = nil, opt)
1836
- opt ||= {}
1837
- opt[:totals] ||= false
1838
- opt[:sort_tags] ||= false
1839
- opt[:config_template] ||= 'today'
1840
- opt[:yesterday] = true
1841
-
1842
- section = guess_section(section)
1843
- y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
1844
- opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
1845
- opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
1846
-
1847
- opt[:output] = output
1848
- opt[:section] = section
1849
- opt[:times] = times
1850
- opt[:count] = 0
1851
-
1852
- list_section(opt)
1853
- end
1854
-
1855
- ##
1856
- ## Show recent entries
1857
- ##
1858
- ## @param count [Integer] The number to show
1859
- ## @param section [String] The section to show from, default Currently
1860
- ## @param opt [Hash] Additional Options
1861
- ##
1862
- def recent(count = 10, section = nil, opt)
1863
- opt ||= {}
1864
- times = opt[:t] || true
1865
- opt[:totals] ||= false
1866
- opt[:sort_tags] ||= false
1867
-
1868
- cfg = Doing.setting('templates.recent').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1869
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1870
- 'date_format' => Doing.setting('default_date_format'),
1871
- 'order' => Doing.setting('order') || :asc,
1872
- 'tags_color' => Doing.setting('tags_color'),
1873
- 'duration' => Doing.setting('duration'),
1874
- 'interval_format' => Doing.setting('interval_format')
1875
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1876
- opt[:duration] ||= cfg['duration'] || false
1877
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1878
-
1879
- section ||= Doing.setting('current_section')
1880
- section = guess_section(section)
1881
-
1882
- opt[:section] = section
1883
- opt[:wrap_width] = cfg['wrap_width']
1884
- opt[:count] = count
1885
- opt[:format] = cfg['date_format']
1886
- opt[:template] = opt[:template] || cfg['template']
1887
- opt[:order] = :asc
1888
- opt[:times] = times
1889
-
1890
- list_section(opt)
1891
- end
1892
-
1893
- ##
1894
- ## Show the last entry
1895
- ##
1896
- ## @param times (Bool) Show times
1897
- ## @param section [String] Section to pull from, default Currently
1898
- ##
1899
- def last(times: true, section: nil, options: {})
1900
- section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
1901
- cfg = Doing.setting(['templates', options[:config_template]]).deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1902
- 'wrap_width' => Doing.setting('wrap_width', 0),
1903
- 'date_format' => Doing.setting('default_date_format'),
1904
- 'order' => Doing.setting('order', :asc),
1905
- 'tags_color' => Doing.setting('tags_color'),
1906
- 'duration' => Doing.setting('duration'),
1907
- 'interval_format' => Doing.setting('interval_format')
1908
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1909
- options[:duration] ||= cfg['duration'] || false
1910
- options[:interval_format] ||= cfg['interval_format'] || 'text'
1911
-
1912
- opts = {
1913
- case: options[:case],
1914
- config_template: 'last',
1915
- count: 1,
1916
- delete: options[:delete],
1917
- duration: options[:duration],
1918
- format: cfg['date_format'],
1919
- interval_format: options[:interval_format],
1920
- not: options[:negate],
1921
- section: section,
1922
- template: options[:template] || cfg['template'],
1923
- times: times,
1924
- val: options[:val],
1925
- wrap_width: cfg['wrap_width']
1926
- }
1927
-
1928
- if options[:tag]
1929
- opts[:tag_filter] = {
1930
- 'tags' => options[:tag],
1931
- 'bool' => options[:tag_bool]
1932
- }
1933
- end
1934
-
1935
- opts[:search] = options[:search] if options[:search]
1936
-
1937
- list_section(opts)
1938
- end
1939
-
1940
- ##
1941
- ## Uses 'autotag' configuration to turn keywords into tags for time tracking.
1942
- ## Does not repeat tags in a title, and only converts the first instance of an
1943
- ## untagged keyword
1944
- ##
1945
- ## @param string [String] The text to tag
1946
- ##
1947
- def autotag(string)
1948
- return unless string
1949
- return string unless Doing.auto_tag
1950
-
1951
- original = string.dup
1952
- text = string.dup
1953
-
1954
- current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
1955
- tagged = {
1956
- whitelisted: [],
1957
- synonyms: [],
1958
- transformed: [],
1959
- replaced: []
1960
- }
1961
-
1962
- Doing.setting('autotag.whitelist').each do |tag|
1963
- next if text =~ /@#{tag}\b/i
1964
-
1965
- text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
1966
- m.downcase! unless tag =~ /[A-Z]/
1967
- tagged[:whitelisted].push(m)
1968
- "@#{m}"
1969
- end
1970
- end
1971
-
1972
- Doing.setting('autotag.synonyms').each do |tag, v|
1973
- v.each do |word|
1974
- word = word.wildcard_to_rx
1975
- next unless text =~ /\b#{word}\b/i
1976
-
1977
- unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
1978
- tagged[:synonyms].push(tag)
1979
- tagged[:synonyms] = tagged[:synonyms].uniq
1980
- end
1981
- end
1982
- end
1983
-
1984
- if Doing.setting('autotag.transform')
1985
- Doing.setting('autotag.transform').each do |tag|
1986
- next unless tag =~ /\S+:\S+/
1987
-
1988
- if tag =~ /::/
1989
- rx, r = tag.split(/::/)
1990
- else
1991
- rx, r = tag.split(/:/)
1992
- end
1993
-
1994
- flag_rx = %r{/([r]+)$}
1995
- if r =~ flag_rx
1996
- flags = r.match(flag_rx)[1].split(//)
1997
- r.sub!(flag_rx, '')
1998
- end
1999
- r.gsub!(/\$/, '\\')
2000
- rx.sub!(/^@?/, '@')
2001
- regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
2002
-
2003
- text.sub!(regex) do
2004
- m = Regexp.last_match
2005
- new_tag = r
2006
-
2007
- m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
2008
- new_tag.gsub!("\\#{idx + 1}", v)
2009
- end
2010
- # Replace original tag if /r
2011
- if flags&.include?('r')
2012
- tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
2013
- new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
2014
- else
2015
- tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
2016
- tagged[:transformed] = tagged[:transformed].uniq
2017
- m[0]
2018
- end
2019
- end
2020
- end
2021
- end
2022
-
2023
- logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
2024
- logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
2025
- logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
2026
- logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
2027
-
2028
- tail_tags = tagged[:synonyms].concat(tagged[:transformed])
2029
- tail_tags.sort!
2030
- tail_tags.uniq!
2031
-
2032
- text.add_tags!(tail_tags) unless tail_tags.empty?
2033
-
2034
- if text == original
2035
- logger.debug('Autotag:', "no change to \"#{text.strip}\"")
2036
- else
2037
- new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
2038
- logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
2039
- logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
2040
- end
2041
-
2042
- text.dedup_tags
2043
- end
2044
-
2045
- ##
2046
- ## Get total elapsed time for all tags in
2047
- ## selection
2048
- ##
2049
- ## @param format [String] return format (html,
2050
- ## json, or text)
2051
- ## @param sort_by [Symbol] Sort by :name or :time
2052
- ## @param sort_order [Symbol] The sort order (:asc or :desc)
2053
- ##
2054
- def tag_times(format: :text, sort_by: :time, sort_order: :asc)
2055
- return '' if @timers.empty?
2056
-
2057
- max = @timers.keys.sort_by(&:length).reverse[0].length + 1
2058
-
2059
- total = @timers.delete('All')
2060
-
2061
- tags_data = @timers.delete_if { |_k, v| v.zero? }
2062
- sorted_tags_data = if sort_by.normalize_tag_sort == :name
2063
- tags_data.sort_by { |k, _v| k }
2064
- else
2065
- tags_data.sort_by { |_k, v| v }
2066
- end
2067
-
2068
- sorted_tags_data.reverse! if sort_order.normalize_order == :asc
2069
- case format
2070
- when :html
2071
-
2072
- output = <<EOHEAD
2073
- <table>
2074
- <caption id="tagtotals">Tag Totals</caption>
2075
- <colgroup>
2076
- <col style="text-align:left;"/>
2077
- <col style="text-align:left;"/>
2078
- </colgroup>
2079
- <thead>
2080
- <tr>
2081
- <th style="text-align:left;">project</th>
2082
- <th style="text-align:left;">time</th>
2083
- </tr>
2084
- </thead>
2085
- <tbody>
2086
- EOHEAD
2087
- sorted_tags_data.reverse.each do |k, v|
2088
- if v.positive?
2089
- output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{v.time_string(format: :clock)}</td></tr>\n"
2090
- end
2091
- end
2092
- tail = <<EOTAIL
2093
- <tr>
2094
- <td style="text-align:left;" colspan="2"></td>
2095
- </tr>
2096
- </tbody>
2097
- <tfoot>
2098
- <tr>
2099
- <td style="text-align:left;"><strong>Total</strong></td>
2100
- <td style="text-align:left;">#{total.time_string(format: :clock)}</td>
2101
- </tr>
2102
- </tfoot>
2103
- </table>
2104
- EOTAIL
2105
- output + tail
2106
- when :markdown
2107
- pad = sorted_tags_data.map { |k, _| k }.group_by(&:size).max.last[0].length
2108
- pad = 7 if pad < 7
2109
- output = <<~EOHEADER
2110
- | #{' ' * (pad - 7)}project | time |
2111
- | #{'-' * (pad - 1)}: | :------- |
2112
- EOHEADER
2113
- sorted_tags_data.reverse.each do |k, v|
2114
- if v.positive?
2115
- output += "| #{' ' * (pad - k.length)}#{k} | #{v.time_string(format: :clock)} |\n"
2116
- end
2117
- end
2118
- tail = '[Tag Totals]'
2119
- output + tail
2120
- when :json
2121
- output = []
2122
- sorted_tags_data.reverse.each do |k, v|
2123
- output << {
2124
- 'tag' => k,
2125
- 'seconds' => v,
2126
- 'formatted' => v.time_string(format: :clock)
2127
- }
2128
- end
2129
- output
2130
- when :human
2131
- output = []
2132
- sorted_tags_data.reverse.each do |k, v|
2133
- spacer = ''
2134
- (max - k.length).times do
2135
- spacer += ' '
2136
- end
2137
- output.push("┃ #{spacer}#{k}:#{v.time_string(format: :hm)} ┃")
2138
- end
2139
-
2140
- header = '┏━━ Tag Totals '
2141
- (max - 2).times { header += '━' }
2142
- header += '┓'
2143
- footer = '┗'
2144
- (max + 12).times { footer += '━' }
2145
- footer += '┛'
2146
- divider = '┣'
2147
- (max + 12).times { divider += '━' }
2148
- divider += '┫'
2149
- output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
2150
- output += "\n#{divider}"
2151
- spacer = ''
2152
- (max - 6).times do
2153
- spacer += ' '
2154
- end
2155
- total_time = total.time_string(format: :hm)
2156
- total = "┃ #{spacer}total: "
2157
- total += total_time
2158
- total += ' ┃'
2159
- output += "\n#{total}"
2160
- output += "\n#{footer}"
2161
- output
2162
- else
2163
- output = []
2164
- sorted_tags_data.reverse.each do |k, v|
2165
- spacer = ''
2166
- (max - k.length).times do
2167
- spacer += ' '
2168
- end
2169
- output.push("#{k}:#{spacer}#{v.time_string(format: :clock)}")
2170
- end
2171
-
2172
- output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
2173
- output += "\n\nTotal tracked: #{total.time_string(format: :clock)}\n"
2174
- output
2175
- end
2176
- end
2177
-
2178
- ##
2179
- ## Gets the interval between entry's start
2180
- ## date and @done date
2181
- ##
2182
- ## @param item [Item] The entry
2183
- ## @param formatted [Boolean] Return human readable
2184
- ## time (default seconds)
2185
- ## @param record [Boolean] Add the interval to the
2186
- ## total for each tag
2187
- ##
2188
- ## @return Interval in seconds, or [d, h, m] array if
2189
- ## formatted is true. False if no end date or
2190
- ## interval is 0
2191
- ##
2192
- def get_interval(item, formatted: true, record: true)
2193
- if item.interval
2194
- seconds = item.interval
2195
- record_tag_times(item, seconds) if record
2196
- return seconds.positive? ? seconds : false unless formatted
2197
-
2198
- return seconds.positive? ? seconds.time_string(format: :clock) : false
2199
- end
2200
-
2201
- false
2202
- end
2203
-
2204
- ##
2205
- ## Load configuration files and updated the @settings
2206
- ## attribute with a Doing::Configuration object
2207
- ##
2208
- ## @param filename [String] (optional) path to
2209
- ## alternative config file
2210
- ##
2211
- def configure(filename = nil)
2212
- logger.benchmark(:configure, :start)
2213
-
2214
- if filename
2215
- Doing.config_with(filename, { ignore_local: true })
2216
- elsif ENV['DOING_CONFIG']
2217
- Doing.config_with(ENV['DOING_CONFIG'], { ignore_local: true })
2218
- end
2219
-
2220
- logger.benchmark(:configure, :finish)
2221
-
2222
- Doing.set('backup_dir', ENV['DOING_BACKUP_DIR']) if ENV['DOING_BACKUP_DIR']
2223
- end
2224
-
2225
- def get_diff(filename = nil)
2226
- configure if Doing.settings.nil?
2227
-
2228
- filename ||= Doing.setting('doing_file')
2229
- init_doing_file(filename)
2230
- current_content = @content.clone
2231
- backup_file = Util::Backup.last_backup(filename, count: 1)
2232
- raise DoingRuntimeError, 'No undo history to diff' if backup_file.nil?
2233
-
2234
- backup = WWID.new
2235
- backup.config = Doing.settings
2236
- backup.init_doing_file(backup_file)
2237
- current_content.diff(backup.content)
97
+ false
2238
98
  end
2239
99
 
2240
100
  private
2241
101
 
2242
- ##
2243
- ## Wraps doing file content with additional
2244
- ## header/footer content
2245
- ##
2246
- ## @return [String] concatenated content
2247
- ##
2248
- def combined_content
2249
- output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
2250
- was_color = Color.coloring?
2251
- Color.coloring = false
2252
- @content.dedup!(match_section: true)
2253
- output += @content.to_s
2254
- output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
2255
- # Just strip all ANSI colors from the content before writing to doing file
2256
- Color.coloring = was_color
2257
-
2258
- output.uncolor
2259
- end
2260
-
2261
- ##
2262
- ## Generate output using available export plugins
2263
- ##
2264
- ## @param items [Array] The items
2265
- ## @param title [String] Page title
2266
- ## @param is_single [Boolean] Indicates if single
2267
- ## section
2268
- ## @param opt [Hash] Additional options
2269
- ##
2270
- ## @return [String] formatted output based on opt[:output]
2271
- ## template trigger
2272
- ##
2273
- def output(items, title, is_single, opt)
2274
- logger.benchmark(:output, :start)
2275
- opt ||= {}
2276
- out = nil
2277
-
2278
- raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
2279
-
2280
- export_options = { page_title: title, is_single: is_single, options: opt }
2281
-
2282
- Hooks.trigger :pre_export, self, opt[:output], items
2283
-
2284
- Plugins.plugins[:export].each do |_, options|
2285
- next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
2286
-
2287
- out = options[:class].render(self, items, variables: export_options)
2288
- break
2289
- end
2290
-
2291
- logger.debug('Output:', "#{items.count} #{items.count == 1 ? 'item' : 'items'} shown")
2292
- logger.benchmark(:output, :finish)
2293
- out
2294
- end
2295
-
2296
- ##
2297
- ## Record times for item tags
2298
- ##
2299
- ## @param item [Item] The item to record
2300
- ##
2301
- def record_tag_times(item, seconds)
2302
- item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
2303
- return if @recorded_items.include?(item_hash)
2304
- item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2305
- k = m[0] == 'done' ? 'All' : m[0].downcase
2306
- if @timers.key?(k)
2307
- @timers[k] += seconds
2308
- else
2309
- @timers[k] = seconds
2310
- end
2311
- @recorded_items.push(item_hash)
2312
- end
2313
- end
2314
-
2315
- ##
2316
- ## Helper function, performs the actual archiving
2317
- ##
2318
- ## @param section [String] The source section
2319
- ## @param destination [String] The destination
2320
- ## section
2321
- ## @param opt [Hash] Additional Options
2322
- ##
2323
- def do_archive(section, destination, opt)
2324
- opt ||= {}
2325
- count = opt[:count] || 0
2326
- tags = opt[:tags] || []
2327
- bool = opt[:bool] || :and
2328
- label = opt[:label] || true
2329
-
2330
- section = guess_section(section)
2331
- destination = guess_section(destination)
2332
-
2333
- section_items = @content.in_section(section)
2334
- max = section_items.count - count.to_i
2335
-
2336
- opt[:after] = opt[:from][0] if opt[:from]
2337
- opt[:before] = opt[:from][1] if opt[:from]
2338
-
2339
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
2340
-
2341
- if opt[:before].is_a?(String) && opt[:before] =~ time_rx
2342
- opt[:before] = opt[:before].chronify(guess: :end, future: false)
2343
- end
2344
-
2345
- if opt[:after].is_a?(String) && opt[:after] =~ time_rx
2346
- opt[:after] = opt[:after].chronify(guess: :begin, future: false)
2347
- end
2348
-
2349
- counter = 0
2350
-
2351
- @content.map do |item|
2352
- break if counter >= max
2353
-
2354
- next if item.section.downcase == destination.downcase
2355
-
2356
- next if item.section.downcase != section.downcase && section != /^all$/i
2357
-
2358
- next if (opt[:before] && item.date > opt[:before]) || (opt[:after] && item.date < opt[:after])
2359
-
2360
- next if (!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s))
2361
-
2362
- counter += 1
2363
- old_item = item.clone
2364
- item.move_to(destination, label: label, log: false)
2365
- Hooks.trigger :post_entry_updated, self, item, old_item
2366
- item
2367
- end
2368
-
2369
- if counter.positive?
2370
- logger.count(destination == 'Archive' ? :archived : :moved,
2371
- level: :info,
2372
- count: counter,
2373
- message: "%count %items from #{section} to #{destination}")
2374
- else
2375
- logger.info('Skipped:', 'No items were moved')
2376
- end
2377
- end
2378
-
2379
102
  def run_after
2380
103
  return unless Doing.setting('run_after')
2381
104