doing 1.0.93 → 2.0.6.pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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