doing 1.0.90 → 2.0.2.pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/AUTHORS +19 -0
  3. data/CHANGELOG.md +590 -0
  4. data/COMMANDS.md +1181 -0
  5. data/Gemfile +2 -0
  6. data/Gemfile.lock +110 -0
  7. data/LICENSE +23 -0
  8. data/README.md +14 -697
  9. data/Rakefile +79 -0
  10. data/_config.yml +1 -0
  11. data/bin/doing +1037 -481
  12. data/doing.fish +278 -0
  13. data/doing.gemspec +34 -0
  14. data/doing.rdoc +1759 -0
  15. data/example_plugin.rb +209 -0
  16. data/generate_completions.sh +4 -0
  17. data/img/doing-colors.jpg +0 -0
  18. data/img/doing-printf-wrap-800.jpg +0 -0
  19. data/img/doing-show-note-formatting-800.jpg +0 -0
  20. data/lib/completion/_doing.zsh +151 -0
  21. data/lib/completion/doing.bash +416 -0
  22. data/lib/completion/doing.fish +278 -0
  23. data/lib/doing/array.rb +8 -0
  24. data/lib/doing/cli_status.rb +66 -0
  25. data/lib/doing/colors.rb +136 -0
  26. data/lib/doing/configuration.rb +310 -0
  27. data/lib/doing/errors.rb +102 -0
  28. data/lib/doing/hash.rb +31 -0
  29. data/lib/doing/hooks.rb +59 -0
  30. data/lib/doing/item.rb +155 -0
  31. data/lib/doing/log_adapter.rb +342 -0
  32. data/lib/doing/markdown_document_listener.rb +174 -0
  33. data/lib/doing/note.rb +59 -0
  34. data/lib/doing/pager.rb +95 -0
  35. data/lib/doing/plugin_manager.rb +208 -0
  36. data/lib/doing/plugins/export/csv_export.rb +48 -0
  37. data/lib/doing/plugins/export/html_export.rb +83 -0
  38. data/lib/doing/plugins/export/json_export.rb +140 -0
  39. data/lib/doing/plugins/export/markdown_export.rb +85 -0
  40. data/lib/doing/plugins/export/taskpaper_export.rb +34 -0
  41. data/lib/doing/plugins/export/template_export.rb +141 -0
  42. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  43. data/lib/doing/plugins/import/calendar_import.rb +76 -0
  44. data/lib/doing/plugins/import/doing_import.rb +144 -0
  45. data/lib/doing/plugins/import/timing_import.rb +78 -0
  46. data/lib/doing/string.rb +346 -0
  47. data/lib/doing/symbol.rb +16 -0
  48. data/lib/doing/time.rb +18 -0
  49. data/lib/doing/util.rb +186 -0
  50. data/lib/doing/version.rb +1 -1
  51. data/lib/doing/wwid.rb +1838 -2266
  52. data/lib/doing/wwidfile.rb +117 -0
  53. data/lib/doing.rb +43 -2
  54. data/lib/examples/commands/wiki.rb +80 -0
  55. data/lib/examples/plugins/hooks.rb +22 -0
  56. data/lib/examples/plugins/say_export.rb +202 -0
  57. data/lib/examples/plugins/templates/wiki.css +169 -0
  58. data/lib/examples/plugins/templates/wiki.haml +27 -0
  59. data/lib/examples/plugins/templates/wiki_index.haml +18 -0
  60. data/lib/examples/plugins/wiki_export.rb +87 -0
  61. data/lib/templates/doing-markdown.erb +5 -0
  62. data/man/doing.1 +964 -0
  63. data/man/doing.1.html +711 -0
  64. data/man/doing.1.ronn +600 -0
  65. data/package-lock.json +3 -0
  66. data/rdoc_to_mmd.rb +42 -0
  67. data/rdocfixer.rb +13 -0
  68. data/scripts/generate_bash_completions.rb +210 -0
  69. data/scripts/generate_fish_completions.rb +201 -0
  70. data/scripts/generate_zsh_completions.rb +164 -0
  71. metadata +82 -6
  72. data/lib/doing/helpers.rb +0 -121
data/lib/doing/wwid.rb CHANGED
@@ -1,2587 +1,2159 @@
1
1
  #!/usr/bin/ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'deep_merge'
4
5
  require 'open3'
5
6
  require 'pp'
6
7
  require 'shellwords'
8
+ require 'erb'
9
+
10
+ module Doing
11
+ ##
12
+ ## @brief Main "What Was I Doing" methods
13
+ ##
14
+ class WWID
15
+ attr_reader :additional_configs, :current_section, :doing_file, :content
16
+
17
+ attr_accessor :config, :config_file, :auto_tag, :default_option
18
+
19
+ # include Util
20
+
21
+ ##
22
+ ## @brief Initializes the object.
23
+ ##
24
+ def initialize
25
+ @timers = {}
26
+ @recorded_items = []
27
+ @content = {}
28
+ @doingrc_needs_update = false
29
+ @default_config_file = '.doingrc'
30
+ @auto_tag = true
31
+ @user_home = Util.user_home
32
+ end
33
+
34
+ ##
35
+ ## @brief Logger
36
+ ##
37
+ ## Responds to :debug, :info, :warn, and :error
38
+ ##
39
+ ## Each method takes a topic, and a message or block
40
+ ##
41
+ ## Example: debug('Hooks', 'Hook 1 triggered')
42
+ ##
43
+ def logger
44
+ @logger ||= Doing.logger
45
+ end
46
+
47
+ ##
48
+ ## @brief Initializes the doing file.
49
+ ##
50
+ ## @param path (String) Override path to a doing file, optional
51
+ ##
52
+ def init_doing_file(path = nil)
53
+ @doing_file = File.expand_path(@config['doing_file'])
54
+
55
+ if path.nil?
56
+ create(@doing_file) unless File.exist?(@doing_file)
57
+ input = IO.read(@doing_file)
58
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
59
+ elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
60
+ @doing_file = File.expand_path(path)
61
+ input = IO.read(File.expand_path(path))
62
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
63
+ elsif path.length < 256
64
+ @doing_file = File.expand_path(path)
65
+ create(path)
66
+ input = IO.read(File.expand_path(path))
67
+ input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
68
+ end
7
69
 
8
- ##
9
- ## @brief Main "What Was I Doing" methods
10
- ##
11
- class WWID
12
- attr_accessor :content, :sections, :current_section, :doing_file, :config, :user_home, :default_config_file,
13
- :config_file, :results, :auto_tag
14
-
15
- ##
16
- ## @brief Initializes the object.
17
- ##
18
- def initialize
19
- @content = {}
20
- @doingrc_needs_update = false
21
- @default_config_file = '.doingrc'
22
- @interval_cache = {}
23
- @results = []
24
- @auto_tag = true
25
- end
26
-
27
- ##
28
- ## @brief Finds a project-specific configuration file
29
- ##
30
- ## @return (String) A file path
31
- ##
32
- def find_local_config
33
- config = {}
34
- dir = Dir.pwd
70
+ @other_content_top = []
71
+ @other_content_bottom = []
72
+
73
+ section = 'Uncategorized'
74
+ lines = input.split(/[\n\r]/)
75
+ current = 0
76
+
77
+ lines.each do |line|
78
+ next if line =~ /^\s*$/
79
+
80
+ if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
81
+ section = Regexp.last_match(1)
82
+ @content[section] = {}
83
+ @content[section][:original] = line
84
+ @content[section][:items] = []
85
+ current = 0
86
+ elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
87
+ date = Regexp.last_match(1).strip
88
+ title = Regexp.last_match(2).strip
89
+ item = Item.new(date, title, section)
90
+ @content[section][:items].push(item)
91
+ current += 1
92
+ elsif current.zero?
93
+ # if content[section][:items].length - 1 == current
94
+ @other_content_top.push(line)
95
+ elsif line =~ /^\S/
96
+ @other_content_bottom.push(line)
97
+ else
98
+ prev_item = @content[section][:items][current - 1]
99
+ prev_item.note = Note.new unless prev_item.note
35
100
 
36
- local_config_files = []
101
+ prev_item.note.add(line)
102
+ # end
103
+ end
104
+ end
105
+ Hooks.trigger :post_read, self
106
+ end
37
107
 
38
- while dir != '/' && (dir =~ %r{[A-Z]:/}).nil?
39
- local_config_files.push(File.join(dir, @default_config_file)) if File.exist? File.join(dir, @default_config_file)
108
+ ##
109
+ ## @brief Create a new doing file
110
+ ##
111
+ def create(filename = nil)
112
+ filename = @doing_file if filename.nil?
113
+ return if File.exist?(filename) && File.stat(filename).size.positive?
40
114
 
41
- dir = File.dirname(dir)
115
+ File.open(filename, 'w+') do |f|
116
+ f.puts "#{@config['current_section']}:"
117
+ end
42
118
  end
43
119
 
44
- local_config_files
45
- end
120
+ ##
121
+ ## @brief Create a process for an editor and wait for the file handle to return
122
+ ##
123
+ ## @param input (String) Text input for editor
124
+ ##
125
+ def fork_editor(input = '')
126
+ # raise Errors::NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
46
127
 
47
- ##
48
- ## @brief Reads a configuration.
49
- ##
50
- def read_config(opt = {})
51
- @config_file ||= if Dir.respond_to?('home')
52
- File.join(Dir.home, @default_config_file)
53
- else
54
- File.join(File.expand_path('~'), @default_config_file)
55
- end
56
-
57
- additional_configs = if opt[:ignore_local]
58
- []
59
- else
60
- find_local_config
61
- end
128
+ raise Errors::MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
62
129
 
63
- begin
64
- @local_config = {}
130
+ tmpfile = Tempfile.new(['doing', '.md'])
65
131
 
66
- @config = YAML.load_file(@config_file) || {} if File.exist?(@config_file)
67
- additional_configs.each do |cfg|
68
- new_config = YAML.load_file(cfg) || {} if cfg
69
- @local_config = @local_config.deep_merge(new_config)
132
+ File.open(tmpfile.path, 'w+') do |f|
133
+ f.puts input
134
+ f.puts "\n# The first line is the entry title, any lines after that are added as a note"
70
135
  end
71
136
 
72
- # @config.deep_merge(@local_config)
73
- rescue StandardError
74
- @config = {}
75
- @local_config = {}
76
- # exit_now! "error reading config"
77
- end
78
- end
79
-
80
- ##
81
- ## @brief Read user configuration and merge with defaults
82
- ##
83
- ## @param opt (Hash) Additional Options
84
- ##
85
- def configure(opt = {})
86
- @timers = {}
87
- @recorded_items = []
88
- opt[:ignore_local] ||= false
89
-
90
- @config_file ||= File.join(@user_home, @default_config_file)
91
-
92
- read_config({ ignore_local: opt[:ignore_local] })
93
-
94
- @config = {} if @config.nil?
95
-
96
- @config['autotag'] ||= {}
97
- @config['autotag']['whitelist'] ||= []
98
- @config['autotag']['synonyms'] ||= {}
99
- @config['doing_file'] ||= '~/what_was_i_doing.md'
100
- @config['current_section'] ||= 'Currently'
101
- @config['config_editor_app'] ||= nil
102
- @config['editor_app'] ||= nil
103
-
104
- @config['html_template'] ||= {}
105
- @config['html_template']['haml'] ||= nil
106
- @config['html_template']['css'] ||= nil
107
-
108
- @config['templates'] ||= {}
109
- @config['templates']['default'] ||= {
110
- 'date_format' => '%Y-%m-%d %H:%M',
111
- 'template' => '%date | %title%note',
112
- 'wrap_width' => 0
113
- }
114
- @config['templates']['today'] ||= {
115
- 'date_format' => '%_I:%M%P',
116
- 'template' => '%date: %title %interval%note',
117
- 'wrap_width' => 0
118
- }
119
- @config['templates']['last'] ||= {
120
- 'date_format' => '%-I:%M%P on %a',
121
- 'template' => '%title (at %date)%odnote',
122
- 'wrap_width' => 88
123
- }
124
- @config['templates']['recent'] ||= {
125
- 'date_format' => '%_I:%M%P',
126
- 'template' => '%shortdate: %title (%section)',
127
- 'wrap_width' => 88,
128
- 'count' => 10
129
- }
130
- @config['views'] ||= {
131
- 'done' => {
132
- 'date_format' => '%_I:%M%P',
133
- 'template' => '%date | %title%note',
134
- 'wrap_width' => 0,
135
- 'section' => 'All',
136
- 'count' => 0,
137
- 'order' => 'desc',
138
- 'tags' => 'done complete cancelled',
139
- 'tags_bool' => 'OR'
140
- },
141
- 'color' => {
142
- 'date_format' => '%F %_I:%M%P',
143
- 'template' => '%boldblack%date %boldgreen| %boldwhite%title%default%note',
144
- 'wrap_width' => 0,
145
- 'section' => 'Currently',
146
- 'count' => 10,
147
- 'order' => 'asc'
148
- }
149
- }
150
- @config['marker_tag'] ||= 'flagged'
151
- @config['marker_color'] ||= 'red'
152
- @config['default_tags'] ||= []
153
- @config['tag_sort'] ||= 'time'
154
-
155
- @current_section = config['current_section']
156
- @default_template = config['templates']['default']['template']
157
- @default_date_format = config['templates']['default']['date_format']
158
-
159
- @config[:include_notes] ||= true
137
+ pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
160
138
 
161
- # if ENV['DOING_DEBUG'].to_i == 3
162
- # if @config['default_tags'].length > 0
163
- # exit_now! "DEFAULT CONFIG CHANGED"
164
- # end
165
- # end
139
+ trap('INT') do
140
+ begin
141
+ Process.kill(9, pid)
142
+ rescue StandardError
143
+ Errno::ESRCH
144
+ end
145
+ tmpfile.unlink
146
+ tmpfile.close!
147
+ exit 0
148
+ end
166
149
 
167
- File.open(@config_file, 'w') { |yf| YAML.dump(@config, yf) } unless File.exist?(@config_file)
150
+ Process.wait(pid)
168
151
 
169
- @config = @local_config.deep_merge(@config)
152
+ begin
153
+ if $?.exitstatus == 0
154
+ input = IO.read(tmpfile.path)
155
+ else
156
+ exit_now! 'Cancelled'
157
+ end
158
+ ensure
159
+ tmpfile.close
160
+ tmpfile.unlink
161
+ end
170
162
 
171
- @current_section = @config['current_section']
172
- @default_template = @config['templates']['default']['template']
173
- @default_date_format = @config['templates']['default']['date_format']
174
- end
163
+ input.split(/\n/).delete_if(&:ignore?).join("\n")
164
+ end
165
+
166
+ ##
167
+ ## @brief Takes a multi-line string and formats it as an entry
168
+ ##
169
+ ## @return (Array) [(String)title, (Array)note]
170
+ ##
171
+ ## @param input (String) The string to parse
172
+ ##
173
+ ## @return (Array) [(String)title, (Note)note]
174
+ ##
175
+ def format_input(input)
176
+ raise Errors::EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
177
+
178
+ input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
179
+ title = input_lines[0]&.strip
180
+ raise Errors::EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
181
+
182
+ note = Note.new
183
+ note.add(input_lines[1..-1]) if input_lines.length > 1
184
+ # If title line ends in a parenthetical, use that as the note
185
+ if note.empty? && title =~ /\s+\(.*?\)$/
186
+ title.sub!(/\s+\((.*?)\)$/) do
187
+ m = Regexp.last_match
188
+ note.add(m[1])
189
+ ''
190
+ end
191
+ end
175
192
 
