doing 1.0.93 → 2.0.6.pre

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