doing 1.0.90 → 2.0.2.pre

Sign up to get free protection for your applications and to get access to all the features.
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