176
- ##
177
- ## @brief Initializes the doing file.
178
- ##
179
- ## @param path (String) Override path to a doing file, optional
180
- ##
181
- def init_doing_file(path = nil)
182
- @doing_file = File.expand_path(@config['doing_file'])
183
-
184
- input = path
185
-
186
- if input.nil?
187
- create(@doing_file) unless File.exist?(@doing_file)
188
- input = IO.read(@doing_file)
189
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
190
- elsif File.exist?(File.expand_path(input)) && File.file?(File.expand_path(input)) && File.stat(File.expand_path(input)).size.positive?
191
- @doing_file = File.expand_path(input)
192
- input = IO.read(File.expand_path(input))
193
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
194
- elsif input.length < 256
195
- @doing_file = File.expand_path(input)
196
- create(input)
197
- input = IO.read(File.expand_path(input))
198
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
199
- end
193
+ note.strip_lines!
194
+ note.compress
195
+
196
+ [title, note]
197
+ end
198
+
199
+ ##
200
+ ## @brief Converts input string into a Time object when input takes on the
201
+ ## following formats:
202
+ ## - interval format e.g. '1d2h30m', '45m' etc.
203
+ ## - a semantic phrase e.g. 'yesterday 5:30pm'
204
+ ## - a strftime e.g. '2016-03-15 15:32:04 PDT'
205
+ ##
206
+ ## @param input (String) String to chronify
207
+ ##
208
+ ## @return (DateTime) result
209
+ ##
210
+ def chronify(input, future: false, guess: :begin)
211
+ now = Time.now
212
+ raise Errors::InvalidTimeExpression, "Invalid time expression #{input.inspect}" if input.to_s.strip == ''
213
+
214
+ secs_ago = if input.match(/^(\d+)$/)
215
+ # plain number, assume minutes
216
+ Regexp.last_match(1).to_i * 60
217
+ elsif (m = input.match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
218
+ # day/hour/minute format e.g. 1d2h30m
219
+ [[m['day'], 24 * 3600],
220
+ [m['hour'], 3600],
221
+ [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
222
+ end
200
223
 
201
- @other_content_top = []
202
- @other_content_bottom = []
203
-
204
- section = 'Uncategorized'
205
- lines = input.split(/[\n\r]/)
206
- current = 0
207
-
208
- lines.each do |line|
209
- next if line =~ /^\s*$/
210
-
211
- if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
212
- section = Regexp.last_match(1)
213
- @content[section] = {}
214
- @content[section]['original'] = line
215
- @content[section]['items'] = []
216
- current = 0
217
- elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
218
- date = Time.parse(Regexp.last_match(1))
219
- title = Regexp.last_match(2)
220
- @content[section]['items'].push({ 'title' => title, 'date' => date, 'section' => section })
221
- current += 1
222
- elsif current.zero?
223
- # if content[section]['items'].length - 1 == current
224
- @other_content_top.push(line)
225
- elsif line =~ /^\S/
226
- @other_content_bottom.push(line)
224
+ if secs_ago
225
+ now - secs_ago
227
226
  else
228
- @content[section]['items'][current - 1]['note'] = [] unless @content[section]['items'][current - 1].key? 'note'
229
-
230
- @content[section]['items'][current - 1]['note'].push(line.chomp)
231
- # end
227
+ Chronic.parse(input, { guess: guess, context: future ? :future : :past, ambiguous_time_range: 8 })
232
228
  end
233
229
  end
234
- end
235
230
 
236
- ##
237
- ## @brief Return the contents of the HAML template for HTML output
238
- ##
239
- ## @return (String) HAML template
240
- ##
241
- def haml_template
242
- IO.read(File.join(File.dirname(__FILE__), '../templates/doing.haml'))
243
- end
231
+ ##
232
+ ## @brief Converts simple strings into seconds that can be added to a Time
233
+ ## object
234
+ ##
235
+ ## @param qty (String) HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
236
+ ## 1.5d, 1h20m, etc.)
237
+ ##
238
+ ## @return (Integer) seconds
239
+ ##
240
+ def chronify_qty(qty)
241
+ minutes = 0
242
+ case qty.strip
243
+ when /^(\d+):(\d\d)$/
244
+ minutes += Regexp.last_match(1).to_i * 60
245
+ minutes += Regexp.last_match(2).to_i
246
+ when /^(\d+(?:\.\d+)?)([hmd])?$/
247
+ amt = Regexp.last_match(1)
248
+ type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
249
+
250
+ minutes = case type.downcase
251
+ when 'm'
252
+ amt.to_i
253
+ when 'h'
254
+ (amt.to_f * 60).round
255
+ when 'd'
256
+ (amt.to_f * 60 * 24).round
257
+ else
258
+ minutes
259
+ end
260
+ end
261
+ minutes * 60
262
+ end
263
+
264
+ ##
265
+ ## @brief List sections
266
+ ##
267
+ ## @return (Array) section titles
268
+ ##
269
+ def sections
270
+ @content.keys
271
+ end
272
+
273
+ ##
274
+ ## @brief Adds a section.
275
+ ##
276
+ ## @param title (String) The new section title
277
+ ##
278
+ def add_section(title)
279
+ if @content.key?(title.cap_first)
280
+ logger.debug('Skipped': 'Section already exists')
281
+ return
282
+ end
244
283
 
245
- ##
246
- ## @brief Return the contents of the CSS template for HTML output
247
- ##
248
- ## @return (String) CSS template
249
- ##
250
- def css_template
251
- IO.read(File.join(File.dirname(__FILE__), '../templates/doing.css'))
252
- end
284
+ @content[title.cap_first] = { :original => "#{title}:", :items => [] }
285
+ logger.info('New section:', %("#{title.cap_first}"))
286
+ end
253
287
 
254
- ##
255
- ## @brief Create a new doing file
256
- ##
257
- def create(filename = nil)
258
- filename = @doing_file if filename.nil?
259
- return if File.exist?(filename) && File.stat(filename).size.positive?
288
+ ##
289
+ ## @brief Attempt to match a string with an existing section
290
+ ##
291
+ ## @param frag (String) The user-provided string
292
+ ## @param guessed (Boolean) already guessed and failed
293
+ ##
294
+ def guess_section(frag, guessed: false, suggest: false)
295
+ return 'All' if frag =~ /^all$/i
296
+ frag ||= @config['current_section']
260
297
 
261
- File.open(filename, 'w+') do |f|
262
- f.puts "#{@current_section}:"
263
- end
264
- end
298
+ sections.each { |sect| return sect.cap_first if frag.downcase == sect.downcase }
299
+ section = false
300
+ re = frag.split('').join('.*?')
301
+ sections.each do |sect|
302
+ next unless sect =~ /#{re}/i
265
303
 
266
- ##
267
- ## @brief Create a process for an editor and wait for the file handle to return
268
- ##
269
- ## @param input (String) Text input for editor
270
- ##
271
- def fork_editor(input = '')
272
- tmpfile = Tempfile.new(['doing', '.md'])
304
+ logger.debug('Match:', %(Assuming "#{sect}" from "#{frag}"))
305
+ section = sect
306
+ break
307
+ end
273
308
 
274
- File.open(tmpfile.path, 'w+') do |f|
275
- f.puts input
276
- f.puts "\n# The first line is the entry title, any lines after that are added as a note"
277
- end
309
+ return section if suggest
278
310
 
279
- pid = Process.fork { system("$EDITOR #{tmpfile.path}") }
311
+ unless section || guessed
312
+ alt = guess_view(frag, guessed: true, suggest: true)
313
+ if alt
314
+ meant_view = yn("Did you mean `doing view #{alt}`?", default_response: 'n')
315
+ raise Errors::InvalidSection, "Run again with `doing view #{alt}`" if meant_view
316
+ end
280
317
 
281
- trap('INT') do
282
- begin
283
- Process.kill(9, pid)
284
- rescue StandardError
285
- Errno::ESRCH
286
- end
287
- tmpfile.unlink
288
- tmpfile.close!
289
- exit 0
290
- end
318
+ res = yn("Section #{frag} not found, create it", default_response: 'n')
291
319
 
292
- Process.wait(pid)
320
+ if res
321
+ add_section(frag.cap_first)
322
+ write(@doing_file)
323
+ return frag.cap_first
324
+ end
293
325
 
294
- begin
295
- if $?.exitstatus == 0
296
- input = IO.read(tmpfile.path)
326
+ raise Errors::InvalidSection, "Unknown section: #{frag}"
327
+ end
328
+ section ? section.cap_first : guessed
329
+ end
330
+
331
+ ##
332
+ ## @brief Ask a yes or no question in the terminal
333
+ ##
334
+ ## @param question (String) The question to ask
335
+ ## @param default (Bool) default response if no input
336
+ ##
337
+ ## @return (Bool) yes or no
338
+ ##
339
+ def yn(question, default_response: false)
340
+ if default_response.is_a?(String)
341
+ default = default_response =~ /y/i ? true : false
297
342
  else
298
- exit_now! 'Cancelled'
343
+ default = default_response
299
344
  end
300
- ensure
301
- tmpfile.close
302
- tmpfile.unlink
303
- end
304
-
305
- input.split(/\n/).delete_if {|line| line =~ /^#/ }.join("\n")
306
- end
307
345
 
308
- #
309
- # @brief Takes a multi-line string and formats it as an entry
310
- #
311
- # @return (Array) [(String)title, (Array)note]
312
- #
313
- # @param input (String) The string to parse
314
- #
315
- def format_input(input)
316
- exit_now! 'No content in entry' if input.nil? || input.strip.empty?
317
-
318
- input_lines = input.split(/[\n\r]+/).delete_if {|line| line =~ /^#/ || line =~ /^\s*$/ }
319
- title = input_lines[0]&.strip
320
- exit_now! 'No content in first line' if title.nil? || title.strip.empty?
321
-
322
- note = input_lines.length > 1 ? input_lines[1..-1] : []
323
- # If title line ends in a parenthetical, use that as the note
324
- if note.empty? && title =~ /\s+\(.*?\)$/
325
- title.sub!(/\s+\((.*?)\)$/) do
326
- m = Regexp.last_match
327
- note.push(m[1])
328
- ''
329
- end
330
- end
346
+ # if global --default is set, answer default
347
+ return default if @default_option
331
348
 
332
- note.map!(&:strip)
333
- note.delete_if { |line| line =~ /^\s*$/ || line =~ /^#/ }
349
+ # if this isn't an interactive shell, answer default
350
+ return default unless $stdout.isatty
334
351
 
335
- [title, note]
336
- end
352
+ # clear the buffer
353
+ if ARGV&.length
354
+ ARGV.length.times do
355
+ ARGV.shift
356
+ end
357
+ end
358
+ system 'stty cbreak'
337
359
 
338
- #
339
- # @brief Converts input string into a Time object when input takes on the
340
- # following formats:
341
- # - interval format e.g. '1d2h30m', '45m' etc.
342
- # - a semantic phrase e.g. 'yesterday 5:30pm'
343
- # - a strftime e.g. '2016-03-15 15:32:04 PDT'
344
- #
345
- # @param input (String) String to chronify
346
- #
347
- # @return (DateTime) result
348
- #
349
- def chronify(input)
350
- now = Time.now
351
- exit_now! "Invalid time expression #{input.inspect}" if input.to_s.strip == ''
352
-
353
- secs_ago = if input.match(/^(\d+)$/)
354
- # plain number, assume minutes
355
- Regexp.last_match(1).to_i * 60
356
- elsif (m = input.match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
357
- # day/hour/minute format e.g. 1d2h30m
358
- [[m['day'], 24 * 3600],
359
- [m['hour'], 3600],
360
- [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
361
- end
362
-
363
- if secs_ago
364
- now - secs_ago
365
- else
366
- Chronic.parse(input, { context: :past, ambiguous_time_range: 8 })
367
- end
368
- end
360
+ cw = Color.white
361
+ cbw = Color.boldwhite
362
+ cbg = Color.boldgreen
363
+ cd = Color.default
369
364
 
370
- #
371
- # @brief Converts simple strings into seconds that can be added to a Time
372
- # object
373
- #
374
- # @param qty (String) HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
375
- # 1.5d, 1h20m, etc.)
376
- #
377
- # @return (Integer) seconds
378
- #
379
- def chronify_qty(qty)
380
- minutes = 0
381
- case qty.strip
382
- when /^(\d+):(\d\d)$/
383
- minutes += Regexp.last_match(1).to_i * 60
384
- minutes += Regexp.last_match(2).to_i
385
- when /^(\d+(?:\.\d+)?)([hmd])?$/
386
- amt = Regexp.last_match(1)
387
- type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
388
-
389
- minutes = case type.downcase
390
- when 'm'
391
- amt.to_i
392
- when 'h'
393
- (amt.to_f * 60).round
394
- when 'd'
395
- (amt.to_f * 60 * 24).round
365
+ options = unless default.nil?
366
+ "#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
396
367
  else
397
- minutes
368
+ "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
398
369
  end
399
- end
400
- minutes * 60
401
- end
402
-
403
- ##
404
- ## @brief List sections
405
- ##
406
- ## @return (Array) section titles
407
- ##
408
- def sections
409
- @content.keys
410
- end
411
-
412
- ##
413
- ## @brief Adds a section.
414
- ##
415
- ## @param title (String) The new section title
416
- ##
417
- def add_section(title)
418
- @content[title.cap_first] = { 'original' => "#{title}:", 'items' => [] }
419
- @results.push(%(Added section "#{title.cap_first}"))
420
- end
370
+ $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
371
+ res = $stdin.sysread 1
372
+ puts
373
+ system 'stty cooked'
374
+
375
+ res.chomp!
376
+ res.downcase!
377
+
378
+ return default if res.empty?
379
+
380
+ res =~ /y/i ? true : false
381
+ end
382
+
383
+ ##
384
+ ## @brief Attempt to match a string with an existing view
385
+ ##
386
+ ## @param frag (String) The user-provided string
387
+ ## @param guessed (Boolean) already guessed
388
+ ##
389
+ def guess_view(frag, guessed: false, suggest: false)
390
+ views.each { |view| return view if frag.downcase == view.downcase }
391
+ view = false
392
+ re = frag.split('').join('.*?')
393
+ views.each do |v|
394
+ next unless v =~ /#{re}/i
395
+
396
+ logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
397
+ view = v
398
+ break
399
+ end
400
+ unless view || guessed
401
+ guess = guess_section(frag, guessed: true, suggest: true)
402
+ exit_now! "Did you mean `doing show #{guess}`?" if guess
421
403
 
422
- ##
423
- ## @brief Attempt to match a string with an existing section
424
- ##
425
- ## @param frag (String) The user-provided string
426
- ## @param guessed (Boolean) already guessed and failed
427
- ##
428
- def guess_section(frag, guessed: false)
429
- return 'All' if frag =~ /^all$/i
430
- frag ||= @current_section
431
- sections.each { |section| return section.cap_first if frag.downcase == section.downcase }
432
- section = false
433
- re = frag.split('').join('.*?')
434
- sections.each do |sect|
435
- next unless sect =~ /#{re}/i
436
-
437
- warn "Assuming you meant #{sect}"
438
- section = sect
439
- break
440
- end
441
- unless section || guessed
442
- alt = guess_view(frag, true)
443
- exit_now! "Did you mean `doing view #{alt}`?" if alt
404
+ raise Errors::InvalidView, "Unknown view: #{frag}"
444
405
 
445
- res = yn("Section #{frag} not found, create it", default_response: false)
406
+ end
407
+ view
408
+ end
409
+
410
+ ##
411
+ ## @brief Adds an entry
412
+ ##
413
+ ## @param title (String) The entry title
414
+ ## @param section (String) The section to add to
415
+ ## @param opt (Hash) Additional Options {:date, :note, :back, :timed}
416
+ ##
417
+ def add_item(title, section = nil, opt = {})
418
+ section ||= @config['current_section']
419
+ add_section(section) unless @content.key?(section)
420
+ opt[:date] ||= Time.now
421
+ opt[:note] ||= []
422
+ opt[:back] ||= Time.now
423
+ opt[:timed] ||= false
424
+
425
+ opt[:note] = opt[:note].lines if opt[:note].is_a?(String)
426
+
427
+ title = [title.strip.cap_first]
428
+ title = title.join(' ')
429
+
430
+ if @auto_tag
431
+ title = autotag(title)
432
+ title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty?
433
+ end
446
434
 
447
- if res
448
- add_section(frag.cap_first)
449
- write(@doing_file)
450
- return frag.cap_first
435
+ title.gsub!(/ +/, ' ')
436
+ entry = Item.new(opt[:back], title.strip, section)
437
+ entry.note = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
438
+ items = @content[section][:items]
439
+ if opt[:timed]
440
+ items.reverse!
441
+ items.each_with_index do |i, x|
442
+ next if i.title =~ / @done/
443
+
444
+ items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
445
+ break
446
+ end
447
+ items.reverse!
451
448
  end
452
449
 
453
- exit_now! "Unknown section: #{frag}"
450
+ items.push(entry)
451
+ logger.count(:added)
452
+ logger.debug('Entry added:', %("#{entry.title}" to #{section}))
454
453
  end
455
- section ? section.cap_first : guessed
456
- end
457
-
458
- ##
459
- ## @brief Ask a yes or no question in the terminal
460
- ##
461
- ## @param question (String) The question to ask
462
- ## @param default (Bool) default response if no input
463
- ##
464
- ## @return (Bool) yes or no
465
- ##
466
- def yn(question, default_response: false)
467
- default = default_response ? default_response : 'n'
468
454
 
469
- # if this isn't an interactive shell, answer default
470
- return default.downcase == 'y' unless $stdout.isatty
455
+ ##
456
+ ## @brief Remove items from a list that already exist in @content
457
+ ##
458
+ ## @param items (Array) The items to deduplicate
459
+ ## @param no_overlap (Boolean) Remove items with overlapping time spans
460
+ ##
461
+ def dedup(items, no_overlap = false)
471
462
 
472
- # clear the buffer
473
- if ARGV&.length
474
- ARGV.length.times do
475
- ARGV.shift
463
+ combined = []
464
+ @content.each do |_k, v|
465
+ combined += v[:items]
476
466
  end
477
- end
478
- system 'stty cbreak'
479
-
480
- cw = colors['white']
481
- cbw = colors['boldwhite']
482
- cbg = colors['boldgreen']
483
- cd = colors['default']
484
-
485
- options = if default
486
- default =~ /y/i ? "#{cw}[#{cbg}Y#{cw}/#{cbw}n#{cw}]#{cd}" : "#{cw}[#{cbw}y#{cw}/#{cbg}N#{cw}]#{cd}"
487
- else
488
- "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
489
- end
490
- $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
491
- res = $stdin.sysread 1
492
- puts
493
- system 'stty cooked'
494
-
495
- res.chomp!
496
- res.downcase!
497
467
 
498
- res = default.downcase if res == ''
499
-
500
- res =~ /y/i
501
- end
502
-
503
- ##
504
- ## @brief Attempt to match a string with an existing view
505
- ##
506
- ## @param frag (String) The user-provided string
507
- ## @param guessed (Boolean) already guessed
508
- ##
509
- def guess_view(frag, guessed = false)
510
- views.each { |view| return view if frag.downcase == view.downcase }
511
- view = false
512
- re = frag.split('').join('.*?')
513
- views.each do |v|
514
- next unless v =~ /#{re}/i
515
-
516
- warn "Assuming you meant #{v}"
517
- view = v
518
- break
519
- end
520
- unless view || guessed
521
- alt = guess_section(frag, guessed: true)
522
- if alt
523
- exit_now! "Did you mean `doing show #{alt}`?"
524
- else
525
- exit_now! "Unknown view: #{frag}"
468
+ items.delete_if do |item|
469
+ duped = false
470
+ combined.each do |comp|
471
+ duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
472
+ break if duped
473
+ end
474
+ logger.count(:skipped, level: :debug, message: 'overlapping entry') if duped
475
+ logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped
476
+ duped
526
477
  end
527
478
  end
528
- view
529
- end
530
479
 
531
- ##
532
- ## @brief Adds an entry
533
- ##
534
- ## @param title (String) The entry title
535
- ## @param section (String) The section to add to
536
- ## @param opt (Hash) Additional Options {:date, :note, :back, :timed}
537
- ##
538
- def add_item(title, section = nil, opt = {})
539
- section ||= @current_section
540
- add_section(section) unless @content.has_key?(section)
541
- opt[:date] ||= Time.now
542
- opt[:note] ||= []
543
- opt[:back] ||= Time.now
544
- opt[:timed] ||= false
545
-
546
- opt[:note] = [opt[:note]] if opt[:note].instance_of?(String)
547
-
548
- title = [title.strip.cap_first]
549
- title = title.join(' ')
550
-
551
- if @auto_tag
552
- title = autotag(title)
553
- unless @config['default_tags'].empty?
554
- default_tags = @config['default_tags'].map do |t|
555
- next if t.nil?
556
-
557
- dt = t.sub(/^ *@/, '').chomp
558
- if title =~ /@#{dt}/
559
- ''
560
- else
561
- " @#{dt}"
480
+ ##
481
+ ## @brief Imports external entries
482
+ ##
483
+ ## @param path (String) Path to JSON report file
484
+ ## @param opt (Hash) Additional Options
485
+ ##
486
+ def import(paths, opt = {})
487
+ Plugins.plugins[:import].each do |_, options|
488
+ next unless opt[:type] =~ /^(#{options[:trigger].normalize_trigger})$/i
489
+
490
+ if paths.count.positive?
491
+ paths.each do |path|
492
+ options[:class].import(self, path, options: opt)
562
493
  end
494
+ else
495
+ options[:class].import(self, nil, options: opt)
563
496
  end
564
- default_tags.delete_if { |t| t == '' }
565
- title += default_tags.join(' ')
566
- end
567
- end
568
- title.gsub!(/ +/, ' ')
569
- entry = { 'title' => title.strip, 'date' => opt[:back] }
570
- entry['note'] = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
571
- items = @content[section]['items']
572
- if opt[:timed]
573
- items.reverse!
574
- items.each_with_index do |i, x|
575
- next if i['title'] =~ / @done/
576
-
577
- items[x]['title'] = "#{i['title']} @done(#{opt[:back].strftime('%F %R')})"
578
497
  break
579
498
  end
580
- items.reverse!
581
499
  end
582
- items.push(entry)
583
- @content[section]['items'] = items
584
- @results.push(%(Added "#{entry['title']}" to #{section}))
585
- end
586
500
 
587
- def same_time?(item_a, item_b)
588
- item_a['date'] == item_b['date'] ? get_interval(item_a, formatted: false, record: false) == get_interval(item_b, formatted: false, record: false) : false
589
- end
501
+ ##
502
+ ## @brief Return the content of the last note for a given section
503
+ ##
504
+ ## @param section (String) The section to retrieve from, default
505
+ ## All
506
+ ##
507
+ def last_note(section = 'All')
508
+ section = guess_section(section)
590
509
 
591
- def overlapping_time?(item_a, item_b)
592
- return true if same_time?(item_a, item_b)
510
+ last_item = last_entry({ section: section })
593
511
 
594
- start_a = item_a['date']
595
- interval = get_interval(item_a, formatted: false, record: false)
596
- end_a = interval ? start_a + interval.to_i : start_a
597
- start_b = item_b['date']
598
- interval = get_interval(item_b, formatted: false, record: false)
599
- end_b = interval ? start_b + interval.to_i : start_b
600
- (start_a >= start_b && start_a <= end_b) || (end_a >= start_b && end_a <= end_b) || (start_a < start_b && end_a > end_b)
601
- end
512
+ raise Errors::NoEntryError, 'No entry found' unless last_item
602
513
 
603
- def dedup(items, no_overlap = false)
514
+ logger.log_now(:info, 'Edit note:', last_item.title)
604
515
 
605
- combined = []
606
- @content.each do |_k, v|
607
- combined += v['items']
516
+ note = last_item.note&.to_s || ''
517
+ "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
608
518
  end
609
519
 
610
- items.delete_if do |item|
611
- duped = false
612
- combined.each do |comp|
613
- duped = no_overlap ? overlapping_time?(item, comp) : same_time?(item, comp)
614
- break if duped
520
+ def reset_item(item, resume: false)
521
+ item.date = Time.now
522
+ if resume
523
+ item.tag('done', remove: true)
615
524
  end
616
- # warn "Skipping overlapping entry: #{item['title']}" if duped
617
- duped
525
+ Doing.logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
526
+ item
618
527
  end
619
- end
620
528
 
621
- ##
622
- ## @brief Imports a Timing report
623
- ##
624
- ## @param path (String) Path to JSON report file
625
- ## @param section (String) The section to add to
626
- ## @param opt (Hash) Additional Options
627
- ##
628
- def import_timing(path, opt = {})
629
- section = opt[:section] || @current_section
630
- opt[:no_overlap] ||= false
631
- opt[:autotag] ||= @auto_tag
632
-
633
- add_section(section) unless @content.has_key?(section)
634
-
635
- add_tags = opt[:tag] ? opt[:tag].split(/[ ,]+/).map { |t| t.sub(/^@?/, '@') }.join(' ') : ''
636
- prefix = opt[:prefix] ? opt[:prefix] : '[Timing.app]'
637
- exit_now! "File not found" unless File.exist?(File.expand_path(path))
638
-
639
- data = JSON.parse(IO.read(File.expand_path(path)))
640
- new_items = []
641
- data.each do |entry|
642
- # Only process task entries
643
- next if entry.key?('activityType') && entry['activityType'] != 'Task'
644
- # Only process entries with a start and end date
645
- next unless entry.key?('startDate') && entry.key?('endDate')
646
-
647
- # Round down seconds and convert UTC to local time
648
- start_time = Time.parse(entry['startDate'].sub(/:\d\dZ$/, ':00Z')).getlocal
649
- end_time = Time.parse(entry['endDate'].sub(/:\d\dZ$/, ':00Z')).getlocal
650
- next unless start_time && end_time
651
-
652
- tags = entry['project'].split(/ ▸ /).map {|proj| proj.gsub(/[^a-z0-9]+/i, '').downcase }
653
- title = "#{prefix} "
654
- title += entry.key?('activityTitle') && entry['activityTitle'] != '(Untitled Task)' ? entry['activityTitle'] : 'Working on'
655
- tags.each do |tag|
656
- if title =~ /\b#{tag}\b/i
657
- title.sub!(/\b#{tag}\b/i, "@#{tag}")
529
+ def repeat_item(item, opt = {})
530
+ original = item.dup
531
+ if item.should_finish?
532
+ if item.should_time?
533
+ item.title.tag!('done', value: Time.now.strftime('%F %R'))
658
534
  else
659
- title += " @#{tag}"
535
+ item.title.tag!('done')
660
536
  end
661
537
  end
662
- title = autotag(title) if opt[:autotag]
663
- title += " @done(#{end_time.strftime('%Y-%m-%d %H:%M')})"
664
- title.gsub!(/ +/, ' ')
665
- title.strip!
666
- new_entry = { 'title' => title, 'date' => start_time, 'section' => section }
667
- new_entry['note'] = entry['notes'].split(/\n/).map(&:chomp) if entry.key?('notes')
668
- new_items.push(new_entry)
538
+
539
+ # Remove @done tag
540
+ title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
541
+ section = opt[:in].nil? ? item.section : guess_section(opt[:in])
542
+ @auto_tag = false
543
+
544
+ note = opt[:note] || Note.new
545
+
546
+ if opt[:editor]
547
+ to_edit = title
548
+ to_edit += "\n#{note.to_s}" unless note.empty?
549
+ new_item = fork_editor(to_edit)
550
+ title, note = format_input(new_item)
551
+
552
+ if title.nil? || title.empty?
553
+ logger.debug('Skipped:', 'No content provided')
554
+ return
555
+ end
556
+ end
557
+
558
+ update_item(original, item)
559
+ add_item(title, section, { note: note, back: opt[:date], timed: true })
560
+ write(@doing_file)
669
561
  end
670
- total = new_items.count
671
- new_items = dedup(new_items, opt[:no_overlap])
672
- dups = total - new_items.count
673
- @results.push(%(Skipped #{dups} items with overlapping times)) if dups > 0
674
- @content[section]['items'].concat(new_items)
675
- @results.push(%(Imported #{new_items.count} items to #{section}))
676
- end
677
562
 
678
- ##
679
- ## @brief Return the content of the last note for a given section
680
- ##
681
- ## @param section (String) The section to retrieve from, default
682
- ## All
683
- ##
684
- def last_note(section = 'All')
685
- section = guess_section(section)
686
- if section =~ /^all$/i
687
- combined = { 'items' => [] }
688
- @content.each do |_k, v|
689
- combined['items'] += v['items']
563
+ ##
564
+ ## @brief Restart the last entry
565
+ ##
566
+ ## @param opt (Hash) Additional Options
567
+ ##
568
+ def repeat_last(opt = {})
569
+ opt[:section] ||= 'all'
570
+ opt[:note] ||= []
571
+ opt[:tag] ||= []
572
+ opt[:tag_bool] ||= :and
573
+
574
+ last = last_entry(opt)
575
+ if last.nil?
576
+ logger.debug('Skipped:', 'No previous entry found')
577
+ return
690
578
  end
691
- section = combined['items'].dup.sort_by { |item| item['date'] }.reverse[0]['section']
579
+
580
+ repeat_item(last, opt)
692
581
  end
693
582
 
694
- exit_now! "Section #{section} not found" unless @content.key?(section)
583
+ ##
584
+ ## @brief Get the last entry
585
+ ##
586
+ ## @param opt (Hash) Additional Options
587
+ ##
588
+ def last_entry(opt = {})
589
+ opt[:tag_bool] ||= :and
590
+ opt[:section] ||= @config['current_section']
695
591
 
696
- last_item = @content[section]['items'].dup.sort_by { |item| item['date'] }.reverse[0]
697
- warn "Editing note for #{last_item['title']}"
698
- note = ''
699
- note = last_item['note'].map(&:strip).join("\n") unless last_item['note'].nil?
700
- "#{last_item['title']}\n# EDIT BELOW THIS LINE ------------\n#{note}"
701
- end
592
+ items = filter_items([], opt: opt)
702
593
 
703
- ##
704
- ## @brief Restart the last entry
705
- ##
706
- ## @param opt (Hash) Additional Options
707
- ##
708
- def restart_last(opt = {})
709
- opt[:section] ||= 'all'
710
- opt[:note] ||= []
711
- opt[:tag] ||= []
712
- opt[:tag_bool] ||= :and
713
-
714
- last = last_entry(opt)
715
- if last.nil?
716
- @results.push(%(No previous entry found))
717
- return
718
- end
719
- unless last.has_tags?(['done'], 'ALL')
720
- new_item = last.dup
721
- new_item['title'] += " @done(#{Time.now.strftime('%F %R')})"
722
- update_item(last, new_item)
723
- end
724
- # Remove @done tag
725
- title = last['title'].sub(/\s*@done(\(.*?\))?/, '').chomp
726
- section = opt[:in].nil? ? last['section'] : guess_section(opt[:in])
727
- @auto_tag = false
728
- add_item(title, section, { note: opt[:note], back: opt[:date], timed: true })
729
- write(@doing_file)
730
- end
594
+ logger.debug('Filtered:', "Parameters matched #{items.count} entries")
731
595
 
732
- ##
733
- ## @brief Get the last entry
734
- ##
735
- ## @param opt (Hash) Additional Options
736
- ##
737
- def last_entry(opt = {})
738
- opt[:tag_bool] ||= :and
739
- opt[:section] ||= @current_section
740
-
741
- sec_arr = []
742
-
743
- if opt[:section].nil?
744
- sec_arr = [@current_section]
745
- elsif opt[:section].instance_of?(String)
746
- if opt[:section] =~ /^all$/i
747
- combined = { 'items' => [] }
748
- @content.each do |_k, v|
749
- combined['items'] += v['items']
750
- end
751
- items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
752
- sec_arr.push(items[0]['section'])
596
+ if opt[:interactive]
597
+ last_entry = choose_from_items(items, {
598
+ menu: true,
599
+ header: '',
600
+ prompt: 'Select an entry > ',
601
+ multiple: false,
602
+ sort: false,
603
+ show_if_single: true
604
+ }, include_section: opt[:section] =~ /^all$/i )
753
605
  else
754
- sec_arr = [guess_section(opt[:section])]
606
+ last_entry = items.max_by { |item| item.date }
755
607
  end
608
+
609
+ last_entry
756
610
  end
757
611
 
758
- all_items = []
759
- sec_arr.each do |section|
760
- all_items.concat(@content[section]['items'].dup) if @content.key?(section)
612
+ ##
613
+ ## @brief Generate a menu of options and allow user selection
614
+ ##
615
+ ## @return (String) The selected option
616
+ ##
617
+ def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
618
+ return nil unless $stdout.isatty
619
+
620
+ fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
621
+ # fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
622
+ fzf_args << %(--prompt "#{prompt}")
623
+ fzf_args << '--multi' if multiple
624
+ header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
625
+ fzf_args << %(--header "#{header}")
626
+ options.sort! if sorted
627
+ res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
628
+ return false if res.strip.size.zero?
629
+
630
+ res
761
631
  end
762
632
 
763
- if opt[:tag]&.length
764
- all_items.select! { |item| item.has_tags?(opt[:tag], opt[:tag_bool]) }
765
- elsif opt[:search]&.length
766
- all_items.select! { |item| item.matches_search?(opt[:search]) }
633
+ def all_tags(items, opt: {})
634
+ all_tags = []
635
+ items.each { |item| all_tags.concat(item.tags).uniq! }
636
+ all_tags.sort
767
637
  end
768
638
 
769
- all_items.max_by { |item| item['date'] }
770
- end
639
+ def tag_groups(items, opt: {})
640
+ all_items = filter_items(items, opt: opt)
641
+ tags = all_tags(all_items, opt: {})
642
+ tag_groups = {}
643
+ tags.each do |tag|
644
+ tag_groups[tag] ||= []
645
+ tag_groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
646
+ end
771
647
 
772
- ##
773
- ## @brief Generate a menu of options and allow user selection
774
- ##
775
- ## @return (String) The selected option
776
- ##
777
- def choose_from(options, prompt: 'Make a selection: ', multiple: false, fzf_args: [])
778
- fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
779
- fzf_args << '-1'
780
- fzf_args << %(--prompt "#{prompt}")
781
- fzf_args << '--multi' if multiple
782
- header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
783
- fzf_args << %(--header "#{header}")
784
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
785
- return false if res.strip.size.zero?
786
-
787
- res
788
- end
648
+ tag_groups
649
+ end
650
+
651
+ ##
652
+ ## @brief Filter items based on search criteria
653
+ ##
654
+ ## @param items (Array) The items to filter (if empty, filters all items)
655
+ ## @param opt (Hash) The filter parameters
656
+ ##
657
+ ## Available filter options in opt object
658
+ ##
659
+ ## - +:section+ (String)
660
+ ## - +:unfinished+ (Boolean)
661
+ ## - +:tag+ (Array or comma-separated string)
662
+ ## - +:tag_bool+ (:and, :or, :not)
663
+ ## - +:search+ (string, optional regex with //)
664
+ ## - +:date_filter+ (Array[(Time)start, (Time)end])
665
+ ## - +:only_timed+ (Boolean)
666
+ ## - +:before+ (Date/Time string, unparsed)
667
+ ## - +:after+ (Date/Time string, unparsed)
668
+ ## - +:today+ (Boolean)
669
+ ## - +:yesterday+ (Boolean)
670
+ ## - +:count+ (Number to return)
671
+ ## - +:age+ (String, 'old' or 'new')
672
+ ##
673
+ def filter_items(items = [], opt: {})
674
+ if items.nil? || items.empty?
675
+ section = opt[:section] ? guess_section(opt[:section]) : 'All'
676
+
677
+ items = if section =~ /^all$/i
678
+ @content.each_with_object([]) { |(_k, v), arr| arr.concat(v[:items].dup) }
679
+ else
680
+ @content[section][:items].dup
681
+ end
682
+ end
789
683
 
790
- ##
791
- ## @brief Display an interactive menu of entries
792
- ##
793
- ## @param opt (Hash) Additional options
794
- ##
795
- def interactive(opt = {})
796
- fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
684
+ items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
685
+ filtered_items = items.select do |item|
686
+ keep = true
687
+ finished = opt[:unfinished] && item.tags?('done', :and)
688
+ keep = false if finished
797
689
 
798
- section = opt[:section] ? guess_section(opt[:section]) : 'All'
690
+ if keep && opt[:tag]
691
+ opt[:tag_bool] ||= :and
692
+ tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
693
+ keep = false unless tag_match
694
+ end
799
695
 
696
+ if keep && opt[:search]
697
+ search_match = opt[:search].nil? || opt[:search].empty? ? true : item.search(opt[:search])
698
+ keep = false unless search_match
699
+ end
800
700
 
801
- if section =~ /^all$/i
802
- combined = { 'items' => [] }
803
- @content.each do |_k, v|
804
- combined['items'] += v['items']
805
- end
806
- items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
807
- else
808
- items = @content[section]['items']
809
- end
701
+ if keep && opt[:date_filter]&.length == 2
702
+ start_date = opt[:date_filter][0]
703
+ end_date = opt[:date_filter][1]
810
704
 
705
+ in_date_range = if end_date
706
+ item.date >= start_date && item.date <= end_date
707
+ else
708
+ item.date.strftime('%F') == start_date.strftime('%F')
709
+ end
710
+ keep = false unless in_date_range
711
+ end
811
712
 
812
- options = items.map.with_index do |item, i|
813
- out = [
814
- i,
815
- ') ',
816
- item['date'],
817
- ' | ',
818
- item['title']
819
- ]
820
- if section =~ /^all/i
821
- out.concat([
822
- ' (',
823
- item['section'],
824
- ') '
825
- ])
826
- end
827
- out.join('')
828
- end
829
- fzf_args = [
830
- %(--header="Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"),
831
- %(--prompt="Select entries to act on > "),
832
- '-1',
833
- '-m',
834
- '--bind ctrl-a:select-all',
835
- %(-q "#{opt[:query]}")
836
- ]
837
- if !opt[:menu]
838
- exit_now! "Can't skip menu when no query is provided" unless opt[:query]
839
-
840
- fzf_args.concat([%(--filter="#{opt[:query]}"), '--no-sort'])
841
- end
713
+ keep = false if keep && opt[:only_timed] && !item.interval
842
714
 
843
- res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
844
- selected = []
845
- res.split(/\n/).each do |item|
846
- idx = item.match(/^(\d+)\)/)[1].to_i
847
- selected.push(items[idx])
848
- end
715
+ if keep && opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
716
+ keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
717
+ end
849
718
 
850
- if selected.empty?
851
- @results.push("No selection")
852
- return
853
- end
719
+ if keep && opt[:before]
720
+ time_string = opt[:before]
721
+ cutoff = chronify(time_string, guess: :begin)
722
+ keep = cutoff && item.date <= cutoff
723
+ end
854
724
 
855
- actions = %i[editor delete tag flag finish cancel tag archive output save_to]
856
- has_action = false
857
- actions.each do |a|
858
- if opt[a]
859
- has_action = true
860
- break
861
- end
862
- end
725
+ if keep && opt[:after]
726
+ time_string = opt[:after]
727
+ cutoff = chronify(time_string, guess: :end)
728
+ keep = cutoff && item.date >= cutoff
729
+ end
863
730
 
864
- unless has_action
865
- choice = choose_from([
866
- 'add tag',
867
- 'remove tag',
868
- 'cancel',
869
- 'delete',
870
- 'finish',
871
- 'flag',
872
- 'archive',
873
- 'move',
874
- 'edit',
875
- 'output formatted'
876
- ],
877
- prompt: 'What do you want to do with the selected items? > ',
878
- multiple: true,
879
- fzf_args: ['--height=60%', '--tac', '--no-sort'])
880
- return unless choice
881
-
882
- to_do = choice.strip.split(/\n/)
883
- to_do.each do |action|
884
- case action
885
- when /(add|remove) tag/
886
- type = action =~ /^add/ ? 'add' : 'remove'
887
- if opt[:tag]
888
- exit_now! "'add tag' and 'remove tag' can not be used together"
889
- end
890
- print "#{colors['yellow']}Tag to #{type}: #{colors['reset']}"
891
- tag = STDIN.gets
892
- return if tag =~ /^ *$/
893
- opt[:tag] = tag.strip.sub(/^@/, '')
894
- opt[:remove] = true if type == 'remove'
895
- when /output formatted/
896
- output_format = choose_from(%w[doing taskpaper json timeline html csv].sort, prompt: 'Which output format? > ', fzf_args: ['--height=60%', '--tac', '--no-sort'])
897
- return if tag =~ /^ *$/
898
- opt[:output] = output_format.strip
899
- res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
900
- if res
901
- print "#{colors['yellow']}File path/name: #{colors['reset']}"
902
- filename = STDIN.gets.strip
903
- return if filename.empty?
904
- opt[:save_to] = filename
905
- end
906
- when /archive/
907
- opt[:archive] = true
908
- when /delete/
909
- opt[:delete] = true
910
- when /edit/
911
- opt[:editor] = true
912
- when /finish/
913
- opt[:finish] = true
914
- when /cancel/
915
- opt[:cancel] = true
916
- when /move/
917
- section = choose_section.strip
918
- opt[:move] = section.strip unless section =~ /^ *$/
919
- when /flag/
920
- opt[:flag] = true
731
+ if keep && opt[:today]
732
+ keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
733
+ elsif keep && opt[:yesterday]
734
+ keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
921
735
  end
922
- end
923
- end
924
736
 
925
- if opt[:delete]
926
- res = opt[:force] ? true : yn("Delete #{selected.size} items?", default_response: 'y')
927
- if res
928
- selected.each { |item| delete_item(item) }
929
- write(@doing_file)
737
+ keep
930
738
  end
931
- return
932
- end
739
+ count = opt[:count] && opt[:count].positive? ? opt[:count] : filtered_items.length
933
740
 
934
- if opt[:flag]
935
- tag = @config['marker_tag'] || 'flagged'
936
- selected.map! do |item|
937
- if opt[:remove]
938
- untag_item(item, tag)
939
- else
940
- tag_item(item, tag, date: false)
941
- end
741
+ if opt[:age] =~ /^o/i
742
+ filtered_items.slice(0, count).reverse
743
+ else
744
+ filtered_items.reverse.slice(0, count)
942
745
  end
943
- end
944
746
 
945
- if opt[:finish] || opt[:cancel]
946
- tag = 'done'
947
- selected.map! do |item|
948
- if opt[:remove]
949
- untag_item(item, tag)
950
- else
951
- tag_item(item, tag, date: !opt[:cancel])
952
- end
953
- end
954
747
  end
955
748
 
956
- if opt[:tag]
957
- tag = opt[:tag]
958
- selected.map! do |item|
959
- if opt[:remove]
960
- untag_item(item, tag)
961
- else
962
- tag_item(item, tag, date: false)
963
- end
749
+ ##
750
+ ## @brief Display an interactive menu of entries
751
+ ##
752
+ ## @param opt (Hash) Additional options
753
+ ##
754
+ def interactive(opt = {})
755
+ section = opt[:section] ? guess_section(opt[:section]) : 'All'
756
+ opt[:query] = opt[:search] if opt[:search] && !opt[:query]
757
+ opt[:multiple] = true
758
+ items = filter_items([], opt: { section: section, search: opt[:search] })
759
+
760
+ selection = choose_from_items(items, opt, include_section: section =~ /^all$/i)
761
+
762
+ if selection.empty?
763
+ logger.debug('Skipped:', 'No selection')
764
+ return
964
765
  end
965
- end
966
766
 
967
- if opt[:archive] || opt[:move]
968
- section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
969
- selected.map! {|item| move_item(item, section) }
767
+ act_on(selection, opt)
970
768
  end
971
769
 
972
- write(@doing_file)
770
+ def choose_from_items(items, opt = {}, include_section: false)
771
+ return nil unless $stdout.isatty
973
772
 
974
- if opt[:editor]
773
+ return nil unless items.count.positive?
975
774
 
976
- editable_items = []
775
+ opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
776
+ opt[:prompt] ||= "Select entries to act on > "
977
777
 
978
- selected.each do |item|
979
- editable = "#{item['date']} | #{item['title']}"
980
- old_note = item['note'] ? item['note'].map(&:strip).join("\n") : nil
981
- editable += "\n#{old_note}" unless old_note.nil?
982
- editable_items << editable
778
+ pad = items.length.to_s.length
779
+ options = items.map.with_index do |item, i|
780
+ out = [
781
+ format("%#{pad}d", i),
782
+ ') ',
783
+ format('%13s', item.date.relative_date),
784
+ ' | ',
785
+ item.title
786
+ ]
787
+ if include_section
788
+ out.concat([
789
+ ' (',
790
+ item.section,
791
+ ') '
792
+ ])
793
+ end
794
+ out.join('')
983
795
  end
984
- divider = "\n-----------\n"
985
- input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place"
986
796
 
987
- new_items = fork_editor(input).split(/#{divider}/)
797
+ fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
988
798
 
989
- new_items.each_with_index do |new_item, i|
799
+ fzf_args = [
800
+ %(--header="#{opt[:header]}"),
801
+ %(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"),
802
+ opt[:multiple] ? '--multi' : '--no-multi',
803
+ '-0',
804
+ '--bind ctrl-a:select-all',
805
+ %(-q "#{opt[:query]}")
806
+ ]
807
+ fzf_args.push('-1') unless opt[:show_if_single]
990
808
 
991
- input_lines = new_item.split(/[\n\r]+/).delete_if {|line| line =~ /^#/ || line =~ /^\s*$/ }
992
- title = input_lines[0]&.strip
809
+ unless opt[:menu]
810
+ raise Errors::InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query]
993
811
 
994
- if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
995
- delete_item(selected[i])
996
- else
997
- note = input_lines.length > 1 ? input_lines[1..-1] : []
812
+ fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort'])
813
+ end
998
814
 
999
- note.map!(&:strip)
1000
- note.delete_if { |line| line =~ /^\s*$/ || line =~ /^#/ }
815
+ res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
816
+ selected = []
817
+ res.split(/\n/).each do |item|
818
+ idx = item.match(/^ *(\d+)\)/)[1].to_i
819
+ selected.push(items[idx])
820
+ end
1001
821
 
1002
- date = title.match(/^([\d\-: ]+) \| /)[1]
1003
- title.sub!(/^([\d\-: ]+) \| /, '')
822
+ opt[:multiple] ? selected : selected[0]
823
+ end
1004
824
 
1005
- item = selected[i].dup
1006
- item['title'] = title
1007
- item['note'] = note
1008
- item['date'] = Time.parse(date) || selected[i]['date']
1009
- update_item(selected[i], item)
825
+ def act_on(items, opt = {})
826
+ actions = %i[editor delete tag flag finish cancel archive output save_to]
827
+ has_action = false
828
+ actions.each do |a|
829
+ if opt[a]
830
+ has_action = true
831
+ break
1010
832
  end
1011
833
  end
1012
834
 
1013
- write(@doing_file)
1014
- end
835
+ unless has_action
836
+ actions = [
837
+ 'add tag',
838
+ 'remove tag',
839
+ 'cancel',
840
+ 'delete',
841
+ 'finish',
842
+ 'flag',
843
+ 'archive',
844
+ 'move',
845
+ 'edit',
846
+ 'output formatted'
847
+ ]
848
+
849
+ actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
850
+
851
+ choice = choose_from(actions,
852
+ prompt: 'What do you want to do with the selected items? > ',
853
+ multiple: true,
854
+ sorted: false,
855
+ fzf_args: ['--height=60%', '--tac', '--no-sort'])
856
+ return unless choice
857
+
858
+ to_do = choice.strip.split(/\n/)
859
+ to_do.each do |action|
860
+ case action
861
+ when /resume/
862
+ opt[:resume] = true
863
+ when /reset/
864
+ opt[:reset] = true
865
+ when /(add|remove) tag/
866
+ type = action =~ /^add/ ? 'add' : 'remove'
867
+ raise Errors::InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
868
+
869
+ print "#{Color.yellow}Tag to #{type}: #{Color.reset}"
870
+ tag = $stdin.gets
871
+ next if tag =~ /^ *$/
872
+
873
+ opt[:tag] = tag.strip.sub(/^@/, '')
874
+ opt[:remove] = true if type == 'remove'
875
+ when /output formatted/
876
+ output_format = choose_from(Plugins.available_plugins(type: :export).sort,
877
+ prompt: 'Which output format? > ',
878
+ fzf_args: ['--height=60%', '--tac', '--no-sort'])
879
+ next if tag =~ /^ *$/
880
+
881
+ unless output_format
882
+ raise Errors::UserCancelled, 'Cancelled'
883
+ end
884
+
885
+ opt[:output] = output_format.strip
886
+ res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
887
+ if res
888
+ print "#{Color.yellow}File path/name: #{Color.reset}"
889
+ filename = $stdin.gets.strip
890
+ next if filename.empty?
1015
891
 
1016
- if opt[:output]
1017
- selected.map! do |item|
1018
- item['title'] = "#{item['title']} @project(#{item['section']})"
1019
- item
892
+ opt[:save_to] = filename
893
+ end
894
+ when /archive/
895
+ opt[:archive] = true
896
+ when /delete/
897
+ opt[:delete] = true
898
+ when /edit/
899
+ opt[:editor] = true
900
+ when /finish/
901
+ opt[:finish] = true
902
+ when /cancel/
903
+ opt[:cancel] = true
904
+ when /move/
905
+ section = choose_section.strip
906
+ opt[:move] = section.strip unless section =~ /^ *$/
907
+ when /flag/
908
+ opt[:flag] = true
909
+ end
910
+ end
1020
911
  end
1021
912
 
1022
- @content = { 'Export' => { 'original' => 'Export:', 'items' => selected } }
1023
- options = { section: 'Export' }
913
+ if opt[:resume] || opt[:reset]
914
+ if items.count > 1
915
+ logger.error('Error:', 'resume and restart can only be used on a single entry')
916
+ else
917
+ item = items[0]
918
+ if opt[:resume] && !opt[:reset]
919
+ repeat_item(item, { editor: opt[:editor] })
920
+ elsif opt[:reset]
921
+ if item.tags?('done', :and) && !opt[:resume]
922
+ res = opt[:force] ? true : yn('Remove @done tag?', default_response: 'y')
923
+ else
924
+ res = opt[:resume]
925
+ end
926
+ update_item(item, reset_item(item, resume: res))
927
+ end
928
+ write(@doing_file)
929
+ end
930
+ return
931
+ end
1024
932
 
1025
- case opt[:output]
1026
- when /doing/
1027
- options[:template] = '- %date | %title%note'
1028
- when /taskpaper/
1029
- options[:template] = '- %title @date(%date)%note'
1030
- else
1031
- options[:output] = opt[:output]
933
+ if opt[:delete]
934
+ res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y')
935
+ if res
936
+ items.each { |item| delete_item(item) }
937
+ write(@doing_file)
938
+ end
939
+ return
1032
940
  end
1033
941
 
1034
- output = list_section(options)
942
+ if opt[:flag]
943
+ tag = @config['marker_tag'] || 'flagged'
944
+ items.map! do |item|
945
+ tag_item(item, tag, date: false, remove: opt[:remove])
946
+ end
947
+ end
1035
948
 
1036
- if opt[:save_to]
1037
- file = File.expand_path(opt[:save_to])
1038
- if File.exist?(file)
1039
- # Create a backup copy for the undo command
1040
- FileUtils.cp(file, "#{file}~")
949
+ if opt[:finish] || opt[:cancel]
950
+ tag = 'done'
951
+ items.map! do |item|
952
+ if item.should_finish?
953
+ should_date = !opt[:cancel] && item.should_time?
954
+ tag_item(item, tag, date: should_date, remove: opt[:remove])
955
+ end
1041
956
  end
957
+ end
1042
958
 
1043
- File.open(file, 'w+') do |f|
1044
- f.puts output
959
+ if opt[:tag]
960
+ tag = opt[:tag]
961
+ items.map! do |item|
962
+ tag_item(item, tag, date: false, remove: opt[:remove])
1045
963
  end
964
+ end
1046
965
 
1047
- @results.push("Export saved to #{file}")
1048
- else
1049
- puts output
966
+ if opt[:archive] || opt[:move]
967
+ section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
968
+ items.map! {|item| move_item(item, section) }
1050
969
  end
1051
- end
1052
- end
1053
970
 
1054
- ##
1055
- ## @brief Tag the last entry or X entries
1056
- ##
1057
- ## @param opt (Hash) Additional Options
1058
- ##
1059
- def tag_last(opt = {})
1060
- opt[:section] ||= nil
1061
- opt[:count] ||= 1
1062
- opt[:archive] ||= false
1063
- opt[:tags] ||= ['done']
1064
- opt[:sequential] ||= false
1065
- opt[:date] ||= false
1066
- opt[:remove] ||= false
1067
- opt[:autotag] ||= false
1068
- opt[:back] ||= false
1069
- opt[:took] ||= nil
1070
- opt[:unfinished] ||= false
1071
-
1072
- sec_arr = []
1073
-
1074
- if opt[:section].nil?
1075
- if opt[:search] || opt[:tag]
1076
- sec_arr = sections
1077
- else
1078
- sec_arr = [@current_section]
1079
- end
1080
- elsif opt[:section].instance_of?(String)
1081
- if opt[:section] =~ /^all$/i
1082
- if opt[:count] == 1
1083
- combined = { 'items' => [] }
1084
- @content.each do |_k, v|
1085
- combined['items'] += v['items']
1086
- end
1087
- items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
1088
- sec_arr.push(items[0]['section'])
1089
- elsif opt[:count] > 1
1090
- if opt[:search] || opt[:tag]
1091
- sec_arr = sections
1092
- else
1093
- exit_now! 'A count greater than one requires a section to be specified'
1094
- end
1095
- else
1096
- sec_arr = sections
1097
- end
1098
- else
1099
- sec_arr = [guess_section(opt[:section])]
1100
- end
1101
- end
971
+ write(@doing_file)
1102
972
 
1103
- sec_arr.each do |section|
1104
- if @content.key?(section)
973
+ if opt[:editor]
1105
974
 
1106
- items = @content[section]['items'].dup.sort_by { |item| item['date'] }.reverse
1107
- idx = 0
1108
- done_date = Time.now
1109
- count = (opt[:count]).zero? ? items.length : opt[:count]
1110
- items.map! do |item|
1111
- break if idx == count
1112
- finished = opt[:unfinished] && item.has_tags?('done', :and)
1113
- tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.has_tags?(opt[:tag], opt[:tag_bool])
1114
- search_match = opt[:search].nil? || opt[:search].empty? ? true : item.matches_search?(opt[:search])
1115
-
1116
- if tag_match && search_match && !finished
1117
- if opt[:autotag]
1118
- new_title = autotag(item['title']) if @auto_tag
1119
- if new_title == item['title']
1120
- @results.push(%(Autotag: No changes))
1121
- else
1122
- @results.push("Tags updated: #{new_title}")
1123
- item['title'] = new_title
1124
- end
1125
- else
1126
- if opt[:sequential]
1127
- next_entry = next_item(item)
975
+ editable_items = []
1128
976
 
1129
- if next_entry.nil?
1130
- done_date = Time.now
1131
- else
1132
- done_date = next_entry['date'] - 60
1133
- end
1134
- elsif opt[:took]
1135
- if item['date'] + opt[:took] > Time.now
1136
- item['date'] = Time.now - opt[:took]
1137
- done_date = Time.now
1138
- else
1139
- done_date = item['date'] + opt[:took]
1140
- end
1141
- elsif opt[:back]
1142
- if opt[:back].is_a? Integer
1143
- done_date = item['date'] + opt[:back]
1144
- else
1145
- done_date = item['date'] + (opt[:back] - item['date'])
1146
- end
1147
- else
1148
- done_date = Time.now
1149
- end
977
+ items.each do |item|
978
+ editable = "#{item.date} | #{item.title}"
979
+ old_note = item.note ? item.note.to_s : nil
980
+ editable += "\n#{old_note}" unless old_note.nil?
981
+ editable_items << editable
982
+ end
983
+ divider = "\n-----------\n"
984
+ input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place"
1150
985
 
1151
- title = item['title']
1152
- opt[:tags].each do |tag|
1153
- tag = tag.strip
1154
- if opt[:remove]
1155
- if title =~ /@#{tag}\b/
1156
- title.gsub!(/(^| )@#{tag}(\([^)]*\))?/, '')
1157
- @results.push(%(Removed @#{tag}: "#{title}" in #{section}))
1158
- end
1159
- elsif title !~ /@#{tag}/
1160
- title.chomp!
1161
- title += if opt[:date]
1162
- " @#{tag}(#{done_date.strftime('%F %R')})"
1163
- else
1164
- " @#{tag}"
1165
- end
1166
- @results.push(%(Added @#{tag}: "#{title}" in #{section}))
1167
- end
1168
- end
1169
- item['title'] = title
1170
- end
986
+ new_items = fork_editor(input).split(/#{divider}/)
1171
987
 
1172
- idx += 1
1173
- end
988
+ new_items.each_with_index do |new_item, i|
1174
989
 
1175
- item
1176
- end
990
+ input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
991
+ title = input_lines[0]&.strip
1177
992
 
1178
- @content[section]['items'] = items
1179
-
1180
- if opt[:archive] && section != 'Archive' && (opt[:count]).positive?
1181
- # concat [count] items from [section] and archive section
1182
- archived = @content[section]['items'][0..opt[:count] - 1].map do |i|
1183
- i['title'].sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{i['section']})")
1184
- i
1185
- end.concat(@content['Archive']['items'])
1186
- # slice [count] items off of [section] items
1187
- @content[opt[:section]]['items'] = @content[opt[:section]]['items'][opt[:count]..-1]
1188
- # overwrite archive section with concatenated array
1189
- @content['Archive']['items'] = archived
1190
- # log it
1191
- result = opt[:count] == 1 ? '1 entry' : "#{opt[:count]} entries"
1192
- @results.push("Archived #{result} from #{section}")
1193
- elsif opt[:archive] && (opt[:count]).zero?
1194
- @results.push('Archiving is skipped when operating on all entries') if (opt[:count]).zero?
1195
- end
1196
- else
1197
- exit_now! "Section not found: #{section}"
1198
- end
1199
- end
1200
-
1201
- write(@doing_file)
1202
- end
993
+ if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
994
+ delete_item(items[i])
995
+ else
996
+ note = input_lines.length > 1 ? input_lines[1..-1] : []
1203
997
 
1204
- def move_item(item, section)
1205
- old_section = item['section']
1206
- new_item = item.dup
1207
- new_item['section'] = section
998
+ note.map!(&:strip)
999
+ note.delete_if(&:ignore?)
1208
1000
 
1209
- section_items = @content[old_section]['items']
1210
- section_items.delete(item)
1211
- @content[old_section]['items'] = section_items
1001
+ date = title.match(/^([\d\-: ]+) \| /)[1]
1002
+ title.sub!(/^([\d\-: ]+) \| /, '')
1212
1003
 
1213
- archive_items = @content[section]['items']
1214
- archive_items.push(new_item)
1215
- # archive_items = archive_items.sort_by { |item| item['date'] }
1216
- @content[section]['items'] = archive_items
1004
+ item = items[i]
1005
+ item.title = title
1006
+ item.note = note
1007
+ item.date = Time.parse(date) || items[i].date
1008
+ end
1009
+ end
1217
1010
 
1218
- @results.push("Entry moved to #{section}: #{new_item['title']}")
1219
- return new_item
1220
- end
1011
+ write(@doing_file)
1012
+ end
1221
1013
 
1222
- ##
1223
- ## @brief Get next item in the index
1224
- ##
1225
- ## @param old_item
1226
- ##
1227
- def next_item(old_item)
1228
- combined = { 'items' => [] }
1229
- @content.each do |_k, v|
1230
- combined['items'] += v['items']
1231
- end
1232
- items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
1233
- idx = items.index(old_item)
1014
+ if opt[:output]
1015
+ items.map! do |item|
1016
+ item.title = "#{item.title} @project(#{item.section})"
1017
+ item
1018
+ end
1234
1019
 
1235
- if idx > 0
1236
- items[idx - 1]
1237
- else
1238
- nil
1239
- end
1240
- end
1020
+ @content = { 'Export' => { :original => 'Export:', :items => items } }
1021
+ options = { section: 'Export' }
1241
1022
 
1242
- ##
1243
- ## @brief Delete an item from the index
1244
- ##
1245
- ## @param old_item
1246
- ##
1247
- def delete_item(old_item)
1248
- section = old_item['section']
1249
1023
 
1250
- section_items = @content[section]['items']
1251
- deleted = section_items.delete(old_item)
1252
- @results.push("Entry deleted: #{deleted['title']}")
1253
- @content[section]['items'] = section_items
1254
- end
1024
+ if opt[:output] =~ /doing/
1025
+ options[:output] = 'template'
1026
+ options[:template] = '- %date | %title%note'
1027
+ else
1028
+ options[:output] = opt[:output]
1029
+ options[:template] = opt[:template] || nil
1030
+ end
1255
1031
 
1256
- ##
1257
- ## @brief Remove a tag on an item from the index
1258
- ##
1259
- ## @param old_item (Item) The item to tag
1260
- ## @param tag (string) The tag to remove
1261
- ##
1262
- def untag_item(old_item, tags)
1263
- title = old_item['title'].dup
1264
- if tags.is_a? ::String
1265
- tags = tags.split(/ *, */).map {|t| t.strip.gsub(/\*/,'[^ (]*') }
1266
- end
1032
+ output = list_section(options)
1267
1033
 
1268
- tags.each do |tag|
1269
- if title =~ /@#{tag}/
1270
- title.chomp!
1271
- title.gsub!(/ +@#{tag}(\(.*?\))?/, '')
1272
- new_item = old_item.dup
1273
- new_item['title'] = title
1274
- update_item(old_item, new_item)
1275
- return new_item
1276
- else
1277
- @results.push(%(Item isn't tagged @#{tag}: "#{title}" in #{old_item['section']}))
1278
- return old_item
1279
- end
1280
- end
1281
- end
1034
+ if opt[:save_to]
1035
+ file = File.expand_path(opt[:save_to])
1036
+ if File.exist?(file)
1037
+ # Create a backup copy for the undo command
1038
+ FileUtils.cp(file, "#{file}~")
1039
+ end
1282
1040
 
1283
- ##
1284
- ## @brief Tag an item from the index
1285
- ##
1286
- ## @param old_item (Item) The item to tag
1287
- ## @param tag (string) The tag to apply
1288
- ## @param date (Boolean) Include timestamp?
1289
- ##
1290
- def tag_item(old_item, tags, remove: false, date: false)
1291
- title = old_item['title'].dup
1292
- if tags.is_a? ::String
1293
- tags = tags.split(/ *, */).map(&:strip)
1294
- end
1041
+ File.open(file, 'w+') do |f|
1042
+ f.puts output
1043
+ end
1295
1044
 
1296
- done_date = Time.now
1297
- tags.each do |tag|
1298
- if title !~ /@#{tag}/
1299
- title.chomp!
1300
- if date
1301
- title += " @#{tag}(#{done_date.strftime('%F %R')})"
1045
+ logger.warn('File written:', file)
1302
1046
  else
1303
- title += " @#{tag}"
1047
+ Doing::Pager.page output
1304
1048
  end
1305
- new_item = old_item.dup
1306
- new_item['title'] = title
1307
- update_item(old_item, new_item)
1308
- return new_item
1309
- else
1310
- @results.push(%(Item already @#{tag}: "#{title}" in #{old_item['section']}))
1311
- return old_item
1312
1049
  end
1313
1050
  end
1314
- end
1315
1051
 
1316
- ##
1317
- ## @brief Update an item in the index with a modified item
1318
- ##
1319
- ## @param old_item The old item
1320
- ## @param new_item The new item
1321
- ##
1322
- def update_item(old_item, new_item)
1323
- section = old_item['section']
1052
+ ##
1053
+ ## @brief Tag an item from the index
1054
+ ##
1055
+ ## @param item (Item) The item to tag
1056
+ ## @param tags (string) The tag to apply
1057
+ ## @param remove (Boolean) remove tags
1058
+ ## @param date (Boolean) Include timestamp?
1059
+ ##
1060
+ def tag_item(item, tags, remove: false, date: false)
1061
+ added = []
1062
+ removed = []
1324
1063
 
1325
- section_items = @content[section]['items']
1326
- s_idx = section_items.index(old_item)
1064
+ tags = tags.to_tags if tags.is_a? ::String
1327
1065
 
1328
- section_items[s_idx] = new_item
1329
- @results.push("Entry updated: #{section_items[s_idx]['title']}")
1330
- @content[section]['items'] = section_items
1331
- end
1066
+ done_date = Time.now
1332
1067
 
1333
- ##
1334
- ## @brief Edit the last entry
1335
- ##
1336
- ## @param section (String) The section, default "All"
1337
- ##
1338
- def edit_last(section: 'All', options: {})
1339
- section = guess_section(section)
1068
+ tags.each do |tag|
1069
+ bool = remove ? :and : :not
1070
+ if item.tags?(tag, bool)
1071
+ item.tag(tag, remove: remove, value: date ? done_date.strftime('%F %R') : nil)
1072
+ remove ? removed.push(tag) : added.push(tag)
1073
+ end
1074
+ end
1340
1075
 
1341
- if section =~ /^all$/i
1342
- items = []
1343
- @content.each do |_k, v|
1344
- items.concat(v['items'])
1076
+ log_change(tags_added: added, tags_removed: removed, count: 1)
1077
+
1078
+ item
1079
+ end
1080
+
1081
+ ##
1082
+ ## @brief Tag the last entry or X entries
1083
+ ##
1084
+ ## @param opt (Hash) Additional Options
1085
+ ##
1086
+ def tag_last(opt = {})
1087
+ opt[:count] ||= 1
1088
+ opt[:archive] ||= false
1089
+ opt[:tags] ||= ['done']
1090
+ opt[:sequential] ||= false
1091
+ opt[:date] ||= false
1092
+ opt[:remove] ||= false
1093
+ opt[:autotag] ||= false
1094
+ opt[:back] ||= false
1095
+ opt[:unfinished] ||= false
1096
+ opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
1097
+
1098
+ items = filter_items([], opt: opt)
1099
+
1100
+ logger.info('Skipped:', 'no items matched your search') if items.empty?
1101
+
1102
+ if opt[:interactive]
1103
+ items = choose_from_items(items, {
1104
+ menu: true,
1105
+ header: '',
1106
+ prompt: 'Select entries to tag > ',
1107
+ multiple: true,
1108
+ sort: true,
1109
+ show_if_single: true
1110
+ }, include_section: opt[:section] =~ /^all$/i )
1111
+
1112
+ return if items.nil?
1345
1113
  end
1346
- # section = combined['items'].dup.sort_by { |item| item['date'] }.reverse[0]['section']
1347
- else
1348
- items = @content[section]['items']
1349
- end
1350
1114
 
1351
- items = items.sort_by { |item| item['date'] }.reverse
1115
+ items.each do |item|
1116
+ added = []
1117
+ removed = []
1118
+
1119
+ if opt[:autotag]
1120
+ new_title = autotag(item.title) if @auto_tag
1121
+ if new_title == item.title
1122
+ logger.count(:skipped, level: :debug, message: '%count unchaged %items')
1123
+ # logger.debug('Autotag:', 'No changes')
1124
+ else
1125
+ logger.count(:added_tags)
1126
+ logger.debug('Tags updated:', new_title)
1127
+ item.title = new_title
1128
+ end
1129
+ else
1130
+ if opt[:sequential]
1131
+ next_entry = next_item(item)
1132
+
1133
+ done_date = if next_entry.nil?
1134
+ Time.now
1135
+ else
1136
+ next_entry.date - 60
1137
+ end
1138
+ elsif opt[:took]
1139
+ if item.date + opt[:took] > Time.now
1140
+ item.date = Time.now - opt[:took]
1141
+ done_date = Time.now
1142
+ else
1143
+ done_date = item.date + opt[:took]
1144
+ end
1145
+ elsif opt[:back]
1146
+ done_date = if opt[:back].is_a? Integer
1147
+ item.date + opt[:back]
1148
+ else
1149
+ item.date + (opt[:back] - item.date)
1150
+ end
1151
+ else
1152
+ done_date = Time.now
1153
+ end
1352
1154
 
1353
- idx = nil
1155
+ opt[:tags].each do |tag|
1156
+ if tag == 'done' && !item.should_finish?
1354
1157
 
1355
- if options[:tag] && !options[:tag].empty?
1356
- items.each_with_index do |item, i|
1357
- if item.has_tags?(options[:tag], options[:tag_bool])
1358
- idx = i
1359
- break
1158
+ Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
1159
+ logger.count(:skipped, level: :debug)
1160
+ next
1161
+ end
1162
+
1163
+ tag = tag.strip
1164
+ if opt[:remove] || opt[:rename]
1165
+ rename_to = nil
1166
+ if opt[:rename]
1167
+ rename_to = tag
1168
+ tag = opt[:rename]
1169
+ end
1170
+ old_title = item.title.dup
1171
+ item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex])
1172
+ if old_title != item.title
1173
+ removed << tag
1174
+ added << rename_to if rename_to
1175
+ else
1176
+ logger.count(:skipped, level: :debug)
1177
+ end
1178
+ else
1179
+ old_title = item.title.dup
1180
+ should_date = opt[:date] && item.should_time?
1181
+ item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
1182
+ added << tag if old_title != item.title
1183
+ end
1184
+ end
1360
1185
  end
1361
- end
1362
- elsif options[:search]
1363
- items.each_with_index do |item, i|
1364
- if item.matches_search?(options[:search])
1365
- idx = i
1366
- break
1186
+
1187
+ log_change(tags_added: added, tags_removed: removed)
1188
+
1189
+ item.note.add(opt[:note]) if opt[:note]
1190
+
1191
+ if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1192
+ move_item(item, 'Archive', label: true)
1193
+ elsif opt[:archive] && opt[:count].zero?
1194
+ logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1367
1195
  end
1368
1196
  end
1369
- else
1370
- idx = 0
1371
- end
1372
1197
 
1373
- if idx.nil?
1374
- @results.push('No entries found')
1375
- return
1376
- end
1377
-
1378
- section = items[idx]['section']
1379
-
1380
- section_items = @content[section]['items']
1381
- s_idx = section_items.index(items[idx])
1382
-
1383
- current_item = section_items[s_idx]['title']
1384
- old_note = section_items[s_idx]['note'] ? section_items[s_idx]['note'].map(&:strip).join("\n") : nil
1385
- current_item += "\n#{old_note}" unless old_note.nil?
1386
- new_item = fork_editor(current_item)
1387
- title, note = format_input(new_item)
1388
-
1389
- if title.nil? || title.empty?
1390
- @results.push('No content provided')
1391
- elsif title == section_items[s_idx]['title'] && note == old_note
1392
- @results.push('No change in content')
1393
- else
1394
- section_items[s_idx]['title'] = title
1395
- section_items[s_idx]['note'] = note
1396
- @results.push("Entry edited: #{section_items[s_idx]['title']}")
1397
- @content[section]['items'] = section_items
1398
1198
  write(@doing_file)
1399
1199
  end
1400
- end
1401
-
1402
- ##
1403
- ## @brief Add a note to the last entry in a section
1404
- ##
1405
- ## @param section (String) The section, default "All"
1406
- ## @param note (String) The note to add
1407
- ## @param replace (Bool) Should replace existing note
1408
- ##
1409
- def note_last(section, note, replace: false)
1410
- section = guess_section(section)
1411
1200
 
1412
- if section =~ /^all$/i
1413
- combined = { 'items' => [] }
1414
- @content.each do |_k, v|
1415
- combined['items'] += v['items']
1201
+ ##
1202
+ ## @brief Move item from current section to
1203
+ ## destination section
1204
+ ##
1205
+ ## @param item The item
1206
+ ## @param section The destination section
1207
+ ##
1208
+ ## @return Updated item
1209
+ ##
1210
+ def move_item(item, section, label: true)
1211
+ from = item.section
1212
+ new_item = @content[item.section][:items].delete(item)
1213
+ new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
1214
+ new_item.section = section
1215
+
1216
+ @content[section][:items].concat([new_item])
1217
+
1218
+ logger.count(section == 'Archive' ? :archived : :moved)
1219
+ logger.debug("Entry #{section == 'Archive' ? 'archived' : 'moved'}:",
1220
+ "#{new_item.title.truncate(60)} from #{from} to #{section}")
1221
+ new_item
1222
+ end
1223
+
1224
+ ##
1225
+ ## @brief Get next item in the index
1226
+ ##
1227
+ ## @param item
1228
+ ##
1229
+ def next_item(item, options = {})
1230
+ items = filter_items([], opt: options)
1231
+
1232
+ idx = items.index(item)
1233
+
1234
+ idx.positive? ? items[idx - 1] : nil
1235
+ end
1236
+
1237
+ ##
1238
+ ## @brief Delete an item from the index
1239
+ ##
1240
+ ## @param item The item
1241
+ ##
1242
+ def delete_item(item)
1243
+ section = item.section
1244
+
1245
+ section_items = @content[section][:items]
1246
+ deleted = section_items.delete(item)
1247
+ logger.count(:deleted)
1248
+ logger.debug('Entry deleted:', deleted.title)
1249
+ end
1250
+
1251
+ ##
1252
+ ## @brief Update an item in the index with a modified item
1253
+ ##
1254
+ ## @param old_item The old item
1255
+ ## @param new_item The new item
1256
+ ##
1257
+ def update_item(old_item, new_item)
1258
+ section = old_item.section
1259
+
1260
+ section_items = @content[section][:items]
1261
+ s_idx = section_items.index { |item| item.equal?(old_item) }
1262
+
1263
+ unless s_idx
1264
+ Doing.logger.error('Fail to update:', 'Could not find item in index')
1265
+ raise Errors::ItemNotFound, 'Unable to find item in index, did it mutate?'
1416
1266
  end
1417
- section = combined['items'].dup.sort_by { |item| item['date'] }.reverse[0]['section']
1418
- end
1419
1267
 
1420
- exit_now! "Section #{section} not found" unless @content.key?(section)
1421
-
1422
- # sort_section(opt[:section])
1423
- items = @content[section]['items'].dup.sort_by { |item| item['date'] }.reverse
1424
-
1425
- current_note = items[0]['note']
1426
- current_note = [] if current_note.nil?
1427
- title = items[0]['title']
1428
- if replace
1429
- items[0]['note'] = note
1430
- if note.empty? && !current_note.empty?
1431
- @results.push(%(Removed note from "#{title}"))
1432
- elsif !current_note.empty? && !note.empty?
1433
- @results.push(%(Replaced note from "#{title}"))
1434
- elsif !note.empty?
1435
- @results.push(%(Added note to "#{title}"))
1436
- else
1437
- @results.push(%(Entry "#{title}" has no note))
1438
- end
1439
- elsif current_note.instance_of?(Array)
1440
- items[0]['note'] = current_note.concat(note)
1441
- @results.push(%(Added note to "#{title}")) unless note.empty?
1442
- else
1443
- items[0]['note'] = note
1444
- @results.push(%(Added note to "#{title}")) unless note.empty?
1268
+ return if section_items[s_idx].equal?(new_item)
1269
+
1270
+ section_items[s_idx] = new_item
1271
+ logger.count(:updated)
1272
+ logger.debug('Entry updated:', section_items[s_idx].title.truncate(60))
1273
+ new_item
1445
1274
  end
1446
1275
 
1447
- @content[section]['items'] = items
1276
+ ##
1277
+ ## @brief Edit the last entry
1278
+ ##
1279
+ ## @param section (String) The section, default "All"
1280
+ ##
1281
+ def edit_last(section: 'All', options: {})
1282
+ options[:section] = guess_section(section)
1448
1283
 
1449
- end
1284
+ item = last_entry(options)
1450
1285
 
1451
- #
1452
- # @brief Accepts one tag and the raw text of a new item if the passed tag
1453
- # is on any item, it's replaced with @done. if new_item is not
1454
- # nil, it's tagged with the passed tag and inserted. This is for
1455
- # use where only one instance of a given tag should exist
1456
- # (@meanwhile)
1457
- #
1458
- # @param tag (String) Tag to replace
1459
- # @param opt (Hash) Additional Options
1460
- #
1461
- def stop_start(target_tag, opt = {})
1462
- tag = target_tag.dup
1463
- opt[:section] ||= @current_section
1464
- opt[:archive] ||= false
1465
- opt[:back] ||= Time.now
1466
- opt[:new_item] ||= false
1467
- opt[:note] ||= false
1468
-
1469
- opt[:section] = guess_section(opt[:section])
1470
-
1471
- tag.sub!(/^@/, '')
1472
-
1473
- found_items = 0
1474
- @content[opt[:section]]['items'].each_with_index do |item, i|
1475
- next unless item['title'] =~ /@#{tag}/
1476
-
1477
- title = item['title'].gsub(/(^| )@(#{tag}|done)(\([^)]*\))?/, '')
1478
- title += " @done(#{opt[:back].strftime('%F %R')})"
1479
-
1480
- @content[opt[:section]]['items'][i]['title'] = title
1481
- found_items += 1
1482
-
1483
- if opt[:archive] && opt[:section] != 'Archive'
1484
- @results.push(%(Completed and archived "#{@content[opt[:section]]['items'][i]['title']}"))
1485
- archive_item = @content[opt[:section]]['items'][i]
1486
- archive_item['title'] = i['title'].sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{i['section']})")
1487
- @content['Archive']['items'].push(archive_item)
1488
- @content[opt[:section]]['items'].delete_at(i)
1286
+ if item.nil?
1287
+ logger.debug('Skipped:', 'No entries found')
1288
+ return
1289
+ end
1290
+
1291
+ content = [item.title.dup]
1292
+ content << item.note.to_s unless item.note.empty?
1293
+ new_item = fork_editor(content.join("\n"))
1294
+ title, note = format_input(new_item)
1295
+
1296
+ if title.nil? || title.empty?
1297
+ logger.debug('Skipped:', 'No content provided')
1298
+ elsif title == item.title && note.equal?(item.note)
1299
+ logger.debug('Skipped:', 'No change in content')
1489
1300
  else
1490
- @results.push(%(Completed "#{@content[opt[:section]]['items'][i]['title']}"))
1301
+ item.title = title
1302
+ item.note.add(note, replace: true)
1303
+ logger.info('Edited:', item.title)
1304
+
1305
+ write(@doing_file)
1491
1306
  end
1492
1307
  end
1493
1308
 
1494
- @results.push("No active @#{tag} tasks found.") if found_items == 0
1309
+ ##
1310
+ ## @brief Accepts one tag and the raw text of a new item if the passed tag
1311
+ ## is on any item, it's replaced with @done. if new_item is not
1312
+ ## nil, it's tagged with the passed tag and inserted. This is for
1313
+ ## use where only one instance of a given tag should exist
1314
+ ## (@meanwhile)
1315
+ ##
1316
+ ## @param tag (String) Tag to replace
1317
+ ## @param opt (Hash) Additional Options
1318
+ ##
1319
+ def stop_start(target_tag, opt = {})
1320
+ tag = target_tag.dup
1321
+ opt[:section] ||= @config['current_section']
1322
+ opt[:archive] ||= false
1323
+ opt[:back] ||= Time.now
1324
+ opt[:new_item] ||= false
1325
+ opt[:note] ||= false
1495
1326
 
1496
- if opt[:new_item]
1497
- title, note = format_input(opt[:new_item])
1498
- note.push(opt[:note].map(&:chomp)) if opt[:note]
1499
- title += " @#{tag}"
1500
- add_item(title.cap_first, opt[:section], { note: note.join(' ').rstrip, back: opt[:back] })
1501
- end
1327
+ opt[:section] = guess_section(opt[:section])
1502
1328
 
1503
- write(@doing_file)
1504
- end
1329
+ tag.sub!(/^@/, '')
1505
1330
 
1506
- ##
1507
- ## @brief Write content to file or STDOUT
1508
- ##
1509
- ## @param file (String) The filepath to write to
1510
- ##
1511
- def write(file = nil, backup: true)
1512
- output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
1331
+ found_items = 0
1513
1332
 
1514
- @content.each do |title, section|
1515
- output += "#{section['original']}\n"
1516
- output += list_section({ section: title, template: "\t- %date | %title%note", highlight: false })
1517
- end
1518
- output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
1519
- if file.nil?
1520
- $stdout.puts output
1521
- else
1522
- file = File.expand_path(file)
1523
- if File.exist?(file) && backup
1524
- # Create a backup copy for the undo command
1525
- FileUtils.cp(file, "#{file}~")
1526
- end
1333
+ @content[opt[:section]][:items].each_with_index do |item, i|
1334
+ next unless item.title =~ /@#{tag}/
1527
1335
 
1528
- File.open(file, 'w+') do |f|
1529
- f.puts output
1530
- end
1336
+ item.title.add_tags!([tag, 'done'], remove: true)
1337
+ item.tag('done', value: opt[:back].strftime('%F %R'))
1338
+
1339
+ found_items += 1
1531
1340
 
1532
- if @config.key?('run_after')
1533
- stdout, stderr, status = Open3.capture3(@config['run_after'])
1534
- if status.exitstatus.positive?
1535
- warn "Error running #{@config['run_after']}"
1536
- warn stderr
1341
+ if opt[:archive] && opt[:section] != 'Archive'
1342
+ item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1343
+ move_item(item, 'Archive', label: false)
1344
+ logger.count(:completed_archived)
1345
+ logger.debug('Completed/archived:', item.title)
1346
+ else
1347
+ logger.count(:completed)
1348
+ logger.debug('Completed:', item.title)
1537
1349
  end
1538
1350
  end
1539
- end
1540
- end
1541
-
1542
- ##
1543
- ## @brief Restore a backed up version of a file
1544
- ##
1545
- ## @param file (String) The filepath to restore
1546
- ##
1547
- def restore_backup(file)
1548
- if File.exist?(file + '~')
1549
- puts file + '~'
1550
- FileUtils.cp(file + '~', file)
1551
- @results.push("Restored #{file}")
1552
- end
1553
- end
1554
-
1555
- ##
1556
- ## @brief Rename doing file with date and start fresh one
1557
- ##
1558
- def rotate(opt = {})
1559
- count = opt[:keep] || 0
1560
- tags = []
1561
- tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
1562
- bool = opt[:bool] || :and
1563
- sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1564
-
1565
- if sect =~ /^all$/i
1566
- all_sections = sections.dup
1567
- else
1568
- all_sections = [sect]
1569
- end
1570
1351
 
1571
- counter = 0
1572
- new_content = {}
1352
+ logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1573
1353
 
1354
+ if opt[:new_item]
1355
+ title, note = format_input(opt[:new_item])
1356
+ note.add(opt[:note]) if opt[:note]
1357
+ title.tag!(tag)
1358
+ add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
1359
+ end
1574
1360
 
1575
- all_sections.each do |section|
1576
- items = @content[section]['items'].dup
1577
- new_content[section] = {}
1578
- new_content[section]['original'] = @content[section]['original']
1579
- new_content[section]['items'] = []
1361
+ write(@doing_file)
1362
+ end
1580
1363
 
1581
- moved_items = []
1582
- if !tags.empty? || opt[:search] || opt[:before]
1583
- if opt[:before]
1584
- time_string = opt[:before]
1585
- time_string += ' 12am' if time_string !~ /(\d+:\d+|\d+[ap])/
1586
- cutoff = chronify(time_string)
1587
- end
1364
+ ##
1365
+ ## @brief Write content to file or STDOUT
1366
+ ##
1367
+ ## @param file (String) The filepath to write to
1368
+ ##
1369
+ def write(file = nil, backup: true)
1370
+ Hooks.trigger :pre_write, self, file
1371
+ output = wrapped_content
1588
1372
 
1589
- items.delete_if do |item|
1590
- if ((!tags.empty? && item.has_tags?(tags, bool)) || (opt[:search] && item.matches_search?(opt[:search].to_s)) || (opt[:before] && item['date'] < cutoff))
1591
- moved_items.push(item)
1592
- counter += 1
1593
- true
1594
- else
1595
- false
1596
- end
1597
- end
1598
- @content[section]['items'] = items
1599
- new_content[section]['items'] = moved_items
1600
- @results.push("Rotated #{moved_items.length} items from #{section}")
1373
+ if file.nil?
1374
+ $stdout.puts output
1601
1375
  else
1602
- new_content[section]['items'] = []
1603
- moved_items = []
1376
+ Util.write_to_file(file, output, backup: backup)
1377
+ run_after if @config.key?('run_after')
1378
+ end
1379
+ end
1604
1380
 
1605
- count = items.length if items.length < count
1381
+ def wrapped_content
1382
+ output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
1606
1383
 
1607
- if items.count > count
1608
- moved_items.concat(items[count..-1])
1609
- else
1610
- moved_items.concat(items)
1611
- end
1384
+ @content.each do |title, section|
1385
+ output += "#{section[:original]}\n"
1386
+ output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0 })
1387
+ end
1612
1388
 
1613
- @content[section]['items'] = if count.zero?
1614
- []
1615
- else
1616
- items[0..count - 1]
1617
- end
1618
- new_content[section]['items'] = moved_items
1389
+ output + @other_content_bottom.join("\n") unless @other_content_bottom.nil?
1390
+ end
1619
1391
 
1620
- @results.push("Rotated #{items.length - count} items from #{section}")
1392
+ ##
1393
+ ## @brief Restore a backed up version of a file
1394
+ ##
1395
+ ## @param file (String) The filepath to restore
1396
+ ##
1397
+ def restore_backup(file)
1398
+ if File.exist?("#{file}~")
1399
+ FileUtils.cp("#{file}~", file)
1400
+ logger.warn('File update:', "Restored #{file.sub(/^#{@user_home}/, '~')}")
1401
+ else
1402
+ logger.error('Restore error:', 'No backup file found')
1621
1403
  end
1622
1404
  end
1623
1405
 
1624
- write(@doing_file)
1406
+ ##
1407
+ ## @brief Rename doing file with date and start fresh one
1408
+ ##
1409
+ def rotate(opt = {})
1410
+ keep = opt[:keep] || 0
1411
+ tags = []
1412
+ tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
1413
+ bool = opt[:bool] || :and
1414
+ sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1625
1415
 
1626
- file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1627
- if File.exist?(file)
1628
- init_doing_file(file)
1629
- @content.deep_merge(new_content)
1630
- else
1631
- @content = new_content
1632
- end
1416
+ if sect =~ /^all$/i
1417
+ all_sections = sections.dup
1418
+ else
1419
+ all_sections = [sect]
1420
+ end
1633
1421
 
1634
- write(file, backup: false)
1635
- end
1422
+ counter = 0
1423
+ new_content = {}
1636
1424
 
1637
- ##
1638
- ## @brief Generate a menu of sections and allow user selection
1639
- ##
1640
- ## @return (String) The selected section name
1641
- ##
1642
- def choose_section
1643
- choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1644
- choice ? choice.strip : choice
1645
- end
1646
1425
 
1647
- ##
1648
- ## @brief List available views
1649
- ##
1650
- ## @return (Array) View names
1651
- ##
1652
- def views
1653
- @config.has_key?('views') ? @config['views'].keys : []
1654
- end
1426
+ all_sections.each do |section|
1427
+ items = @content[section][:items].dup
1428
+ new_content[section] = {}
1429
+ new_content[section][:original] = @content[section][:original]
1430
+ new_content[section][:items] = []
1655
1431
 
1656
- ##
1657
- ## @brief Generate a menu of views and allow user selection
1658
- ##
1659
- ## @return (String) The selected view name
1660
- ##
1661
- def choose_view
1662
- choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1663
- choice ? choice.strip : choice
1664
- end
1432
+ moved_items = []
1433
+ if !tags.empty? || opt[:search] || opt[:before]
1434
+ if opt[:before]
1435
+ time_string = opt[:before]
1436
+ cutoff = chronify(time_string, guess: :begin)
1437
+ end
1665
1438
 
1666
- ##
1667
- ## @brief Gets a view from configuration
1668
- ##
1669
- ## @param title (String) The title of the view to retrieve
1670
- ##
1671
- def get_view(title)
1672
- return @config['views'][title] if @config['views'].has_key?(title)
1439
+ items.delete_if do |item|
1440
+ if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1441
+ moved_items.push(item)
1442
+ counter += 1
1443
+ true
1444
+ else
1445
+ false
1446
+ end
1447
+ end
1448
+ @content[section][:items] = items
1449
+ new_content[section][:items] = moved_items
1450
+ logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
1451
+ else
1452
+ new_content[section][:items] = []
1453
+ moved_items = []
1673
1454
 
1674
- false
1675
- end
1455
+ count = items.length < keep ? items.length : keep
1676
1456
 
1677
- ##
1678
- ## @brief Overachieving function for displaying contents of a section.
1679
- ## This is a fucking mess. I mean, Jesus Christ.
1680
- ##
1681
- ## @param opt (Hash) Additional Options
1682
- ##
1683
- def list_section(opt = {})
1684
- opt[:count] ||= 0
1685
- count = opt[:count] - 1
1686
- opt[:age] ||= 'newest'
1687
- opt[:date_filter] ||= []
1688
- opt[:format] ||= @default_date_format
1689
- opt[:only_timed] ||= false
1690
- opt[:order] ||= 'desc'
1691
- opt[:search] ||= false
1692
- opt[:section] ||= nil
1693
- opt[:sort_tags] ||= false
1694
- opt[:tag_filter] ||= false
1695
- opt[:tag_order] ||= 'asc'
1696
- opt[:tags_color] ||= false
1697
- opt[:template] ||= @default_template
1698
- opt[:times] ||= false
1699
- opt[:today] ||= false
1700
- opt[:totals] ||= false
1701
-
1702
- # opt[:highlight] ||= true
1703
- section = ''
1704
- if opt[:section].nil?
1705
- section = choose_section
1706
- opt[:section] = @content[section]
1707
- elsif opt[:section].instance_of?(String)
1708
- if opt[:section] =~ /^all$/i
1709
- combined = { 'items' => [] }
1710
- @content.each do |_k, v|
1711
- combined['items'] += v['items']
1457
+ if items.count > count
1458
+ moved_items.concat(items[count..-1])
1459
+ else
1460
+ moved_items.concat(items)
1461
+ end
1462
+
1463
+ @content[section][:items] = if count.zero?
1464
+ []
1465
+ else
1466
+ items[0..count - 1]
1467
+ end
1468
+ new_content[section][:items] = moved_items
1469
+
1470
+ logger.warn('Rotated:', "#{items.length - count} items from #{section}")
1712
1471
  end
1713
- section = if opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
1714
- opt[:tag_filter]['tags'].map do |tag|
1715
- "@#{tag}"
1716
- end.join(' + ')
1472
+ end
1473
+
1474
+ write(@doing_file)
1475
+
1476
+ file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1477
+ if File.exist?(file)
1478
+ init_doing_file(file)
1479
+ @content.deep_merge(new_content)
1480
+ logger.warn('File update:', "Added entries to existing file: #{file}")
1481
+ else
1482
+ @content = new_content
1483
+ logger.warn('File update:', "Created new file: #{file}")
1484
+ end
1485
+
1486
+ write(file, backup: false)
1487
+ end
1488
+
1489
+ ##
1490
+ ## @brief Generate a menu of sections and allow user selection
1491
+ ##
1492
+ ## @return (String) The selected section name
1493
+ ##
1494
+ def choose_section
1495
+ choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1496
+ choice ? choice.strip : choice
1497
+ end
1498
+
1499
+ ##
1500
+ ## @brief List available views
1501
+ ##
1502
+ ## @return (Array) View names
1503
+ ##
1504
+ def views
1505
+ @config.has_key?('views') ? @config['views'].keys : []
1506
+ end
1507
+
1508
+ ##
1509
+ ## @brief Generate a menu of views and allow user selection
1510
+ ##
1511
+ ## @return (String) The selected view name
1512
+ ##
1513
+ def choose_view
1514
+ choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1515
+ choice ? choice.strip : choice
1516
+ end
1517
+
1518
+ ##
1519
+ ## @brief Gets a view from configuration
1520
+ ##
1521
+ ## @param title (String) The title of the view to retrieve
1522
+ ##
1523
+ def get_view(title)
1524
+ return @config['views'][title] if @config['views'].has_key?(title)
1525
+
1526
+ false
1527
+ end
1528
+
1529
+ ##
1530
+ ## @brief Display contents of a section based on options
1531
+ ##
1532
+ ## @param opt (Hash) Additional Options
1533
+ ##
1534
+ def list_section(opt = {})
1535
+ opt[:count] ||= 0
1536
+ opt[:age] ||= 'newest'
1537
+ opt[:format] ||= @config.dig('templates', 'default', 'date_format')
1538
+ opt[:order] ||= @config.dig('templates', 'default', 'order') || 'asc'
1539
+ opt[:tag_order] ||= 'asc'
1540
+ opt[:tags_color] ||= false
1541
+ opt[:template] ||= @config.dig('templates', 'default', 'template')
1542
+
1543
+ # opt[:highlight] ||= true
1544
+ title = ''
1545
+ is_single = true
1546
+ if opt[:section].nil?
1547
+ opt[:section] = choose_section
1548
+ title = opt[:section]
1549
+ elsif opt[:section].instance_of?(String)
1550
+ if opt[:section] =~ /^all$/i
1551
+ title = if opt[:page_title]
1552
+ opt[:page_title]
1553
+ elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
1554
+ opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
1717
1555
  else
1718
1556
  'doing'
1719
1557
  end
1720
- opt[:section] = combined
1721
- else
1722
- section = guess_section(opt[:section])
1723
- opt[:section] = @content[section]
1558
+ else
1559
+ title = guess_section(opt[:section])
1560
+ end
1724
1561
  end
1725
- end
1726
1562
 
1727
- exit_now! 'Invalid section object' unless opt[:section].instance_of? Hash
1563
+ items = filter_items([], opt: opt).reverse
1728
1564
 
1729
- items = opt[:section]['items'].sort_by { |item| item['date'] }
1565
+ items.reverse! if opt[:order] =~ /^d/i
1730
1566
 
1731
- if opt[:date_filter].length == 2
1732
- start_date = opt[:date_filter][0]
1733
- end_date = opt[:date_filter][1]
1734
- items.keep_if do |item|
1735
- if end_date
1736
- item['date'] >= start_date && item['date'] <= end_date
1737
- else
1738
- item['date'].strftime('%F') == start_date.strftime('%F')
1567
+
1568
+ if opt[:interactive]
1569
+ opt[:menu] = !opt[:force]
1570
+ opt[:query] = '' # opt[:search]
1571
+ opt[:multiple] = true
1572
+ selected = choose_from_items(items, opt, include_section: opt[:section] =~ /^all$/i )
1573
+
1574
+ if selected.empty?
1575
+ logger.debug('Skipped:', 'No selection')
1576
+ return
1739
1577
  end
1578
+
1579
+ act_on(selected, opt)
1580
+ return
1740
1581
  end
1741
- end
1742
1582
 
1743
- if opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
1744
- items.select! { |item| item.has_tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool']) }
1745
- end
1746
1583
 
1747
- if opt[:search]
1748
- items.keep_if {|item| item.matches_search?(opt[:search]) }
1749
- end
1584
+ opt[:output] ||= 'template'
1750
1585
 
1751
- if opt[:only_timed]
1752
- items.delete_if do |item|
1753
- get_interval(item, record: false) == false
1754
- end
1755
- end
1586
+ opt[:wrap_width] ||= @config['templates']['default']['wrap_width']
1756
1587
 
1757
- if opt[:before]
1758
- time_string = opt[:before]
1759
- time_string += ' 12am' if time_string !~ /(\d+:\d+|\d+[ap])/
1760
- cutoff = chronify(time_string)
1761
- if cutoff
1762
- items.delete_if { |item| item['date'] >= cutoff }
1763
- end
1588
+ output(items, title, is_single, opt)
1764
1589
  end
1765
1590
 
1766
- if opt[:after]
1767
- time_string = opt[:after]
1768
- time_string += ' 11:59pm' if time_string !~ /(\d+:\d+|\d+[ap])/
1769
- cutoff = chronify(time_string)
1770
- if cutoff
1771
- items.delete_if { |item| item['date'] <= cutoff }
1772
- end
1773
- end
1591
+ def output(items, title, is_single, opt = {})
1592
+ out = nil
1774
1593
 
1775
- if opt[:today]
1776
- items.delete_if do |item|
1777
- item['date'] < Date.today.to_time
1778
- end.reverse!
1779
- section = Time.now.strftime('%A, %B %d')
1780
- elsif opt[:yesterday]
1781
- items.delete_if do |item|
1782
- item['date'] <= Date.today.prev_day.to_time or
1783
- item['date'] >= Date.today.to_time
1784
- end.reverse!
1785
- elsif opt[:age] =~ /oldest/i
1786
- items = items[0..count]
1787
- else
1788
- items = items.reverse[0..count]
1789
- end
1594
+ raise Errors::InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
1790
1595
 
1791
- items.reverse! if opt[:order] =~ /^a/i
1596
+ export_options = { page_title: title, is_single: is_single, options: opt }
1792
1597
 
1793
- out = ''
1598
+ Plugins.plugins[:export].each do |_, options|
1599
+ next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
1794
1600
 
1795
- exit_now! 'Unknown output format' if opt[:output] && (opt[:output] !~ /^(template|html|csv|json|timeline)$/i)
1601
+ out = options[:class].render(self, items, variables: export_options)
1602
+ break
1603
+ end
1796
1604
 
1797
- case opt[:output]
1798
- when /^csv$/i
1799
- output = [CSV.generate_line(%w[date title note timer section])]
1800
- items.each do |i|
1801
- note = ''
1802
- if i['note']
1803
- arr = i['note'].map { |line| line.strip }.delete_if { |e| e =~ /^\s*$/ }
1804
- note = arr.join("\n") unless arr.nil?
1805
- end
1806
- interval = get_interval(i, formatted: false) if i['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
1807
- interval ||= 0
1808
- output.push(CSV.generate_line([i['date'], i['title'], note, interval, i['section']]))
1809
- end
1810
- out = output.join('')
1811
- when /^(json|timeline)/i
1812
- items_out = []
1813
- max = items[-1]['date'].strftime('%F')
1814
- min = items[0]['date'].strftime('%F')
1815
- items.each_with_index do |i, index|
1816
- if String.method_defined? :force_encoding
1817
- title = i['title'].force_encoding('utf-8')
1818
- note = i['note'].map { |line| line.force_encoding('utf-8').strip } if i['note']
1819
- else
1820
- title = i['title']
1821
- note = i['note'].map { |line| line.strip } if i['note']
1822
- end
1823
- if i['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
1824
- end_date = Time.parse(Regexp.last_match(1))
1825
- interval = get_interval(i, formatted: false)
1826
- end
1827
- end_date ||= ''
1828
- interval ||= 0
1829
- note ||= ''
1830
-
1831
- tags = []
1832
- attributes = {}
1833
- skip_tags = %w[meanwhile done cancelled flagged]
1834
- i['title'].scan(/@([^(\s]+)(?:\((.*?)\))?/).each do |tag|
1835
- tags.push(tag[0]) unless skip_tags.include?(tag[0])
1836
- attributes[tag[0]] = tag[1] if tag[1]
1605
+ out
1606
+ end
1607
+
1608
+ def load_plugins
1609
+ if @config.key?('plugins') && @config['plugins']['plugin_path']
1610
+ add_dir = @config['plugins']['plugin_path']
1611
+ else
1612
+ add_dir = File.join(@user_home, '.config', 'doing', 'plugins')
1613
+ begin
1614
+ FileUtils.mkdir_p(add_dir) if add_dir
1615
+ rescue
1616
+ nil
1837
1617
  end
1618
+ end
1838
1619
 
1839
- if opt[:output] == 'json'
1620
+ Plugins.load_plugins(add_dir)
1621
+ end
1840
1622
 
1841
- i = {
1842
- date: i['date'],
1843
- end_date: end_date,
1844
- title: title.strip, #+ " #{note}"
1845
- note: note.instance_of?(Array) ? note.map(&:strip).join("\n") : note,
1846
- time: '%02d:%02d:%02d' % fmt_time(interval),
1847
- tags: tags
1848
- }
1623
+ ##
1624
+ ## @brief Move entries from a section to Archive or other specified
1625
+ ## section
1626
+ ##
1627
+ ## @param section (String) The source section
1628
+ ## @param options (Hash) Options
1629
+ ##
1630
+ def archive(section = @config['current_section'], options = {})
1631
+ count = options[:keep] || 0
1632
+ destination = options[:destination] || 'Archive'
1633
+ tags = options[:tags] || []
1634
+ bool = options[:bool] || :and
1849
1635
 
1850
- attributes.each { |attr, val| i[attr.to_sym] = val }
1636
+ section = choose_section if section.nil? || section =~ /choose/i
1637
+ archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1638
+ section = guess_section(section) unless archive_all
1851
1639
 
1852
- items_out << i
1640
+ add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
1853
1641
 
1854
- elsif opt[:output] == 'timeline'
1855
- new_item = {
1856
- 'id' => index + 1,
1857
- 'content' => title.strip, #+ " #{note}"
1858
- 'title' => title.strip + " (#{'%02d:%02d:%02d' % fmt_time(interval)})",
1859
- 'start' => i['date'].strftime('%F %T'),
1860
- 'type' => 'point'
1861
- }
1642
+ destination = guess_section(destination)
1862
1643
 
1863
- if interval && interval.to_i > 0
1864
- new_item['end'] = end_date.strftime('%F %T')
1865
- new_item['type'] = 'range' if interval.to_i > 3600 * 3
1866
- end
1867
- items_out.push(new_item)
1868
- end
1644
+ if sections.include?(destination) && (sections.include?(section) || archive_all)
1645
+ do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
1646
+ write(doing_file)
1647
+ else
1648
+ raise Errors::InvalidArgument, 'Either source or destination does not exist'
1869
1649
  end
1870
- if opt[:output] == 'json'
1871
- puts JSON.pretty_generate({
1872
- 'section' => section,
1873
- 'items' => items_out,
1874
- 'timers' => tag_times(format: 'json', sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order])
1875
- })
1876
- elsif opt[:output] == 'timeline'
1877
- template = <<~EOTEMPLATE
1878
- <!doctype html>
1879
- <html>
1880
- <head>
1881
- <link href="https://unpkg.com/vis-timeline@7.4.9/dist/vis-timeline-graph2d.min.css" rel="stylesheet" type="text/css" />
1882
- <script src="https://unpkg.com/vis-timeline@7.4.9/dist/vis-timeline-graph2d.min.js"></script>
1883
- </head>
1884
- <body>
1885
- <div id="mytimeline"></div>
1886
- #{' '}
1887
- <script type="text/javascript">
1888
- // DOM element where the Timeline will be attached
1889
- var container = document.getElementById('mytimeline');
1890
- #{' '}
1891
- // Create a DataSet with data (enables two way data binding)
1892
- var data = new vis.DataSet(#{items_out.to_json});
1893
- #{' '}
1894
- // Configuration for the Timeline
1895
- var options = {
1896
- width: '100%',
1897
- height: '800px',
1898
- margin: {
1899
- item: 20
1900
- },
1901
- stack: true,
1902
- min: '#{min}',
1903
- max: '#{max}'
1904
- };
1905
- #{' '}
1906
- // Create a Timeline
1907
- var timeline = new vis.Timeline(container, data, options);
1908
- </script>
1909
- </body>
1910
- </html>
1911
- EOTEMPLATE
1912
- return template
1913
- end
1914
- when /^html$/i
1915
- page_title = section
1916
- items_out = []
1917
- items.each do |i|
1918
- # if i.has_key?('note')
1919
- # note = '<span class="note">' + i['note'].map{|n| n.strip }.join('<br>') + '</span>'
1920
- # else
1921
- # note = ''
1922
- # end
1923
- if String.method_defined? :force_encoding
1924
- title = i['title'].force_encoding('utf-8').link_urls
1925
- note = i['note'].map { |line| line.force_encoding('utf-8').strip.link_urls } if i['note']
1926
- else
1927
- title = i['title'].link_urls
1928
- note = i['note'].map { |line| line.strip.link_urls } if i['note']
1929
- end
1930
-
1931
- interval = get_interval(i) if i['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
1932
- interval ||= false
1650
+ end
1933
1651
 
1934
- items_out << {
1935
- date: i['date'].strftime('%a %-I:%M%p'),
1936
- title: title.gsub(/(@[^ (]+(\(.*?\))?)/im, '<span class="tag">\1</span>').strip, #+ " #{note}"
1937
- note: note,
1938
- time: interval,
1939
- section: i['section']
1940
- }
1652
+ ##
1653
+ ## @brief Helper function, performs the actual archiving
1654
+ ##
1655
+ ## @param section (String) The source section
1656
+ ## @param destination (String) The destination section
1657
+ ## @param opt (Hash) Additional Options
1658
+ ##
1659
+ def do_archive(sect, destination, opt = {})
1660
+ count = opt[:count] || 0
1661
+ tags = opt[:tags] || []
1662
+ bool = opt[:bool] || :and
1663
+ label = opt[:label] || true
1664
+
1665
+ if sect =~ /^all$/i
1666
+ all_sections = sections.dup
1667
+ all_sections.delete(destination)
1668
+ else
1669
+ all_sections = [sect]
1941
1670
  end
1942
1671
 
1943
- template = if @config['html_template']['haml'] && File.exist?(File.expand_path(@config['html_template']['haml']))
1944
- IO.read(File.expand_path(@config['html_template']['haml']))
1945
- else
1946
- haml_template
1947
- end
1672
+ counter = 0
1948
1673
 
1949
- style = if @config['html_template']['css'] && File.exist?(File.expand_path(@config['html_template']['css']))
1950
- IO.read(File.expand_path(@config['html_template']['css']))
1951
- else
1952
- css_template
1953
- end
1674
+ all_sections.each do |section|
1675
+ items = @content[section][:items].dup
1954
1676
 
1955
- totals = opt[:totals] ? tag_times(format: 'html', sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order]) : ''
1956
- engine = Haml::Engine.new(template)
1957
- out = engine.render(Object.new,
1958
- { :@items => items_out, :@page_title => page_title, :@style => style, :@totals => totals })
1959
- else
1960
- items.each do |item|
1961
- if opt[:highlight] && item['title'] =~ /@#{@config['marker_tag']}\b/i
1962
- flag = colors[@config['marker_color']]
1963
- reset = colors['default']
1964
- else
1965
- flag = ''
1966
- reset = ''
1967
- end
1677
+ moved_items = []
1678
+ if !tags.empty? || opt[:search] || opt[:before]
1679
+ if opt[:before]
1680
+ time_string = opt[:before]
1681
+ cutoff = chronify(time_string, guess: :begin)
1682
+ end
1968
1683
 
1969
- if (item.key?('note') && !item['note'].empty?) && @config[:include_notes]
1970
- note_lines = item['note'].delete_if do |line|
1971
- line =~ /^\s*$/
1684
+ items.delete_if do |item|
1685
+ if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1686
+ moved_items.push(item)
1687
+ counter += 1
1688
+ true
1689
+ else
1690
+ false
1691
+ end
1972
1692
  end
1973
- note_lines.map! { |line| "\t\t#{line.sub(/^\t*/, '').sub(/^-/, '—')} " }
1974
- if opt[:wrap_width]&.positive?
1975
- width = opt[:wrap_width]
1976
- note_lines.map! do |line|
1977
- line.strip.gsub(/(.{1,#{width}})(\s+|\Z)/, "\t\\1\n")
1693
+ moved_items.each do |item|
1694
+ if label
1695
+ item.title = if section == @config['current_section']
1696
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
1697
+ else
1698
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1699
+ end
1700
+ logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
1978
1701
  end
1979
1702
  end
1980
- note = "\n#{note_lines.join("\n").chomp}"
1981
- else
1982
- note = ''
1983
- end
1984
- output = opt[:template].dup
1985
1703
 
1986
- output.gsub!(/%[a-z]+/) do |m|
1987
- if colors.key?(m.sub(/^%/, ''))
1988
- colors[m.sub(/^%/, '')]
1989
- else
1990
- m
1704
+ @content[section][:items] = items
1705
+ @content[destination][:items].concat(moved_items)
1706
+ if moved_items.length.positive?
1707
+ logger.info('Archived:', "#{moved_items.length} items from #{section} to #{destination}")
1991
1708
  end
1992
- end
1993
-
1994
- output.sub!(/%date/, item['date'].strftime(opt[:format]))
1995
-
1996
- interval = get_interval(item, record: true) if item['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
1997
- interval ||= ''
1998
- output.sub!(/%interval/, interval)
1709
+ else
1710
+ count = items.length if items.length < count
1999
1711
 
2000
- output.sub!(/%shortdate/) do
2001
- if item['date'] > Date.today.to_time
2002
- item['date'].strftime(' %_I:%M%P')
2003
- elsif item['date'] > (Date.today - 6).to_time
2004
- item['date'].strftime('%a %_I:%M%P')
2005
- elsif item['date'].year == Date.today.year
2006
- item['date'].strftime('%m/%d %_I:%M%P')
2007
- else
2008
- item['date'].strftime('%m/%d/%Y %_I:%M%P')
1712
+ items.map! do |item|
1713
+ if label
1714
+ item.title = if section == @config['current_section']
1715
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
1716
+ else
1717
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1718
+ end
1719
+ logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
1720
+ end
1721
+ item
2009
1722
  end
2010
- end
2011
1723
 
2012
- output.sub!(/%title/) do |_m|
2013
- if opt[:wrap_width] && opt[:wrap_width] > 0
2014
- flag + item['title'].gsub(/(.{1,#{opt[:wrap_width]}})(\s+|\Z)/, "\\1\n\t ").chomp + reset
1724
+ if items.count > count
1725
+ @content[destination][:items].concat(items[count..-1])
2015
1726
  else
2016
- flag + item['title'].chomp + reset
1727
+ @content[destination][:items].concat(items)
2017
1728
  end
2018
- end
2019
-
2020
- output.sub!(/%section/, item['section']) if item['section']
2021
1729
 
2022
- if opt[:tags_color]
2023
- escapes = output.scan(/(\e\[[\d;]+m)[^\e]+@/)
2024
- last_color = if escapes.length > 0
2025
- escapes[-1][0]
2026
- else
2027
- colors['default']
2028
- end
2029
- output.gsub!(/(\s|m)(@[^ (]+)/, "\\1#{colors[opt[:tags_color]]}\\2#{last_color}")
1730
+ @content[section][:items] = if count.zero?
1731
+ []
1732
+ else
1733
+ items[0..count - 1]
1734
+ end
1735
+ logger.count(destination == 'Archive' ? :archived : :moved,
1736
+ count: items.length - count,
1737
+ message: "%count %items from #{section} to #{destination}")
1738
+ # logger.info('Archived:', "#{items.length - count} items from #{section} to #{destination}")
2030
1739
  end
2031
- output.sub!(/%note/, note)
2032
- output.sub!(/%odnote/, note.gsub(/^\t*/, ''))
2033
- output.sub!(/%chompnote/, note.gsub(/\n+/, ' ').gsub(/(^\s*|\s*$)/, '').gsub(/\s+/, ' '))
2034
- output.gsub!(/%hr(_under)?/) do |_m|
2035
- o = ''
2036
- `tput cols`.to_i.times do
2037
- o += Regexp.last_match(1).nil? ? '-' : '_'
2038
- end
2039
- o
2040
- end
2041
- output.gsub!(/%n/, "\n")
2042
- output.gsub!(/%t/, "\t")
2043
-
2044
- out += "#{output}\n"
2045
1740
  end
2046
-
2047
- out += tag_times(format: 'text', sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order]) if opt[:totals]
2048
1741
  end
2049
- out
2050
- end
2051
1742
 
2052
- ##
2053
- ## @brief Move entries from a section to Archive or other specified
2054
- ## section
2055
- ##
2056
- ## @param section (String) The source section
2057
- ## @param options (Hash) Options
2058
- ##
2059
- def archive(section = @current_section, options = {})
2060
- count = options[:keep] || 0
2061
- destination = options[:destination] || 'Archive'
2062
- tags = options[:tags] || []
2063
- bool = options[:bool] || :and
1743
+ ##
1744
+ ## @brief Show all entries from the current day
1745
+ ##
1746
+ ## @param times (Boolean) show times
1747
+ ## @param output (String) output format
1748
+ ## @param opt (Hash) Options
1749
+ ##
1750
+ def today(times = true, output = nil, opt = {})
1751
+ opt[:totals] ||= false
1752
+ opt[:sort_tags] ||= false
1753
+
1754
+ cfg = @config['templates']['today']
1755
+ options = {
1756
+ after: opt[:after],
1757
+ before: opt[:before],
1758
+ count: 0,
1759
+ format: cfg['date_format'],
1760
+ order: 'asc',
1761
+ output: output,
1762
+ section: opt[:section],
1763
+ sort_tags: opt[:sort_tags],
1764
+ template: cfg['template'],
1765
+ times: times,
1766
+ today: true,
1767
+ totals: opt[:totals],
1768
+ wrap_width: cfg['wrap_width']
1769
+ }
1770
+ list_section(options)
1771
+ end
1772
+
1773
+ ##
1774
+ ## @brief Display entries within a date range
1775
+ ##
1776
+ ## @param dates (Array) [start, end]
1777
+ ## @param section (String) The section
1778
+ ## @param times (Bool) Show times
1779
+ ## @param output (String) Output format
1780
+ ## @param opt (Hash) Additional Options
1781
+ ##
1782
+ def list_date(dates, section, times = nil, output = nil, opt = {})
1783
+ opt[:totals] ||= false
1784
+ opt[:sort_tags] ||= false
1785
+ section = guess_section(section)
1786
+ # :date_filter expects an array with start and end date
1787
+ dates = [dates, dates] if dates.instance_of?(String)
1788
+
1789
+ list_section({ section: section, count: 0, order: 'asc', date_filter: dates, times: times,
1790
+ output: output, totals: opt[:totals], sort_tags: opt[:sort_tags] })
1791
+ end
1792
+
1793
+ ##
1794
+ ## @brief Show entries from the previous day
1795
+ ##
1796
+ ## @param section (String) The section
1797
+ ## @param times (Bool) Show times
1798
+ ## @param output (String) Output format
1799
+ ## @param opt (Hash) Additional Options
1800
+ ##
1801
+ def yesterday(section, times = nil, output = nil, opt = {})
1802
+ opt[:totals] ||= false
1803
+ opt[:sort_tags] ||= false
1804
+ section = guess_section(section)
1805
+ y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
1806
+ opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
1807
+ opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
1808
+
1809
+ options = {
1810
+ after: opt[:after],
1811
+ before: opt[:before],
1812
+ count: 0,
1813
+ order: opt[:order],
1814
+ output: output,
1815
+ section: section,
1816
+ sort_tags: opt[:sort_tags],
1817
+ tag_order: opt[:tag_order],
1818
+ times: times,
1819
+ totals: opt[:totals],
1820
+ yesterday: true
1821
+ }
2064
1822
 
2065
- section = choose_section if section.nil? || section =~ /choose/i
2066
- archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
2067
- section = guess_section(section) unless archive_all
1823
+ list_section(options)
1824
+ end
1825
+
1826
+ ##
1827
+ ## @brief Show recent entries
1828
+ ##
1829
+ ## @param count (Integer) The number to show
1830
+ ## @param section (String) The section to show from, default Currently
1831
+ ## @param opt (Hash) Additional Options
1832
+ ##
1833
+ def recent(count = 10, section = nil, opt = {})
1834
+ times = opt[:t] || true
1835
+ opt[:totals] ||= false
1836
+ opt[:sort_tags] ||= false
1837
+
1838
+ cfg = @config['templates']['recent']
1839
+ section ||= @config['current_section']
1840
+ section = guess_section(section)
1841
+
1842
+ list_section({ section: section, wrap_width: cfg['wrap_width'], count: count,
1843
+ format: cfg['date_format'], template: cfg['template'],
1844
+ order: 'asc', times: times, totals: opt[:totals],
1845
+ sort_tags: opt[:sort_tags], tags_color: opt[:tags_color] })
1846
+ end
1847
+
1848
+ ##
1849
+ ## @brief Show the last entry
1850
+ ##
1851
+ ## @param times (Bool) Show times
1852
+ ## @param section (String) Section to pull from, default Currently
1853
+ ##
1854
+ def last(times: true, section: nil, options: {})
1855
+ section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
1856
+ cfg = @config['templates']['last']
1857
+
1858
+ opts = {
1859
+ section: section,
1860
+ wrap_width: cfg['wrap_width'],
1861
+ count: 1,
1862
+ format: cfg['date_format'],
1863
+ template: cfg['template'],
1864
+ times: times
1865
+ }
2068
1866
 
2069
- add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
1867
+ if options[:tag]
1868
+ opts[:tag_filter] = {
1869
+ 'tags' => options[:tag],
1870
+ 'bool' => options[:tag_bool]
1871
+ }
1872
+ end
2070
1873
 
2071
- destination = guess_section(destination)
1874
+ opts[:search] = options[:search] if options[:search]
2072
1875
 
2073
- if sections.include?(destination) && (sections.include?(section) || archive_all)
2074
- do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
2075
- write(doing_file)
2076
- else
2077
- exit_now! 'Either source or destination does not exist'
1876
+ list_section(opts)
2078
1877
  end
2079
- end
2080
1878
 
2081
- ##
2082
- ## @brief Helper function, performs the actual archiving
2083
- ##
2084
- ## @param section (String) The source section
2085
- ## @param destination (String) The destination section
2086
- ## @param opt (Hash) Additional Options
2087
- ##
2088
- def do_archive(sect, destination, opt = {})
2089
- count = opt[:count] || 0
2090
- tags = opt[:tags] || []
2091
- bool = opt[:bool] || :and
2092
- label = opt[:label] || true
2093
-
2094
- if sect =~ /^all$/i
2095
- all_sections = sections.dup
2096
- all_sections.delete(destination)
2097
- else
2098
- all_sections = [sect]
2099
- end
1879
+ ##
1880
+ ## @brief Uses 'autotag' configuration to turn keywords into tags for time tracking.
1881
+ ## Does not repeat tags in a title, and only converts the first instance of an
1882
+ ## untagged keyword
1883
+ ##
1884
+ ## @param text (String) The text to tag
1885
+ ##
1886
+ def autotag(text)
1887
+ return unless text
1888
+ return text unless @auto_tag
2100
1889
 
2101
- counter = 0
1890
+ original = text.dup
2102
1891
 
2103
- all_sections.each do |section|
2104
- items = @content[section]['items'].dup
1892
+ current_tags = text.scan(/@\w+/)
1893
+ whitelisted = []
1894
+ @config['autotag']['whitelist'].each do |tag|
1895
+ next if text =~ /@#{tag}\b/i
2105
1896
 
2106
- moved_items = []
2107
- if !tags.empty? || opt[:search] || opt[:before]
2108
- if opt[:before]
2109
- time_string = opt[:before]
2110
- time_string += ' 12am' if time_string !~ /(\d+:\d+|\d+[ap])/
2111
- cutoff = chronify(time_string)
1897
+ text.sub!(/(?<!@)\b(#{tag.strip})\b/i) do |m|
1898
+ m.downcase! if tag =~ /[a-z]/
1899
+ whitelisted.push("@#{m}")
1900
+ "@#{m}"
2112
1901
  end
1902
+ end
1903
+ tail_tags = []
1904
+ @config['autotag']['synonyms'].each do |tag, v|
1905
+ v.each do |word|
1906
+ next unless text =~ /\b#{word}\b/i
2113
1907
 
2114
- items.delete_if do |item|
2115
- if ((!tags.empty? && item.has_tags?(tags, bool)) || (opt[:search] && item.matches_search?(opt[:search].to_s)) || (opt[:before] && item['date'] < cutoff))
2116
- moved_items.push(item)
2117
- counter += 1
2118
- true
2119
- else
2120
- false
2121
- end
1908
+ tail_tags.push(tag) unless current_tags.include?("@#{tag}") || whitelisted.include?("@#{tag}")
2122
1909
  end
2123
- moved_items.each do |item|
2124
- if label && section != @current_section
2125
- item['title'] =
2126
- item['title'].sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2127
- end
2128
- end
2129
-
2130
- @content[section]['items'] = items
2131
- @content[destination]['items'].concat(moved_items)
2132
- @results.push("Archived #{moved_items.length} items from #{section} to #{destination}")
2133
- else
2134
- count = items.length if items.length < count
2135
-
2136
- items.map! do |item|
2137
- if label && section != @current_section
2138
- item['title'] =
2139
- item['title'].sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1910
+ end
1911
+ if @config['autotag'].key? 'transform'
1912
+ @config['autotag']['transform'].each do |tag|
1913
+ next unless tag =~ /\S+:\S+/
1914
+
1915
+ rx, r = tag.split(/:/)
1916
+ r.gsub!(/\$/, '\\')
1917
+ rx.sub!(/^@/, '')
1918
+ regex = Regexp.new('@' + rx + '\b')
1919
+
1920
+ matches = text.scan(regex)
1921
+ next unless matches
1922
+
1923
+ matches.each do |m|
1924
+ new_tag = r
1925
+ if m.is_a?(Array)
1926
+ index = 1
1927
+ m.each do |v|
1928
+ new_tag.gsub!('\\' + index.to_s, v)
1929
+ index += 1
1930
+ end
1931
+ end
1932
+ tail_tags.push(new_tag)
2140
1933
  end
2141
- item
2142
- end
2143
-
2144
- if items.count > count
2145
- @content[destination]['items'].concat(items[count..-1])
2146
- else
2147
- @content[destination]['items'].concat(items)
2148
1934
  end
2149
-
2150
- @content[section]['items'] = if count.zero?
2151
- []
2152
- else
2153
- items[0..count - 1]
2154
- end
2155
-
2156
- @results.push("Archived #{items.length - count} items from #{section} to #{destination}")
2157
1935
  end
2158
- end
2159
- end
2160
-
2161
- ##
2162
- ## @brief A dictionary of colors
2163
- ##
2164
- ## @return (String) ANSI escape sequence
2165
- ##
2166
- def colors
2167
- color = {}
2168
- color['black'] = "\033[0;0;30m"
2169
- color['red'] = "\033[0;0;31m"
2170
- color['green'] = "\033[0;0;32m"
2171
- color['yellow'] = "\033[0;0;33m"
2172
- color['blue'] = "\033[0;0;34m"
2173
- color['magenta'] = "\033[0;0;35m"
2174
- color['cyan'] = "\033[0;0;36m"
2175
- color['white'] = "\033[0;0;37m"
2176
- color['bgblack'] = "\033[40m"
2177
- color['bgred'] = "\033[41m"
2178
- color['bggreen'] = "\033[42m"
2179
- color['bgyellow'] = "\033[43m"
2180
- color['bgblue'] = "\033[44m"
2181
- color['bgmagenta'] = "\033[45m"
2182
- color['bgcyan'] = "\033[46m"
2183
- color['bgwhite'] = "\033[47m"
2184
- color['boldblack'] = "\033[1;30m"
2185
- color['boldred'] = "\033[1;31m"
2186
- color['boldgreen'] = "\033[0;1;32m"
2187
- color['boldyellow'] = "\033[0;1;33m"
2188
- color['boldblue'] = "\033[0;1;34m"
2189
- color['boldmagenta'] = "\033[0;1;35m"
2190
- color['boldcyan'] = "\033[0;1;36m"
2191
- color['boldwhite'] = "\033[0;1;37m"
2192
- color['boldbgblack'] = "\033[1;40m"
2193
- color['boldbgred'] = "\033[1;41m"
2194
- color['boldbggreen'] = "\033[1;42m"
2195
- color['boldbgyellow'] = "\033[1;43m"
2196
- color['boldbgblue'] = "\033[1;44m"
2197
- color['boldbgmagenta'] = "\033[1;45m"
2198
- color['boldbgcyan'] = "\033[1;46m"
2199
- color['boldbgwhite'] = "\033[1;47m"
2200
- color['softpurple'] = "\033[0;35;40m"
2201
- color['hotpants'] = "\033[7;34;40m"
2202
- color['knightrider'] = "\033[7;30;40m"
2203
- color['flamingo'] = "\033[7;31;47m"
2204
- color['yeller'] = "\033[1;37;43m"
2205
- color['whiteboard'] = "\033[1;30;47m"
2206
- color['default'] = "\033[0;39m"
2207
- color
2208
- end
2209
1936
 
2210
- ##
2211
- ## @brief Show all entries from the current day
2212
- ##
2213
- ## @param times (Boolean) show times
2214
- ## @param output (String) output format
2215
- ## @param opt (Hash) Options
2216
- ##
2217
- def today(times = true, output = nil, opt = {})
2218
- opt[:totals] ||= false
2219
- opt[:sort_tags] ||= false
2220
-
2221
- cfg = @config['templates']['today']
2222
- options = {
2223
- after: opt[:after],
2224
- before: opt[:before],
2225
- count: 0,
2226
- format: cfg['date_format'],
2227
- order: 'asc',
2228
- output: output,
2229
- section: opt[:section],
2230
- sort_tags: opt[:sort_tags],
2231
- template: cfg['template'],
2232
- times: times,
2233
- today: true,
2234
- totals: opt[:totals],
2235
- wrap_width: cfg['wrap_width']
2236
- }
2237
- list_section(options)
2238
- end
1937
+ logger.debug('Autotag:', "Whitelisted tags: #{whitelisted.join(', ')}") unless whitelisted.empty?
1938
+ new_tags = whitelisted
1939
+ unless tail_tags.empty?
1940
+ tags = tail_tags.uniq.map { |t| "@#{t}".cyan }.join(' ')
1941
+ logger.debug('Autotag:', "Synonym tags: #{tags}")
1942
+ tags_a = tail_tags.map { |t| "@#{t}" }
1943
+ text.add_tags!(tags_a.join(' '))
1944
+ new_tags.concat(tags_a)
1945
+ end
2239
1946
 
2240
- ##
2241
- ## @brief Display entries within a date range
2242
- ##
2243
- ## @param dates (Array) [start, end]
2244
- ## @param section (String) The section
2245
- ## @param times (Bool) Show times
2246
- ## @param output (String) Output format
2247
- ## @param opt (Hash) Additional Options
2248
- ##
2249
- def list_date(dates, section, times = nil, output = nil, opt = {})
2250
- opt[:totals] ||= false
2251
- opt[:sort_tags] ||= false
2252
- section = guess_section(section)
2253
- # :date_filter expects an array with start and end date
2254
- dates = [dates, dates] if dates.instance_of?(String)
2255
-
2256
- list_section({ section: section, count: 0, order: 'asc', date_filter: dates, times: times,
2257
- output: output, totals: opt[:totals], sort_tags: opt[:sort_tags] })
2258
- end
1947
+ unless text == original
1948
+ logger.info('Autotag:', "added #{new_tags.join(', ')} to \"#{text}\"")
1949
+ else
1950
+ logger.debug('Autotag:', "no change to \"#{text}\"")
1951
+ end
2259
1952
 
2260
- ##
2261
- ## @brief Show entries from the previous day
2262
- ##
2263
- ## @param section (String) The section
2264
- ## @param times (Bool) Show times
2265
- ## @param output (String) Output format
2266
- ## @param opt (Hash) Additional Options
2267
- ##
2268
- def yesterday(section, times = nil, output = nil, opt = {})
2269
- opt[:totals] ||= false
2270
- opt[:sort_tags] ||= false
2271
- section = guess_section(section)
2272
- y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
2273
- opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
2274
- opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
2275
-
2276
- options = {
2277
- after: opt[:after],
2278
- before: opt[:before],
2279
- count: 0,
2280
- order: 'asc',
2281
- output: output,
2282
- section: section,
2283
- sort_tags: opt[:sort_tags],
2284
- tag_order: opt[:tag_order],
2285
- times: times,
2286
- totals: opt[:totals],
2287
- yesterday: true
2288
- }
2289
-
2290
- list_section(options)
2291
- end
1953
+ text
1954
+ end
2292
1955
 
2293
- ##
2294
- ## @brief Show recent entries
2295
- ##
2296
- ## @param count (Integer) The number to show
2297
- ## @param section (String) The section to show from, default Currently
2298
- ## @param opt (Hash) Additional Options
2299
- ##
2300
- def recent(count = 10, section = nil, opt = {})
2301
- times = opt[:t] || true
2302
- opt[:totals] ||= false
2303
- opt[:sort_tags] ||= false
2304
-
2305
- cfg = @config['templates']['recent']
2306
- section ||= @current_section
2307
- section = guess_section(section)
2308
-
2309
- list_section({ section: section, wrap_width: cfg['wrap_width'], count: count,
2310
- format: cfg['date_format'], template: cfg['template'],
2311
- order: 'asc', times: times, totals: opt[:totals],
2312
- sort_tags: opt[:sort_tags], tags_color: opt[:tags_color] })
2313
- end
1956
+ ##
1957
+ ## @brief Get total elapsed time for all tags in
1958
+ ## selection
1959
+ ##
1960
+ ## @param format (String) return format (html,
1961
+ ## json, or text)
1962
+ ## @param sort_by_name (Boolean) Sort by name if true, otherwise by time
1963
+ ## @param sort_order (String) The sort order (asc or desc)
1964
+ ##
1965
+ def tag_times(format: :text, sort_by_name: false, sort_order: 'asc')
1966
+ return '' if @timers.empty?
2314
1967
 
2315
- ##
2316
- ## @brief Show the last entry
2317
- ##
2318
- ## @param times (Bool) Show times
2319
- ## @param section (String) Section to pull from, default Currently
2320
- ##
2321
- def last(times: true, section: nil, options: {})
2322
- section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
2323
- cfg = @config['templates']['last']
2324
-
2325
- opts = {
2326
- section: section,
2327
- wrap_width: cfg['wrap_width'],
2328
- count: 1,
2329
- format: cfg['date_format'],
2330
- template: cfg['template'],
2331
- times: times
2332
- }
2333
-
2334
- if options[:tag]
2335
- opts[:tag_filter] = {
2336
- 'tags' => options[:tag],
2337
- 'bool' => options[:tag_bool]
2338
- }
2339
- end
1968
+ max = @timers.keys.sort_by { |k| k.length }.reverse[0].length + 1
2340
1969
 
2341
- opts[:search] = options[:search] if options[:search]
1970
+ total = @timers.delete('All')
2342
1971
 
2343
- list_section(opts)
2344
- end
1972
+ tags_data = @timers.delete_if { |_k, v| v == 0 }
1973
+ sorted_tags_data = if sort_by_name
1974
+ tags_data.sort_by { |k, _v| k }
1975
+ else
1976
+ tags_data.sort_by { |_k, v| v }
1977
+ end
2345
1978
 
2346
- ##
2347
- ## @brief Get total elapsed time for all tags in
2348
- ## selection
2349
- ##
2350
- ## @param format (String) return format (html,
2351
- ## json, or text)
2352
- ## @param sort_by_name (Boolean) Sort by name if true, otherwise by time
2353
- ## @param sort_order (String) The sort order (asc or desc)
2354
- ##
2355
- def tag_times(format: 'text', sort_by_name: false, sort_order: 'asc')
2356
- return '' if @timers.empty?
2357
-
2358
- max = @timers.keys.sort_by { |k| k.length }.reverse[0].length + 1
2359
-
2360
- total = @timers.delete('All')
2361
-
2362
- tags_data = @timers.delete_if { |_k, v| v == 0 }
2363
- sorted_tags_data = if sort_by_name
2364
- tags_data.sort_by { |k, _v| k }
2365
- else
2366
- tags_data.sort_by { |_k, v| v }
2367
- end
2368
-
2369
- sorted_tags_data.reverse! if sort_order =~ /^asc/i
2370
-
2371
- if format == 'html'
2372
- output = <<EOS
2373
- <table>
2374
- <caption id="tagtotals">Tag Totals</caption>
2375
- <colgroup>
2376
- <col style="text-align:left;"/>
2377
- <col style="text-align:left;"/>
2378
- </colgroup>
2379
- <thead>
1979
+ sorted_tags_data.reverse! if sort_order =~ /^asc/i
1980
+ case format
1981
+ when :html
1982
+
1983
+ output = <<EOS
1984
+ <table>
1985
+ <caption id="tagtotals">Tag Totals</caption>
1986
+ <colgroup>
1987
+ <col style="text-align:left;"/>
1988
+ <col style="text-align:left;"/>
1989
+ </colgroup>
1990
+ <thead>
1991
+ <tr>
1992
+ <th style="text-align:left;">project</th>
1993
+ <th style="text-align:left;">time</th>
1994
+ </tr>
1995
+ </thead>
1996
+ <tbody>
1997
+ EOS
1998
+ sorted_tags_data.reverse.each do |k, v|
1999
+ if v > 0
2000
+ output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{'%02d:%02d:%02d' % fmt_time(v)}</td></tr>\n"
2001
+ end
2002
+ end
2003
+ tail = <<EOS
2380
2004
  <tr>
2381
- <th style="text-align:left;">project</th>
2382
- <th style="text-align:left;">time</th>
2005
+ <td style="text-align:left;" colspan="2"></td>
2383
2006
  </tr>
2384
- </thead>
2385
- <tbody>
2007
+ </tbody>
2008
+ <tfoot>
2009
+ <tr>
2010
+ <td style="text-align:left;"><strong>Total</strong></td>
2011
+ <td style="text-align:left;">#{'%02d:%02d:%02d' % fmt_time(total)}</td>
2012
+ </tr>
2013
+ </tfoot>
2014
+ </table>
2386
2015
  EOS
2387
- sorted_tags_data.reverse.each do |k, v|
2388
- if v > 0
2389
- output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{'%02d:%02d:%02d' % fmt_time(v)}</td></tr>\n"
2016
+ output + tail
2017
+ when :markdown
2018
+ pad = sorted_tags_data.map {|k, v| k }.group_by(&:size).max.last[0].length
2019
+ output = <<~EOS
2020
+ | #{' ' * (pad - 7) }project | time |
2021
+ | #{'-' * (pad - 1)}: | :------- |
2022
+ EOS
2023
+ sorted_tags_data.reverse.each do |k, v|
2024
+ if v > 0
2025
+ output += "| #{' ' * (pad - k.length)}#{k} | #{'%02d:%02d:%02d' % fmt_time(v)} |\n"
2026
+ end
2390
2027
  end
2391
- end
2392
- tail = <<EOS
2393
- <tr>
2394
- <td style="text-align:left;" colspan="2"></td>
2395
- </tr>
2396
- </tbody>
2397
- <tfoot>
2398
- <tr>
2399
- <td style="text-align:left;"><strong>Total</strong></td>
2400
- <td style="text-align:left;">#{'%02d:%02d:%02d' % fmt_time(total)}</td>
2401
- </tr>
2402
- </tfoot>
2403
- </table>
2404
- EOS
2405
- output + tail
2406
- elsif format == 'json'
2407
- output = []
2408
- sorted_tags_data.reverse.each do |k, v|
2409
- output << {
2410
- 'tag' => k,
2411
- 'seconds' => v,
2412
- 'formatted' => '%02d:%02d:%02d' % fmt_time(v)
2413
- }
2414
- end
2415
- output
2416
- else
2417
- output = []
2418
- sorted_tags_data.reverse.each do |k, v|
2419
- spacer = ''
2420
- (max - k.length).times do
2421
- spacer += ' '
2028
+ tail = "[Tag Totals]"
2029
+ output + tail
2030
+ when :json
2031
+ output = []
2032
+ sorted_tags_data.reverse.each do |k, v|
2033
+ d, h, m = fmt_time(v)
2034
+ output << {
2035
+ 'tag' => k,
2036
+ 'seconds' => v,
2037
+ 'formatted' => format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
2038
+ }
2039
+ end
2040
+ output
2041
+ else
2042
+ output = []
2043
+ sorted_tags_data.reverse.each do |k, v|
2044
+ spacer = ''
2045
+ (max - k.length).times do
2046
+ spacer += ' '
2047
+ end
2048
+ d, h, m = fmt_time(v)
2049
+ output.push("#{k}:#{spacer}#{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}")
2422
2050
  end
2423
- output.push("#{k}:#{spacer}#{'%02d:%02d:%02d' % fmt_time(v)}")
2424
- end
2425
-
2426
- output = output.empty? ? '' : "\n--- Tag Totals ---\n" + output.join("\n")
2427
- output += "\n\nTotal tracked: #{'%02d:%02d:%02d' % fmt_time(total)}\n"
2428
- output
2429
- end
2430
- end
2431
2051
 
2432
- # @brief Uses 'autotag' configuration to turn keywords into tags for time tracking.
2433
- # Does not repeat tags in a title, and only converts the first instance of an
2434
- # untagged keyword
2435
- #
2436
- # @param text (String) The text to tag
2437
- #
2438
- def autotag(text)
2439
- return unless text
2440
- return text unless @auto_tag
2441
-
2442
- current_tags = text.scan(/@\w+/)
2443
- whitelisted = []
2444
- @config['autotag']['whitelist'].each do |tag|
2445
- next if text =~ /@#{tag}\b/i
2446
-
2447
- text.sub!(/(?<!@)(#{tag.strip})\b/i) do |m|
2448
- m.downcase! if tag =~ /[a-z]/
2449
- whitelisted.push("@#{m}")
2450
- "@#{m}"
2052
+ output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
2053
+ d, h, m = fmt_time(total)
2054
+ output += "\n\nTotal tracked: #{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}\n"
2055
+ output
2451
2056
  end
2452
2057
  end
2453
- tail_tags = []
2454
- @config['autotag']['synonyms'].each do |tag, v|
2455
- v.each do |word|
2456
- next unless text =~ /\b#{word}\b/i
2457
2058
 
2458
- tail_tags.push(tag) unless current_tags.include?("@#{tag}") || whitelisted.include?("@#{tag}")
2059
+ ##
2060
+ ## @brief Gets the interval between entry's start
2061
+ ## date and @done date
2062
+ ##
2063
+ ## @param item (Hash) The entry
2064
+ ## @param formatted (Bool) Return human readable
2065
+ ## time (default seconds)
2066
+ ## @param record (Bool) Add the interval to the
2067
+ ## total for each tag
2068
+ ##
2069
+ ## @return Interval in seconds, or [d, h, m] array if
2070
+ ## formatted is true. False if no end date or
2071
+ ## interval is 0
2072
+ ##
2073
+ def get_interval(item, formatted: true, record: true)
2074
+ if item.interval
2075
+ seconds = item.interval
2076
+ record_tag_times(item, seconds) if record
2077
+ return seconds.positive? ? seconds : false unless formatted
2078
+
2079
+ return seconds.positive? ? format('%02d:%02d:%02d', *fmt_time(seconds)) : false
2459
2080
  end
2460
- end
2461
- if @config['autotag'].key? 'transform'
2462
- @config['autotag']['transform'].each do |tag|
2463
- next unless tag =~ /\S+:\S+/
2464
-
2465
- rx, r = tag.split(/:/)
2466
- r.gsub!(/\$/, '\\')
2467
- rx.sub!(/^@/, '')
2468
- regex = Regexp.new('@' + rx + '\b')
2469
-
2470
- matches = text.scan(regex)
2471
- next unless matches
2472
-
2473
- matches.each do |m|
2474
- new_tag = r
2475
- if m.is_a?(Array)
2476
- index = 1
2477
- m.each do |v|
2478
- new_tag = new_tag.gsub('\\' + index.to_s, v)
2479
- index += 1
2480
- end
2481
- end
2482
- tail_tags.push(new_tag)
2081
+
2082
+ false
2083
+ end
2084
+
2085
+ ##
2086
+ ## @brief Record times for item tags
2087
+ ##
2088
+ ## @param item The item
2089
+ ##
2090
+ def record_tag_times(item, seconds)
2091
+ item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
2092
+ return if @recorded_items.include?(item_hash)
2093
+ item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2094
+ k = m[0] == 'done' ? 'All' : m[0].downcase
2095
+ if @timers.key?(k)
2096
+ @timers[k] += seconds
2097
+ else
2098
+ @timers[k] = seconds
2483
2099
  end
2100
+ @recorded_items.push(item_hash)
2484
2101
  end
2485
2102
  end
2486
- @results.push("Whitelisted tags: #{whitelisted.join(', ')}") if whitelisted.length > 0
2487
- if tail_tags.length > 0
2488
- tags = tail_tags.uniq.map { |t| '@' + t }.join(' ')
2489
- @results.push("Synonym tags: #{tags}")
2490
- text + ' ' + tags
2491
- else
2492
- text
2493
- end
2494
- end
2495
2103
 
2496
- private
2104
+ ##
2105
+ ## @brief Format human readable time from seconds
2106
+ ##
2107
+ ## @param seconds The seconds
2108
+ ##
2109
+ def fmt_time(seconds)
2110
+ return [0, 0, 0] if seconds.nil?
2497
2111
 
2498
- ##
2499
- ## @brief Gets the interval between entry's start date and @done date
2500
- ##
2501
- ## @param item (Hash) The entry
2502
- ## @param formatted (Bool) Return human readable time (default seconds)
2503
- ##
2504
- def get_interval(item, formatted: true, record: true)
2505
- done = nil
2506
- start = nil
2507
-
2508
- if @interval_cache.keys.include? item['title']
2509
- seconds = @interval_cache[item['title']]
2510
- record_tag_times(item, seconds) if record
2511
- return seconds > 0 ? '%02d:%02d:%02d' % fmt_time(seconds) : false
2112
+ if seconds.class == String && seconds =~ /(\d+):(\d+):(\d+)/
2113
+ h = Regexp.last_match(1)
2114
+ m = Regexp.last_match(2)
2115
+ s = Regexp.last_match(3)
2116
+ seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
2117
+ end
2118
+ minutes = (seconds / 60).to_i
2119
+ hours = (minutes / 60).to_i
2120
+ days = (hours / 24).to_i
2121
+ hours = (hours % 24).to_i
2122
+ minutes = (minutes % 60).to_i
2123
+ [days, hours, minutes]
2512
2124
  end
2513
2125
 
2514
- if item['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/
2515
- done = Time.parse(Regexp.last_match(1))
2516
- else
2517
- return false
2518
- end
2126
+ private
2519
2127
 
2520
- start = if item['title'] =~ /@start\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/
2521
- Time.parse(Regexp.last_match(1))
2522
- else
2523
- item['date']
2524
- end
2128
+ def run_after
2129
+ return unless @config.key?('run_after')
2525
2130
 
2526
- seconds = (done - start).to_i
2131
+ _, stderr, status = Open3.capture3(@config['run_after'])
2132
+ return unless status.exitstatus.positive?
2527
2133
 
2528
- if record
2529
- record_tag_times(item, seconds)
2134
+ logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
2135
+ logger.log_now(:error, 'STDERR output:', stderr)
2530
2136
  end
2531
2137
 
2532
- @interval_cache[item['title']] = seconds
2533
-
2534
- return seconds > 0 ? seconds : false unless formatted
2535
-
2536
- seconds > 0 ? '%02d:%02d:%02d' % fmt_time(seconds) : false
2537
- end
2538
-
2539
- ##
2540
- ## @brief Record times for item tags
2541
- ##
2542
- ## @param item The item
2543
- ##
2544
- def record_tag_times(item, seconds)
2545
- return if @recorded_items.include?(item)
2546
-
2547
- item['title'].scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2548
- k = m[0] == 'done' ? 'All' : m[0].downcase
2549
- if @timers.key?(k)
2550
- @timers[k] += seconds
2138
+ def log_change(tags_added: [], tags_removed: [], count: 1)
2139
+ if tags_added.empty? && tags_removed.empty?
2140
+ logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
2551
2141
  else
2552
- @timers[k] = seconds
2553
- end
2554
- @recorded_items.push(item)
2555
- end
2556
- end
2557
2142
 
2558
- ##
2559
- ## @brief Format human readable time from seconds
2560
- ##
2561
- ## @param seconds The seconds
2562
- ##
2563
- def fmt_time(seconds)
2564
- return [0, 0, 0] if seconds.nil?
2565
-
2566
- if seconds =~ /(\d+):(\d+):(\d+)/
2567
- h = Regexp.last_match(1)
2568
- m = Regexp.last_match(2)
2569
- s = Regexp.last_match(3)
2570
- seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
2571
- end
2572
- minutes = (seconds / 60).to_i
2573
- hours = (minutes / 60).to_i
2574
- days = (hours / 24).to_i
2575
- hours = (hours % 24).to_i
2576
- minutes = (minutes % 60).to_i
2577
- [days, hours, minutes]
2578
- end
2143
+ if tags_added.empty?
2144
+ logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
2145
+ # logger.debug('No tags added:', %("#{item.title}" in #{item.section}))
2146
+ else
2147
+ logger.count(:added_tags, tag: tags_added, message: '%tags added to %count %items')
2148
+ # logger.info('Added tags:', %(#{did_add} to "#{item.title}" in #{item.section}))
2149
+ end
2579
2150
 
2580
- def exec_available(cli)
2581
- if File.exists?(File.expand_path(cli))
2582
- File.executable?(File.expand_path(cli))
2583
- else
2584
- system "which #{cli}", :out => File::NULL, :err => File::NULL
2151
+ if tags_removed.empty?
2152
+ logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
2153
+ else
2154
+ logger.count(:removed_tags, tag: tags_removed, message: '%tags removed from %count %items')
2155
+ end
2156
+ end
2585
2157
  end
2586
2158
  end
2587
2159
  end