doing 1.0.92 → 2.0.5.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 +596 -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 +1012 -486
  12. data/doing.fish +278 -0
  13. data/doing.gemspec +34 -0
  14. data/doing.rdoc +1759 -0
  15. data/example_plugin.rb +209 -0
  16. data/generate_completions.sh +4 -0
  17. data/img/doing-colors.jpg +0 -0
  18. data/img/doing-printf-wrap-800.jpg +0 -0
  19. data/img/doing-show-note-formatting-800.jpg +0 -0
  20. data/lib/completion/_doing.zsh +151 -0
  21. data/lib/completion/doing.bash +416 -0
  22. data/lib/completion/doing.fish +278 -0
  23. data/lib/doing/array.rb +8 -0
  24. data/lib/doing/cli_status.rb +66 -0
  25. data/lib/doing/colors.rb +136 -0
  26. data/lib/doing/configuration.rb +312 -0
  27. data/lib/doing/errors.rb +102 -0
  28. data/lib/doing/hash.rb +31 -0
  29. data/lib/doing/hooks.rb +59 -0
  30. data/lib/doing/item.rb +155 -0
  31. data/lib/doing/log_adapter.rb +342 -0
  32. data/lib/doing/markdown_document_listener.rb +174 -0
  33. data/lib/doing/note.rb +59 -0
  34. data/lib/doing/pager.rb +95 -0
  35. data/lib/doing/plugin_manager.rb +208 -0
  36. data/lib/doing/plugins/export/csv_export.rb +48 -0
  37. data/lib/doing/plugins/export/html_export.rb +83 -0
  38. data/lib/doing/plugins/export/json_export.rb +140 -0
  39. data/lib/doing/plugins/export/markdown_export.rb +85 -0
  40. data/lib/doing/plugins/export/taskpaper_export.rb +34 -0
  41. data/lib/doing/plugins/export/template_export.rb +141 -0
  42. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  43. data/lib/doing/plugins/import/calendar_import.rb +76 -0
  44. data/lib/doing/plugins/import/doing_import.rb +144 -0
  45. data/lib/doing/plugins/import/timing_import.rb +78 -0
  46. data/lib/doing/string.rb +347 -0
  47. data/lib/doing/symbol.rb +16 -0
  48. data/lib/doing/time.rb +18 -0
  49. data/lib/doing/util.rb +186 -0
  50. data/lib/doing/version.rb +1 -1
  51. data/lib/doing/wwid.rb +1868 -2356
  52. data/lib/doing/wwidfile.rb +117 -0
  53. data/lib/doing.rb +44 -4
  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 +210 -0
  69. data/scripts/generate_fish_completions.rb +201 -0
  70. data/scripts/generate_zsh_completions.rb +164 -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,2192 @@ 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 Errors::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 Errors::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 Errors::EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
177
+
178
+ input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
179
+ title = input_lines[0]&.strip
180
+ raise Errors::EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
181
+
182
+ note = Note.new
183
+ note.add(input_lines[1..-1]) if input_lines.length > 1
184
+ # If title line ends in a parenthetical, use that as the note
185
+ if note.empty? && title =~ /\s+\(.*?\)$/
186
+ title.sub!(/\s+\((.*?)\)$/) do
187
+ m = Regexp.last_match
188
+ note.add(m[1])
189
+ ''
190
+ end
191
+ end
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 Errors::InvalidTimeExpression, "Invalid time expression #{input.inspect}" if input.to_s.strip == ''
213
+
214
+ secs_ago = if input.match(/^(\d+)$/)
215
+ # plain number, assume minutes
216
+ Regexp.last_match(1).to_i * 60
217
+ elsif (m = input.match(/^(?:(?<day>\d+)d)?(?:(?<hour>\d+)h)?(?:(?<min>\d+)m)?$/i))
218
+ # day/hour/minute format e.g. 1d2h30m
219
+ [[m['day'], 24 * 3600],
220
+ [m['hour'], 3600],
221
+ [m['min'], 60]].map { |qty, secs| qty ? (qty.to_i * secs) : 0 }.reduce(0, :+)
222
+ end
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
-
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
246
230
 
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
231
+ ##
232
+ ## @brief Converts simple strings into seconds that can be added to a Time
233
+ ## object
234
+ ##
235
+ ## @param qty (String) HH:MM or XX[dhm][[XXhm][XXm]] (1d2h30m, 45m,
236
+ ## 1.5d, 1h20m, etc.)
237
+ ##
238
+ ## @return (Integer) seconds
239
+ ##
240
+ def chronify_qty(qty)
241
+ minutes = 0
242
+ case qty.strip
243
+ when /^(\d+):(\d\d)$/
244
+ minutes += Regexp.last_match(1).to_i * 60
245
+ minutes += Regexp.last_match(2).to_i
246
+ when /^(\d+(?:\.\d+)?)([hmd])?$/
247
+ amt = Regexp.last_match(1)
248
+ type = Regexp.last_match(2).nil? ? 'm' : Regexp.last_match(2)
249
+
250
+ minutes = case type.downcase
251
+ when 'm'
252
+ amt.to_i
253
+ when 'h'
254
+ (amt.to_f * 60).round
255
+ when 'd'
256
+ (amt.to_f * 60 * 24).round
257
+ else
258
+ minutes
259
+ end
260
+ end
261
+ minutes * 60
262
+ end
263
+
264
+ ##
265
+ ## @brief List sections
266
+ ##
267
+ ## @return (Array) section titles
268
+ ##
269
+ def sections
270
+ @content.keys
271
+ end
272
+
273
+ ##
274
+ ## @brief Adds a section.
275
+ ##
276
+ ## @param title (String) The new section title
277
+ ##
278
+ def add_section(title)
279
+ if @content.key?(title.cap_first)
280
+ logger.debug('Skipped': 'Section already exists')
281
+ return
282
+ end
255
283
 
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
284
+ @content[title.cap_first] = { :original => "#{title}:", :items => [] }
285
+ logger.info('New section:', %("#{title.cap_first}"))
286
+ end
264
287
 
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?
288
+ ##
289
+ ## @brief Attempt to match a string with an existing section
290
+ ##
291
+ ## @param frag (String) The user-provided string
292
+ ## @param guessed (Boolean) already guessed and failed
293
+ ##
294
+ def guess_section(frag, guessed: false, suggest: false)
295
+ return 'All' if frag =~ /^all$/i
296
+ frag ||= @config['current_section']
271
297
 
272
- File.open(filename, 'w+') do |f|
273
- f.puts "#{@current_section}:"
274
- end
275
- end
298
+ sections.each { |sect| return sect.cap_first if frag.downcase == sect.downcase }
299
+ section = false
300
+ re = frag.split('').join('.*?')
301
+ sections.each do |sect|
302
+ next unless sect =~ /#{re}/i
276
303
 
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'])
304
+ logger.debug('Match:', %(Assuming "#{sect}" from "#{frag}"))
305
+ section = sect
306
+ break
307
+ end
284
308
 
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
309
+ return section if suggest
289
310
 
290
- pid = Process.fork { system("$EDITOR #{tmpfile.path}") }
311
+ unless section || guessed
312
+ alt = guess_view(frag, guessed: true, suggest: true)
313
+ if alt
314
+ meant_view = yn("Did you mean `doing view #{alt}`?", default_response: 'n')
315
+ raise Errors::InvalidSection, "Run again with `doing view #{alt}`" if meant_view
316
+ end
291
317
 
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
318
+ res = yn("Section #{frag} not found, create it", default_response: 'n')
302
319
 
303
- Process.wait(pid)
320
+ if res
321
+ add_section(frag.cap_first)
322
+ write(@doing_file)
323
+ return frag.cap_first
324
+ end
304
325
 
305
- begin
306
- if $?.exitstatus == 0
307
- input = IO.read(tmpfile.path)
326
+ raise Errors::InvalidSection, "Unknown section: #{frag}"
327
+ end
328
+ section ? section.cap_first : guessed
329
+ end
330
+
331
+ ##
332
+ ## @brief Ask a yes or no question in the terminal
333
+ ##
334
+ ## @param question (String) The question to ask
335
+ ## @param default (Bool) default response if no input
336
+ ##
337
+ ## @return (Bool) yes or no
338
+ ##
339
+ def yn(question, default_response: false)
340
+ if default_response.is_a?(String)
341
+ default = default_response =~ /y/i ? true : false
308
342
  else
309
- exit_now! 'Cancelled'
343
+ default = default_response
310
344
  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
345
 
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
346
+ # if global --default is set, answer default
347
+ return default if @default_option
342
348
 
343
- note.map!(&:strip)
344
- note.delete_if { |line| line =~ /^\s*$/ || line =~ /^#/ }
349
+ # if this isn't an interactive shell, answer default
350
+ return default unless $stdout.isatty
345
351
 
346
- [title, note]
347
- end
352
+ # clear the buffer
353
+ if ARGV&.length
354
+ ARGV.length.times do
355
+ ARGV.shift
356
+ end
357
+ end
358
+ system 'stty cbreak'
348
359
 
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
360
+ cw = Color.white
361
+ cbw = Color.boldwhite
362
+ cbg = Color.boldgreen
363
+ cd = Color.default
380
364
 
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
365
+ options = unless default.nil?
366
+ "#{cw}[#{default ? "#{cbg}Y#{cw}/#{cbw}n" : "#{cbw}y#{cw}/#{cbg}N"}#{cw}]#{cd}"
407
367
  else
408
- minutes
368
+ "#{cw}[#{cbw}y#{cw}/#{cbw}n#{cw}]#{cd}"
409
369
  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
370
+ $stdout.syswrite "#{cbw}#{question.sub(/\?$/, '')} #{options}#{cbw}?#{cd} "
371
+ res = $stdin.sysread 1
372
+ puts
373
+ system 'stty cooked'
374
+
375
+ res.chomp!
376
+ res.downcase!
377
+
378
+ return default if res.empty?
379
+
380
+ res =~ /y/i ? true : false
381
+ end
382
+
383
+ ##
384
+ ## @brief Attempt to match a string with an existing view
385
+ ##
386
+ ## @param frag (String) The user-provided string
387
+ ## @param guessed (Boolean) already guessed
388
+ ##
389
+ def guess_view(frag, guessed: false, suggest: false)
390
+ views.each { |view| return view if frag.downcase == view.downcase }
391
+ view = false
392
+ re = frag.split('').join('.*?')
393
+ views.each do |v|
394
+ next unless v =~ /#{re}/i
395
+
396
+ logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
397
+ view = v
398
+ break
399
+ end
400
+ unless view || guessed
401
+ guess = guess_section(frag, guessed: true, suggest: true)
402
+ exit_now! "Did you mean `doing show #{guess}`?" if guess
432
403
 
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
404
+ raise Errors::InvalidView, "Unknown view: #{frag}"
455
405
 
456
- res = yn("Section #{frag} not found, create it", default_response: false)
406
+ end
407
+ view
408
+ end
409
+
410
+ ##
411
+ ## @brief Adds an entry
412
+ ##
413
+ ## @param title (String) The entry title
414
+ ## @param section (String) The section to add to
415
+ ## @param opt (Hash) Additional Options {:date, :note, :back, :timed}
416
+ ##
417
+ def add_item(title, section = nil, opt = {})
418
+ section ||= @config['current_section']
419
+ add_section(section) unless @content.key?(section)
420
+ opt[:date] ||= Time.now
421
+ opt[:note] ||= []
422
+ opt[:back] ||= Time.now
423
+ opt[:timed] ||= false
424
+
425
+ opt[:note] = opt[:note].lines if opt[:note].is_a?(String)
426
+
427
+ title = [title.strip.cap_first]
428
+ title = title.join(' ')
429
+
430
+ if @auto_tag
431
+ title = autotag(title)
432
+ title.add_tags!(@config['default_tags']) unless @config['default_tags'].empty?
433
+ end
457
434
 
458
- if res
459
- add_section(frag.cap_first)
460
- write(@doing_file)
461
- return frag.cap_first
435
+ title.gsub!(/ +/, ' ')
436
+ entry = Item.new(opt[:back], title.strip, section)
437
+ entry.note = opt[:note].map(&:chomp) unless opt[:note].join('').strip == ''
438
+ items = @content[section][:items]
439
+ if opt[:timed]
440
+ items.reverse!
441
+ items.each_with_index do |i, x|
442
+ next if i.title =~ / @done/
443
+
444
+ items[x].title = "#{i.title} @done(#{opt[:back].strftime('%F %R')})"
445
+ break
446
+ end
447
+ items.reverse!
462
448
  end
463
449
 
464
- exit_now! "Unknown section: #{frag}"
450
+ items.push(entry)
451
+ logger.count(:added)
452
+ logger.debug('Entry added:', %("#{entry.title}" to #{section}))
465
453
  end
466
- section ? section.cap_first : guessed
467
- end
468
454
 
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'
455
+ ##
456
+ ## @brief Remove items from a list that already exist in @content
457
+ ##
458
+ ## @param items (Array) The items to deduplicate
459
+ ## @param no_overlap (Boolean) Remove items with overlapping time spans
460
+ ##
461
+ def dedup(items, no_overlap = false)
479
462
 
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
463
+ combined = []
464
+ @content.each do |_k, v|
465
+ combined += v[:items]
487
466
  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
467
 
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}"
468
+ items.delete_if do |item|
469
+ duped = false
470
+ combined.each do |comp|
471
+ duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
472
+ break if duped
473
+ end
474
+ logger.count(:skipped, level: :debug, message: 'overlapping entry') if duped
475
+ logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped
476
+ duped
537
477
  end
538
478
  end
539
- view
540
- end
541
479
 
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}"
480
+ ##
481
+ ## @brief Imports external entries
482
+ ##
483
+ ## @param path (String) Path to JSON report file
484
+ ## @param opt (Hash) Additional Options
485
+ ##
486
+ def import(paths, opt = {})
487
+ Plugins.plugins[:import].each do |_, options|
488
+ next unless opt[:type] =~ /^(#{options[:trigger].normalize_trigger})$/i
489
+
490
+ if paths.count.positive?
491
+ paths.each do |path|
492
+ options[:class].import(self, path, options: opt)
573
493
  end
494
+ else
495
+ options[:class].import(self, nil, options: opt)
574
496
  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
497
  break
590
498
  end
591
- items.reverse!
592
499
  end
593
- items.push(entry)
594
- @content[section]['items'] = items
595
- @results.push(%(Added "#{entry['title']}" to #{section}))
596
- end
597
500
 
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
501
+ ##
502
+ ## @brief Return the content of the last note for a given section
503
+ ##
504
+ ## @param section (String) The section to retrieve from, default
505
+ ## All
506
+ ##
507
+ def last_note(section = 'All')
508
+ section = guess_section(section)
601
509
 
602
- def overlapping_time?(item_a, item_b)
603
- return true if same_time?(item_a, item_b)
510
+ last_item = last_entry({ section: section })
604
511
 
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
512
+ raise Errors::NoEntryError, 'No entry found' unless last_item
613
513
 
614
- def dedup(items, no_overlap = false)
514
+ logger.log_now(:info, 'Edit note:', last_item.title)
615
515
 
616
- combined = []
617
- @content.each do |_k, v|
618
- combined += v['items']
516
+ note = last_item.note&.to_s || ''
517
+ "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
619
518
  end
620
519
 
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
520
+ def reset_item(item, resume: false)
521
+ item.date = Time.now
522
+ if resume
523
+ item.tag('done', remove: true)
626
524
  end
627
- # warn "Skipping overlapping entry: #{item['title']}" if duped
628
- duped
525
+ Doing.logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
526
+ item
629
527
  end
630
- end
631
528
 
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}")
529
+ def repeat_item(item, opt = {})
530
+ original = item.dup
531
+ if item.should_finish?
532
+ if item.should_time?
533
+ item.title.tag!('done', value: Time.now.strftime('%F %R'))
669
534
  else
670
- title += " @#{tag}"
535
+ item.title.tag!('done')
671
536
  end
672
537
  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
538
 
705
- exit_now! "Section #{section} not found" unless @content.key?(section)
539
+ # Remove @done tag
540
+ title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
541
+ section = opt[:in].nil? ? item.section : guess_section(opt[:in])
542
+ @auto_tag = false
706
543
 
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
544
+ note = opt[:note] || Note.new
713
545
 
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
546
+ if opt[:editor]
547
+ to_edit = title
548
+ to_edit += "\n#{note.to_s}" unless note.empty?
549
+ new_item = fork_editor(to_edit)
550
+ title, note = format_input(new_item)
742
551
 
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']
552
+ if title.nil? || title.empty?
553
+ logger.debug('Skipped:', 'No content provided')
554
+ return
761
555
  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
556
  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
557
 
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]) }
558
+ update_item(original, item)
559
+ add_item(title, section, { note: note, back: opt[:date], timed: true })
560
+ write(@doing_file)
778
561
  end
779
562
 
780
- all_items.max_by { |item| item['date'] }
781
- end
563
+ ##
564
+ ## @brief Restart the last entry
565
+ ##
566
+ ## @param opt (Hash) Additional Options
567
+ ##
568
+ def repeat_last(opt = {})
569
+ opt[:section] ||= 'all'
570
+ opt[:note] ||= []
571
+ opt[:tag] ||= []
572
+ opt[:tag_bool] ||= :and
573
+
574
+ last = last_entry(opt)
575
+ if last.nil?
576
+ logger.debug('Skipped:', 'No previous entry found')
577
+ return
578
+ end
782
579
 
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
580
+ repeat_item(last, opt)
581
+ end
800
582
 
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')
583
+ ##
584
+ ## @brief Get the last entry
585
+ ##
586
+ ## @param opt (Hash) Additional Options
587
+ ##
588
+ def last_entry(opt = {})
589
+ opt[:tag_bool] ||= :and
590
+ opt[:section] ||= @config['current_section']
808
591
 
809
- section = opt[:section] ? guess_section(opt[:section]) : 'All'
592
+ items = filter_items([], opt: opt)
810
593
 
594
+ logger.debug('Filtered:', "Parameters matched #{items.count} entries")
811
595
 
812
- if section =~ /^all$/i
813
- combined = { 'items' => [] }
814
- @content.each do |_k, v|
815
- combined['items'] += v['items']
596
+ if opt[:interactive]
597
+ last_entry = choose_from_items(items, {
598
+ menu: true,
599
+ header: '',
600
+ prompt: 'Select an entry > ',
601
+ multiple: false,
602
+ sort: false,
603
+ show_if_single: true
604
+ }, include_section: opt[:section] =~ /^all$/i )
605
+ else
606
+ last_entry = items.max_by { |item| item.date }
816
607
  end
817
- items = combined['items'].dup.sort_by { |item| item['date'] }.reverse
818
- else
819
- items = @content[section]['items']
608
+
609
+ last_entry
820
610
  end
821
611
 
612
+ ##
613
+ ## @brief Generate a menu of options and allow user selection
614
+ ##
615
+ ## @return (String) The selected option
616
+ ##
617
+ def choose_from(options, prompt: 'Make a selection: ', multiple: false, sorted: true, fzf_args: [])
618
+ return nil unless $stdout.isatty
822
619
 
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
620
+ fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
621
+ # fzf_args << '-1' # User is expecting a menu, and even if only one it seves as confirmation
622
+ fzf_args << %(--prompt "#{prompt}")
623
+ fzf_args << '--multi' if multiple
624
+ header = "esc: cancel,#{multiple ? ' tab: multi-select, ctrl-a: select all,' : ''} return: confirm"
625
+ fzf_args << %(--header "#{header}")
626
+ options.sort! if sorted
627
+ res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
628
+ return false if res.strip.size.zero?
853
629
 
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])
630
+ res
859
631
  end
860
632
 
861
- if selected.empty?
862
- @results.push("No selection")
863
- return
633
+ def all_tags(items, opt: {})
634
+ all_tags = []
635
+ items.each { |item| all_tags.concat(item.tags).uniq! }
636
+ all_tags.sort
864
637
  end
865
638
 
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
639
+ def tag_groups(items, opt: {})
640
+ all_items = filter_items(items, opt: opt)
641
+ tags = all_tags(all_items, opt: {})
642
+ tag_groups = {}
643
+ tags.each do |tag|
644
+ tag_groups[tag] ||= []
645
+ tag_groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
872
646
  end
873
- end
874
647
 
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
648
+ tag_groups
649
+ end
650
+
651
+ ##
652
+ ## @brief Filter items based on search criteria
653
+ ##
654
+ ## @param items (Array) The items to filter (if empty, filters all items)
655
+ ## @param opt (Hash) The filter parameters
656
+ ##
657
+ ## Available filter options in opt object
658
+ ##
659
+ ## - +:section+ (String)
660
+ ## - +:unfinished+ (Boolean)
661
+ ## - +:tag+ (Array or comma-separated string)
662
+ ## - +:tag_bool+ (:and, :or, :not)
663
+ ## - +:search+ (string, optional regex with //)
664
+ ## - +:date_filter+ (Array[(Time)start, (Time)end])
665
+ ## - +:only_timed+ (Boolean)
666
+ ## - +:before+ (Date/Time string, unparsed)
667
+ ## - +:after+ (Date/Time string, unparsed)
668
+ ## - +:today+ (Boolean)
669
+ ## - +:yesterday+ (Boolean)
670
+ ## - +:count+ (Number to return)
671
+ ## - +:age+ (String, 'old' or 'new')
672
+ ##
673
+ def filter_items(items = [], opt: {})
674
+ if items.nil? || items.empty?
675
+ section = opt[:section] ? guess_section(opt[:section]) : 'All'
676
+
677
+ items = if section =~ /^all$/i
678
+ @content.each_with_object([]) { |(_k, v), arr| arr.concat(v[:items].dup) }
679
+ else
680
+ @content[section][:items].dup
681
+ end
933
682
  end
934
- end
935
683
 
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
684
+ items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
685
+ filtered_items = items.select do |item|
686
+ keep = true
687
+ finished = opt[:unfinished] && item.tags?('done', :and)
688
+ keep = false if finished
944
689
 
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)
690
+ if keep && opt[:tag]
691
+ opt[:tag_bool] ||= :and
692
+ tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
693
+ keep = false unless tag_match
952
694
  end
953
- end
954
- end
955
695
 
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])
696
+ if keep && opt[:search]
697
+ search_match = opt[:search].nil? || opt[:search].empty? ? true : item.search(opt[:search])
698
+ keep = false unless search_match
963
699
  end
964
- end
965
- end
966
700
 
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)
701
+ if keep && opt[:date_filter]&.length == 2
702
+ start_date = opt[:date_filter][0]
703
+ end_date = opt[:date_filter][1]
704
+
705
+ in_date_range = if end_date
706
+ item.date >= start_date && item.date <= end_date
707
+ else
708
+ item.date.strftime('%F') == start_date.strftime('%F')
709
+ end
710
+ keep = false unless in_date_range
974
711
  end
975
- end
976
- end
977
712
 
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
713
+ keep = false if keep && opt[:only_timed] && !item.interval
714
+
715
+ if keep && opt[:tag_filter] && !opt[:tag_filter]['tags'].empty?
716
+ keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
717
+ end
718
+
719
+ if keep && opt[:before]
720
+ time_string = opt[:before]
721
+ cutoff = chronify(time_string, guess: :begin)
722
+ keep = cutoff && item.date <= cutoff
723
+ end
982
724
 
983
- write(@doing_file)
725
+ if keep && opt[:after]
726
+ time_string = opt[:after]
727
+ cutoff = chronify(time_string, guess: :end)
728
+ keep = cutoff && item.date >= cutoff
729
+ end
984
730
 
985
- if opt[:editor]
731
+ if keep && opt[:today]
732
+ keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
733
+ elsif keep && opt[:yesterday]
734
+ keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
735
+ end
986
736
 
987
- editable_items = []
737
+ keep
738
+ end
739
+ count = opt[:count] && opt[:count].positive? ? opt[:count] : filtered_items.length
988
740
 
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
741
+ if opt[:age] =~ /^o/i
742
+ filtered_items.slice(0, count).reverse
743
+ else
744
+ filtered_items.reverse.slice(0, count)
994
745
  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
746
 
998
- new_items = fork_editor(input).split(/#{divider}/)
747
+ end
999
748
 
1000
- new_items.each_with_index do |new_item, i|
749
+ ##
750
+ ## @brief Display an interactive menu of entries
751
+ ##
752
+ ## @param opt (Hash) Additional options
753
+ ##
754
+ def interactive(opt = {})
755
+ section = opt[:section] ? guess_section(opt[:section]) : 'All'
756
+ opt[:query] = opt[:search] if opt[:search] && !opt[:query]
757
+ opt[:multiple] = true
758
+ items = filter_items([], opt: { section: section, search: opt[:search] })
1001
759
 
1002
- input_lines = new_item.split(/[\n\r]+/).delete_if {|line| line =~ /^#/ || line =~ /^\s*$/ }
1003
- title = input_lines[0]&.strip
760
+ selection = choose_from_items(items, opt, include_section: section =~ /^all$/i)
1004
761
 
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] : []
762
+ if selection.empty?
763
+ logger.debug('Skipped:', 'No selection')
764
+ return
765
+ end
766
+
767
+ act_on(selection, opt)
768
+ end
769
+
770
+ def choose_from_items(items, opt = {}, include_section: false)
771
+ return nil unless $stdout.isatty
1009
772
 
1010
- note.map!(&:strip)
1011
- note.delete_if { |line| line =~ /^\s*$/ || line =~ /^#/ }
773
+ return nil unless items.count.positive?
1012
774
 
1013
- date = title.match(/^([\d\-: ]+) \| /)[1]
1014
- title.sub!(/^([\d\-: ]+) \| /, '')
775
+ opt[:header] ||= "Arrows: navigate, tab: mark for selection, ctrl-a: select all, enter: commit"
776
+ opt[:prompt] ||= "Select entries to act on > "
1015
777
 
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)
778
+ pad = items.length.to_s.length
779
+ options = items.map.with_index do |item, i|
780
+ out = [
781
+ format("%#{pad}d", i),
782
+ ') ',
783
+ format('%13s', item.date.relative_date),
784
+ ' | ',
785
+ item.title
786
+ ]
787
+ if include_section
788
+ out.concat([
789
+ ' (',
790
+ item.section,
791
+ ') '
792
+ ])
1021
793
  end
794
+ out.join('')
1022
795
  end
1023
796
 
1024
- write(@doing_file)
1025
- end
797
+ fzf = File.join(File.dirname(__FILE__), '../helpers/fuzzyfilefinder')
1026
798
 
1027
- if opt[:output]
1028
- selected.map! do |item|
1029
- item['title'] = "#{item['title']} @project(#{item['section']})"
1030
- item
1031
- end
799
+ fzf_args = [
800
+ %(--header="#{opt[:header]}"),
801
+ %(--prompt="#{opt[:prompt].sub(/ *$/, ' ')}"),
802
+ opt[:multiple] ? '--multi' : '--no-multi',
803
+ '-0',
804
+ '--bind ctrl-a:select-all',
805
+ %(-q "#{opt[:query]}")
806
+ ]
807
+ fzf_args.push('-1') unless opt[:show_if_single]
1032
808
 
1033
- @content = { 'Export' => { 'original' => 'Export:', 'items' => selected } }
1034
- options = { section: 'Export' }
809
+ unless opt[:menu]
810
+ raise Errors::InvalidArgument, "Can't skip menu when no query is provided" unless opt[:query]
1035
811
 
1036
- case opt[:output]
1037
- when /doing/
1038
- options[:template] = '- %date | %title%note'
1039
- else
1040
- options[:output] = opt[:output]
812
+ fzf_args.concat([%(--filter="#{opt[:query]}"), opt[:sort] ? '' : '--no-sort'])
1041
813
  end
1042
814
 
1043
- output = list_section(options)
815
+ res = `echo #{Shellwords.escape(options.join("\n"))}|#{fzf} #{fzf_args.join(' ')}`
816
+ selected = []
817
+ res.split(/\n/).each do |item|
818
+ idx = item.match(/^ *(\d+)\)/)[1].to_i
819
+ selected.push(items[idx])
820
+ end
1044
821
 
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
822
+ opt[:multiple] ? selected : selected[0]
823
+ end
1051
824
 
1052
- File.open(file, 'w+') do |f|
1053
- f.puts output
825
+ def act_on(items, opt = {})
826
+ actions = %i[editor delete tag flag finish cancel archive output save_to]
827
+ has_action = false
828
+ actions.each do |a|
829
+ if opt[a]
830
+ has_action = true
831
+ break
1054
832
  end
1055
-
1056
- @results.push("Export saved to #{file}")
1057
- else
1058
- puts output
1059
833
  end
1060
- end
1061
- end
1062
834
 
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'
835
+ unless has_action
836
+ actions = [
837
+ 'add tag',
838
+ 'remove tag',
839
+ 'cancel',
840
+ 'delete',
841
+ 'finish',
842
+ 'flag',
843
+ 'archive',
844
+ 'move',
845
+ 'edit',
846
+ 'output formatted'
847
+ ]
848
+
849
+ actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
850
+
851
+ choice = choose_from(actions,
852
+ prompt: 'What do you want to do with the selected items? > ',
853
+ multiple: true,
854
+ sorted: false,
855
+ fzf_args: ['--height=60%', '--tac', '--no-sort'])
856
+ return unless choice
857
+
858
+ to_do = choice.strip.split(/\n/)
859
+ to_do.each do |action|
860
+ case action
861
+ when /resume/
862
+ opt[:resume] = true
863
+ when /reset/
864
+ opt[:reset] = true
865
+ when /(add|remove) tag/
866
+ type = action =~ /^add/ ? 'add' : 'remove'
867
+ raise Errors::InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
868
+
869
+ print "#{Color.yellow}Tag to #{type}: #{Color.reset}"
870
+ tag = $stdin.gets
871
+ next if tag =~ /^ *$/
872
+
873
+ opt[:tag] = tag.strip.sub(/^@/, '')
874
+ opt[:remove] = true if type == 'remove'
875
+ when /output formatted/
876
+ output_format = choose_from(Plugins.available_plugins(type: :export).sort,
877
+ prompt: 'Which output format? > ',
878
+ fzf_args: ['--height=60%', '--tac', '--no-sort'])
879
+ next if tag =~ /^ *$/
880
+
881
+ unless output_format
882
+ raise Errors::UserCancelled, 'Cancelled'
883
+ end
884
+
885
+ opt[:output] = output_format.strip
886
+ res = opt[:force] ? false : yn('Save to file?', default_response: 'n')
887
+ if res
888
+ print "#{Color.yellow}File path/name: #{Color.reset}"
889
+ filename = $stdin.gets.strip
890
+ next if filename.empty?
891
+
892
+ opt[:save_to] = filename
893
+ end
894
+ when /archive/
895
+ opt[:archive] = true
896
+ when /delete/
897
+ opt[:delete] = true
898
+ when /edit/
899
+ opt[:editor] = true
900
+ when /finish/
901
+ opt[:finish] = true
902
+ when /cancel/
903
+ opt[:cancel] = true
904
+ when /move/
905
+ section = choose_section.strip
906
+ opt[:move] = section.strip unless section =~ /^ *$/
907
+ when /flag/
908
+ opt[:flag] = true
1103
909
  end
1104
- else
1105
- sec_arr = sections
1106
910
  end
1107
- else
1108
- sec_arr = [guess_section(opt[:section])]
1109
911
  end
1110
- end
1111
912
 
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
913
+ if opt[:resume] || opt[:reset]
914
+ if items.count > 1
915
+ logger.error('Error:', 'resume and restart can only be used on a single entry')
916
+ else
917
+ item = items[0]
918
+ if opt[:resume] && !opt[:reset]
919
+ repeat_item(item, { editor: opt[:editor] })
920
+ elsif opt[:reset]
921
+ if item.tags?('done', :and) && !opt[:resume]
922
+ res = opt[:force] ? true : yn('Remove @done tag?', default_response: 'y')
1134
923
  else
1135
- if opt[:sequential]
1136
- next_entry = next_item(item)
924
+ res = opt[:resume]
925
+ end
926
+ update_item(item, reset_item(item, resume: res))
927
+ end
928
+ write(@doing_file)
929
+ end
930
+ return
931
+ end
1137
932
 
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
933
+ if opt[:delete]
934
+ res = opt[:force] ? true : yn("Delete #{items.size} items?", default_response: 'y')
935
+ if res
936
+ items.each { |item| delete_item(item) }
937
+ write(@doing_file)
938
+ end
939
+ return
940
+ end
1159
941
 
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
942
+ if opt[:flag]
943
+ tag = @config['marker_tag'] || 'flagged'
944
+ items.map! do |item|
945
+ tag_item(item, tag, date: false, remove: opt[:remove])
946
+ end
947
+ end
1170
948
 
1171
- if opt[:regex]
1172
- rx_tag = tag.gsub(/\./, '\S')
1173
- else
1174
- rx_tag = tag.gsub(/\?/, '.').gsub(/\*/, '\S*?')
1175
- end
949
+ if opt[:finish] || opt[:cancel]
950
+ tag = 'done'
951
+ items.map! do |item|
952
+ if item.should_finish?
953
+ should_date = !opt[:cancel] && item.should_time?
954
+ tag_item(item, tag, date: should_date, remove: opt[:remove])
955
+ end
956
+ end
957
+ end
1176
958
 
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
959
+ if opt[:tag]
960
+ tag = opt[:tag]
961
+ items.map! do |item|
962
+ tag_item(item, tag, date: false, remove: opt[:remove])
963
+ end
964
+ end
1184
965
 
1185
- title.dedup_tags!
966
+ if opt[:archive] || opt[:move]
967
+ section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
968
+ items.map! {|item| move_item(item, section) }
969
+ end
1186
970
 
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
971
+ write(@doing_file)
1201
972
 
1202
- idx += 1
1203
- end
973
+ if opt[:editor]
1204
974
 
1205
- item
1206
- end
975
+ editable_items = []
1207
976
 
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?
977
+ items.each do |item|
978
+ editable = "#{item.date} | #{item.title}"
979
+ old_note = item.note ? item.note.to_s : nil
980
+ editable += "\n#{old_note}" unless old_note.nil?
981
+ editable_items << editable
1225
982
  end
1226
- else
1227
- exit_now! "Section not found: #{section}"
1228
- end
1229
- end
983
+ divider = "\n-----------\n"
984
+ input = editable_items.map(&:strip).join(divider) + "\n\n# You may delete entries, but leave all divider lines in place"
1230
985
 
1231
- write(@doing_file)
1232
- end
986
+ new_items = fork_editor(input).split(/#{divider}/)
1233
987
 
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
988
+ new_items.each_with_index do |new_item, i|
1260
989
 
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)
990
+ input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
991
+ title = input_lines[0]&.strip
1273
992
 
1274
- if idx > 0
1275
- items[idx - 1]
1276
- else
1277
- nil
1278
- end
1279
- end
993
+ if title.nil? || title =~ /^#{divider.strip}$/ || title.strip.empty?
994
+ delete_item(items[i])
995
+ else
996
+ note = input_lines.length > 1 ? input_lines[1..-1] : []
1280
997
 
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']
998
+ note.map!(&:strip)
999
+ note.delete_if(&:ignore?)
1288
1000
 
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
1001
+ date = title.match(/^([\d\-: ]+) \| /)[1]
1002
+ title.sub!(/^([\d\-: ]+) \| /, '')
1294
1003
 
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
1004
+ item = items[i]
1005
+ item.title = title
1006
+ item.note = note
1007
+ item.date = Time.parse(date) || items[i].date
1008
+ end
1009
+ end
1306
1010
 
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
1011
+ write(@doing_file)
1318
1012
  end
1319
- end
1320
- end
1321
1013
 
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
1014
+ if opt[:output]
1015
+ items.map! do |item|
1016
+ item.title = "#{item.title} @project(#{item.section})"
1017
+ item
1018
+ end
1019
+
1020
+ @content = { 'Export' => { :original => 'Export:', :items => items } }
1021
+ options = { section: 'Export' }
1334
1022
 
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')})"
1023
+
1024
+ if opt[:output] =~ /doing/
1025
+ options[:output] = 'template'
1026
+ options[:template] = '- %date | %title%note'
1341
1027
  else
1342
- title += " @#{tag}"
1028
+ options[:output] = opt[:output]
1029
+ options[:template] = opt[:template] || nil
1343
1030
  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
1031
 
1364
- section_items = @content[section]['items']
1365
- s_idx = section_items.index(old_item)
1032
+ output = list_section(options)
1366
1033
 
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
1034
+ if opt[:save_to]
1035
+ file = File.expand_path(opt[:save_to])
1036
+ if File.exist?(file)
1037
+ # Create a backup copy for the undo command
1038
+ FileUtils.cp(file, "#{file}~")
1039
+ end
1371
1040
 
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)
1041
+ File.open(file, 'w+') do |f|
1042
+ f.puts output
1043
+ end
1379
1044
 
1380
- if section =~ /^all$/i
1381
- items = []
1382
- @content.each do |_k, v|
1383
- items.concat(v['items'])
1045
+ logger.warn('File written:', file)
1046
+ else
1047
+ Doing::Pager.page output
1048
+ end
1384
1049
  end
1385
- # section = combined['items'].dup.sort_by { |item| item['date'] }.reverse[0]['section']
1386
- else
1387
- items = @content[section]['items']
1388
1050
  end
1389
1051
 
1390
- items = items.sort_by { |item| item['date'] }.reverse
1052
+ ##
1053
+ ## @brief Tag an item from the index
1054
+ ##
1055
+ ## @param item (Item) The item to tag
1056
+ ## @param tags (string) The tag to apply
1057
+ ## @param remove (Boolean) remove tags
1058
+ ## @param date (Boolean) Include timestamp?
1059
+ ##
1060
+ def tag_item(item, tags, remove: false, date: false)
1061
+ added = []
1062
+ removed = []
1391
1063
 
1392
- idx = nil
1064
+ tags = tags.to_tags if tags.is_a? ::String
1393
1065
 
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
1399
- end
1400
- 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
1066
+ done_date = Time.now
1067
+
1068
+ tags.each do |tag|
1069
+ bool = remove ? :and : :not
1070
+ if item.tags?(tag, bool)
1071
+ item.tag(tag, remove: remove, value: date ? done_date.strftime('%F %R') : nil)
1072
+ remove ? removed.push(tag) : added.push(tag)
1406
1073
  end
1407
1074
  end
1408
- else
1409
- idx = 0
1410
- end
1411
1075
 
1412
- if idx.nil?
1413
- @results.push('No entries found')
1414
- return
1415
- end
1076
+ log_change(tags_added: added, tags_removed: removed, count: 1)
1077
+
1078
+ item
1079
+ end
1080
+
1081
+ ##
1082
+ ## @brief Tag the last entry or X entries
1083
+ ##
1084
+ ## @param opt (Hash) Additional Options
1085
+ ##
1086
+ def tag_last(opt = {})
1087
+ opt[:count] ||= 1
1088
+ opt[:archive] ||= false
1089
+ opt[:tags] ||= ['done']
1090
+ opt[:sequential] ||= false
1091
+ opt[:date] ||= false
1092
+ opt[:remove] ||= false
1093
+ opt[:autotag] ||= false
1094
+ opt[:back] ||= false
1095
+ opt[:unfinished] ||= false
1096
+ opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
1097
+
1098
+ items = filter_items([], opt: opt)
1099
+
1100
+ logger.info('Skipped:', 'no items matched your search') if items.empty?
1101
+
1102
+ if opt[:interactive]
1103
+ items = choose_from_items(items, {
1104
+ menu: true,
1105
+ header: '',
1106
+ prompt: 'Select entries to tag > ',
1107
+ multiple: true,
1108
+ sort: true,
1109
+ show_if_single: true
1110
+ }, include_section: opt[:section] =~ /^all$/i )
1111
+
1112
+ return if items.nil?
1113
+ end
1416
1114
 
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
1115
+ items.each do |item|
1116
+ added = []
1117
+ removed = []
1118
+
1119
+ if opt[:autotag]
1120
+ new_title = autotag(item.title) if @auto_tag
1121
+ if new_title == item.title
1122
+ logger.count(:skipped, level: :debug, message: '%count unchaged %items')
1123
+ # logger.debug('Autotag:', 'No changes')
1124
+ else
1125
+ logger.count(:added_tags)
1126
+ logger.debug('Tags updated:', new_title)
1127
+ item.title = new_title
1128
+ end
1129
+ else
1130
+ if opt[:sequential]
1131
+ next_entry = next_item(item)
1132
+
1133
+ done_date = if next_entry.nil?
1134
+ Time.now
1135
+ else
1136
+ next_entry.date - 60
1137
+ end
1138
+ elsif opt[:took]
1139
+ if item.date + opt[:took] > Time.now
1140
+ item.date = Time.now - opt[:took]
1141
+ done_date = Time.now
1142
+ else
1143
+ done_date = item.date + opt[:took]
1144
+ end
1145
+ elsif opt[:back]
1146
+ done_date = if opt[:back].is_a? Integer
1147
+ item.date + opt[:back]
1148
+ else
1149
+ item.date + (opt[:back] - item.date)
1150
+ end
1151
+ else
1152
+ done_date = Time.now
1153
+ end
1440
1154
 
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)
1155
+ opt[:tags].each do |tag|
1156
+ if tag == 'done' && !item.should_finish?
1450
1157
 
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
1158
+ Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
1159
+ logger.count(:skipped, level: :debug)
1160
+ next
1161
+ end
1458
1162
 
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
1163
+ tag = tag.strip
1164
+ if opt[:remove] || opt[:rename]
1165
+ rename_to = nil
1166
+ if opt[:rename]
1167
+ rename_to = tag
1168
+ tag = opt[:rename]
1169
+ end
1170
+ old_title = item.title.dup
1171
+ item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex])
1172
+ if old_title != item.title
1173
+ removed << tag
1174
+ added << rename_to if rename_to
1175
+ else
1176
+ logger.count(:skipped, level: :debug)
1177
+ end
1178
+ else
1179
+ old_title = item.title.dup
1180
+ should_date = opt[:date] && item.should_time?
1181
+ item.title.tag!('done', remove: true) if tag =~ /done/ && !should_date
1182
+ item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
1183
+ added << tag if old_title != item.title
1184
+ end
1185
+ end
1186
+ end
1485
1187
 
1486
- @content[section]['items'] = items
1188
+ log_change(tags_added: added, tags_removed: removed)
1487
1189
 
1488
- end
1190
+ item.note.add(opt[:note]) if opt[:note]
1489
1191
 
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']}"))
1192
+ if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1193
+ move_item(item, 'Archive', label: true)
1194
+ elsif opt[:archive] && opt[:count].zero?
1195
+ logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1196
+ end
1530
1197
  end
1198
+
1199
+ write(@doing_file)
1531
1200
  end
1532
1201
 
1533
- @results.push("No active @#{tag} tasks found.") if found_items == 0
1202
+ ##
1203
+ ## @brief Move item from current section to
1204
+ ## destination section
1205
+ ##
1206
+ ## @param item The item
1207
+ ## @param section The destination section
1208
+ ##
1209
+ ## @return Updated item
1210
+ ##
1211
+ def move_item(item, section, label: true)
1212
+ from = item.section
1213
+ new_item = @content[item.section][:items].delete(item)
1214
+ new_item.title.sub!(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{from})") if label
1215
+ new_item.section = section
1216
+
1217
+ @content[section][:items].concat([new_item])
1218
+
1219
+ logger.count(section == 'Archive' ? :archived : :moved)
1220
+ logger.debug("Entry #{section == 'Archive' ? 'archived' : 'moved'}:",
1221
+ "#{new_item.title.truncate(60)} from #{from} to #{section}")
1222
+ new_item
1223
+ end
1224
+
1225
+ ##
1226
+ ## @brief Get next item in the index
1227
+ ##
1228
+ ## @param item
1229
+ ##
1230
+ def next_item(item, options = {})
1231
+ items = filter_items([], opt: options)
1232
+
1233
+ idx = items.index(item)
1234
+
1235
+ idx.positive? ? items[idx - 1] : nil
1236
+ end
1237
+
1238
+ ##
1239
+ ## @brief Delete an item from the index
1240
+ ##
1241
+ ## @param item The item
1242
+ ##
1243
+ def delete_item(item)
1244
+ section = item.section
1245
+
1246
+ section_items = @content[section][:items]
1247
+ deleted = section_items.delete(item)
1248
+ logger.count(:deleted)
1249
+ logger.debug('Entry deleted:', deleted.title)
1250
+ end
1251
+
1252
+ ##
1253
+ ## @brief Update an item in the index with a modified item
1254
+ ##
1255
+ ## @param old_item The old item
1256
+ ## @param new_item The new item
1257
+ ##
1258
+ def update_item(old_item, new_item)
1259
+ section = old_item.section
1260
+
1261
+ section_items = @content[section][:items]
1262
+ s_idx = section_items.index { |item| item.equal?(old_item) }
1263
+
1264
+ unless s_idx
1265
+ Doing.logger.error('Fail to update:', 'Could not find item in index')
1266
+ raise Errors::ItemNotFound, 'Unable to find item in index, did it mutate?'
1267
+ end
1268
+
1269
+ return if section_items[s_idx].equal?(new_item)
1534
1270
 
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] })
1271
+ section_items[s_idx] = new_item
1272
+ logger.count(:updated)
1273
+ logger.debug('Entry updated:', section_items[s_idx].title.truncate(60))
1274
+ new_item
1540
1275
  end
1541
1276
 
1542
- write(@doing_file)
1543
- end
1277
+ ##
1278
+ ## @brief Edit the last entry
1279
+ ##
1280
+ ## @param section (String) The section, default "All"
1281
+ ##
1282
+ def edit_last(section: 'All', options: {})
1283
+ options[:section] = guess_section(section)
1544
1284
 
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" : ''
1285
+ item = last_entry(options)
1552
1286
 
1553
- @content.each do |title, section|
1554
- output += "#{section['original']}\n"
1555
- output += list_section({ section: title, template: "\t- %date | %title%note", highlight: false })
1556
- 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}~")
1287
+ if item.nil?
1288
+ logger.debug('Skipped:', 'No entries found')
1289
+ return
1565
1290
  end
1566
1291
 
1567
- File.open(file, 'w+') do |f|
1568
- f.puts output
1569
- end
1292
+ content = [item.title.dup]
1293
+ content << item.note.to_s unless item.note.empty?
1294
+ new_item = fork_editor(content.join("\n"))
1295
+ title, note = format_input(new_item)
1570
1296
 
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
1297
+ if title.nil? || title.empty?
1298
+ logger.debug('Skipped:', 'No content provided')
1299
+ elsif title == item.title && note.equal?(item.note)
1300
+ logger.debug('Skipped:', 'No change in content')
1301
+ else
1302
+ item.title = title
1303
+ item.note.add(note, replace: true)
1304
+ logger.info('Edited:', item.title)
1305
+
1306
+ write(@doing_file)
1577
1307
  end
1578
1308
  end
1579
- end
1580
1309
 
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
1310
+ ##
1311
+ ## @brief Accepts one tag and the raw text of a new item if the passed tag
1312
+ ## is on any item, it's replaced with @done. if new_item is not
1313
+ ## nil, it's tagged with the passed tag and inserted. This is for
1314
+ ## use where only one instance of a given tag should exist
1315
+ ## (@meanwhile)
1316
+ ##
1317
+ ## @param tag (String) Tag to replace
1318
+ ## @param opt (Hash) Additional Options
1319
+ ##
1320
+ def stop_start(target_tag, opt = {})
1321
+ tag = target_tag.dup
1322
+ opt[:section] ||= @config['current_section']
1323
+ opt[:archive] ||= false
1324
+ opt[:back] ||= Time.now
1325
+ opt[:new_item] ||= false
1326
+ opt[:note] ||= false
1593
1327
 
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
1328
+ opt[:section] = guess_section(opt[:section])
1609
1329
 
1610
- counter = 0
1611
- new_content = {}
1330
+ tag.sub!(/^@/, '')
1612
1331
 
1332
+ found_items = 0
1613
1333
 
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'] = []
1334
+ @content[opt[:section]][:items].each_with_index do |item, i|
1335
+ next unless item.title =~ /@#{tag}/
1619
1336
 
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
1627
-
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 = []
1337
+ item.title.add_tags!([tag, 'done'], remove: true)
1338
+ item.tag('done', value: opt[:back].strftime('%F %R'))
1643
1339
 
1644
- count = items.length if items.length < count
1340
+ found_items += 1
1645
1341
 
1646
- if items.count > count
1647
- moved_items.concat(items[count..-1])
1342
+ if opt[:archive] && opt[:section] != 'Archive'
1343
+ item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1344
+ move_item(item, 'Archive', label: false)
1345
+ logger.count(:completed_archived)
1346
+ logger.debug('Completed/archived:', item.title)
1648
1347
  else
1649
- moved_items.concat(items)
1348
+ logger.count(:completed)
1349
+ logger.debug('Completed:', item.title)
1650
1350
  end
1351
+ end
1651
1352
 
1652
- @content[section]['items'] = if count.zero?
1653
- []
1654
- else
1655
- items[0..count - 1]
1656
- end
1657
- new_content[section]['items'] = moved_items
1353
+ logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1658
1354
 
1659
- @results.push("Rotated #{items.length - count} items from #{section}")
1355
+ if opt[:new_item]
1356
+ title, note = format_input(opt[:new_item])
1357
+ note.add(opt[:note]) if opt[:note]
1358
+ title.tag!(tag)
1359
+ add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
1660
1360
  end
1361
+
1362
+ write(@doing_file)
1661
1363
  end
1662
1364
 
1663
- write(@doing_file)
1365
+ ##
1366
+ ## @brief Write content to file or STDOUT
1367
+ ##
1368
+ ## @param file (String) The filepath to write to
1369
+ ##
1370
+ def write(file = nil, backup: true)
1371
+ Hooks.trigger :pre_write, self, file
1372
+ output = wrapped_content
1664
1373
 
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
1374
+ if file.nil?
1375
+ $stdout.puts output
1376
+ else
1377
+ Util.write_to_file(file, output, backup: backup)
1378
+ run_after if @config.key?('run_after')
1379
+ end
1671
1380
  end
1672
1381
 
1673
- write(file, backup: false)
1674
- end
1382
+ def wrapped_content
1383
+ output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
1675
1384
 
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
1685
-
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
1385
+ @content.each do |title, section|
1386
+ output += "#{section[:original]}\n"
1387
+ output += list_section({ section: title, template: "\t- %date | %title%t2note", highlight: false, wrap_width: 0 })
1388
+ end
1694
1389
 
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
1390
+ output + @other_content_bottom.join("\n") unless @other_content_bottom.nil?
1391
+ end
1704
1392
 
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)
1393
+ ##
1394
+ ## @brief Restore a backed up version of a file
1395
+ ##
1396
+ ## @param file (String) The filepath to restore
1397
+ ##
1398
+ def restore_backup(file)
1399
+ if File.exist?("#{file}~")
1400
+ FileUtils.cp("#{file}~", file)
1401
+ logger.warn('File update:', "Restored #{file.sub(/^#{@user_home}/, '~')}")
1402
+ else
1403
+ logger.error('Restore error:', 'No backup file found')
1404
+ end
1405
+ end
1712
1406
 
1713
- false
1714
- end
1407
+ ##
1408
+ ## @brief Rename doing file with date and start fresh one
1409
+ ##
1410
+ def rotate(opt = {})
1411
+ keep = opt[:keep] || 0
1412
+ tags = []
1413
+ tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
1414
+ bool = opt[:bool] || :and
1415
+ sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1715
1416
 
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']
1753
- 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
1417
+ if sect =~ /^all$/i
1418
+ all_sections = sections.dup
1762
1419
  else
1763
- section = guess_section(opt[:section])
1764
- opt[:section] = @content[section]
1420
+ all_sections = [sect]
1765
1421
  end
1766
- end
1767
1422
 
1768
- exit_now! 'Invalid section object' unless opt[:section].instance_of? Hash
1423
+ counter = 0
1424
+ new_content = {}
1425
+
1769
1426
 
1770
- items = opt[:section]['items'].sort_by { |item| item['date'] }
1427
+ all_sections.each do |section|
1428
+ items = @content[section][:items].dup
1429
+ new_content[section] = {}
1430
+ new_content[section][:original] = @content[section][:original]
1431
+ new_content[section][:items] = []
1432
+
1433
+ moved_items = []
1434
+ if !tags.empty? || opt[:search] || opt[:before]
1435
+ if opt[:before]
1436
+ time_string = opt[:before]
1437
+ cutoff = chronify(time_string, guess: :begin)
1438
+ end
1771
1439
 
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
1440
+ items.delete_if do |item|
1441
+ if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1442
+ moved_items.push(item)
1443
+ counter += 1
1444
+ true
1445
+ else
1446
+ false
1447
+ end
1448
+ end
1449
+ @content[section][:items] = items
1450
+ new_content[section][:items] = moved_items
1451
+ logger.warn('Rotated:', "#{moved_items.length} items from #{section}")
1778
1452
  else
1779
- item['date'].strftime('%F') == start_date.strftime('%F')
1780
- end
1781
- end
1782
- end
1453
+ new_content[section][:items] = []
1454
+ moved_items = []
1783
1455
 
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']) }
1786
- end
1456
+ count = items.length < keep ? items.length : keep
1787
1457
 
1788
- if opt[:search]
1789
- items.keep_if {|item| item.matches_search?(opt[:search]) }
1790
- end
1458
+ if items.count > count
1459
+ moved_items.concat(items[count..-1])
1460
+ else
1461
+ moved_items.concat(items)
1462
+ end
1791
1463
 
1792
- if opt[:only_timed]
1793
- items.delete_if do |item|
1794
- get_interval(item, record: false) == false
1464
+ @content[section][:items] = if count.zero?
1465
+ []
1466
+ else
1467
+ items[0..count - 1]
1468
+ end
1469
+ new_content[section][:items] = moved_items
1470
+
1471
+ logger.warn('Rotated:', "#{items.length - count} items from #{section}")
1472
+ end
1795
1473
  end
1796
- end
1797
1474
 
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 }
1475
+ write(@doing_file)
1476
+
1477
+ file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1478
+ if File.exist?(file)
1479
+ init_doing_file(file)
1480
+ @content.deep_merge(new_content)
1481
+ logger.warn('File update:', "Added entries to existing file: #{file}")
1482
+ else
1483
+ @content = new_content
1484
+ logger.warn('File update:', "Created new file: #{file}")
1804
1485
  end
1805
- end
1806
1486
 
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 }
1487
+ write(file, backup: false)
1488
+ end
1489
+
1490
+ ##
1491
+ ## @brief Generate a menu of sections and allow user selection
1492
+ ##
1493
+ ## @return (String) The selected section name
1494
+ ##
1495
+ def choose_section
1496
+ choice = choose_from(sections.sort, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1497
+ choice ? choice.strip : choice
1498
+ end
1499
+
1500
+ ##
1501
+ ## @brief List available views
1502
+ ##
1503
+ ## @return (Array) View names
1504
+ ##
1505
+ def views
1506
+ @config.has_key?('views') ? @config['views'].keys : []
1507
+ end
1508
+
1509
+ ##
1510
+ ## @brief Generate a menu of views and allow user selection
1511
+ ##
1512
+ ## @return (String) The selected view name
1513
+ ##
1514
+ def choose_view
1515
+ choice = choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1516
+ choice ? choice.strip : choice
1517
+ end
1518
+
1519
+ ##
1520
+ ## @brief Gets a view from configuration
1521
+ ##
1522
+ ## @param title (String) The title of the view to retrieve
1523
+ ##
1524
+ def get_view(title)
1525
+ return @config['views'][title] if @config['views'].has_key?(title)
1526
+
1527
+ false
1528
+ end
1529
+
1530
+ ##
1531
+ ## @brief Display contents of a section based on options
1532
+ ##
1533
+ ## @param opt (Hash) Additional Options
1534
+ ##
1535
+ def list_section(opt = {})
1536
+ opt[:count] ||= 0
1537
+ opt[:age] ||= 'newest'
1538
+ opt[:format] ||= @config.dig('templates', 'default', 'date_format')
1539
+ opt[:order] ||= @config.dig('templates', 'default', 'order') || 'asc'
1540
+ opt[:tag_order] ||= 'asc'
1541
+ opt[:tags_color] ||= false
1542
+ opt[:template] ||= @config.dig('templates', 'default', 'template')
1543
+
1544
+ # opt[:highlight] ||= true
1545
+ title = ''
1546
+ is_single = true
1547
+ if opt[:section].nil?
1548
+ opt[:section] = choose_section
1549
+ title = opt[:section]
1550
+ elsif opt[:section].instance_of?(String)
1551
+ if opt[:section] =~ /^all$/i
1552
+ title = if opt[:page_title]
1553
+ opt[:page_title]
1554
+ elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
1555
+ opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
1556
+ else
1557
+ 'doing'
1558
+ end
1559
+ else
1560
+ title = guess_section(opt[:section])
1561
+ end
1813
1562
  end
1814
- end
1815
1563
 
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
1564
+ items = filter_items([], opt: opt).reverse
1831
1565
 
1832
- items.reverse! if opt[:order] =~ /^a/i
1566
+ items.reverse! if opt[:order] =~ /^d/i
1833
1567
 
1834
- out = ''
1835
1568
 
1836
- exit_now! 'Unknown output format' if opt[:output] && (opt[:output] !~ /^(template|html|csv|json|timeline|markdown|md|taskpaper)$/i)
1569
+ if opt[:interactive]
1570
+ opt[:menu] = !opt[:force]
1571
+ opt[:query] = '' # opt[:search]
1572
+ opt[:multiple] = true
1573
+ selected = choose_from_items(items, opt, include_section: opt[:section] =~ /^all$/i )
1837
1574
 
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]
1575
+ if selected.empty?
1576
+ logger.debug('Skipped:', 'No selection')
1577
+ return
1878
1578
  end
1879
1579
 
1880
- if opt[:output] == 'json'
1580
+ act_on(selected, opt)
1581
+ return
1582
+ end
1881
1583
 
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
- }
1890
1584
 
1891
- attributes.each { |attr, val| i[attr.to_sym] = val }
1585
+ opt[:output] ||= 'template'
1892
1586
 
1893
- items_out << i
1587
+ opt[:wrap_width] ||= @config['templates']['default']['wrap_width']
1894
1588
 
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
- }
1589
+ output(items, title, is_single, opt)
1590
+ end
1903
1591
 
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)
1909
- end
1592
+ def output(items, title, is_single, opt = {})
1593
+ out = nil
1594
+
1595
+ raise Errors::InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
1596
+
1597
+ export_options = { page_title: title, is_single: is_single, options: opt }
1598
+
1599
+ Plugins.plugins[:export].each do |_, options|
1600
+ next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
1601
+
1602
+ out = options[:class].render(self, items, variables: export_options)
1603
+ break
1910
1604
  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
1605
 
1972
- interval = get_interval(i) if i['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
1973
- interval ||= false
1606
+ out
1607
+ end
1974
1608
 
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
- }
1609
+ def load_plugins
1610
+ if @config.key?('plugins') && @config['plugins']['plugin_path']
1611
+ add_dir = @config['plugins']['plugin_path']
1612
+ else
1613
+ add_dir = File.join(@user_home, '.config', 'doing', 'plugins')
1614
+ begin
1615
+ FileUtils.mkdir_p(add_dir) if add_dir
1616
+ rescue
1617
+ nil
1618
+ end
1982
1619
  end
1983
1620
 
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
1621
+ Plugins.load_plugins(add_dir)
1622
+ end
1989
1623
 
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
1624
+ ##
1625
+ ## @brief Move entries from a section to Archive or other specified
1626
+ ## section
1627
+ ##
1628
+ ## @param section (String) The source section
1629
+ ## @param options (Hash) Options
1630
+ ##
1631
+ def archive(section = @config['current_section'], options = {})
1632
+ count = options[:keep] || 0
1633
+ destination = options[:destination] || 'Archive'
1634
+ tags = options[:tags] || []
1635
+ bool = options[:bool] || :and
1995
1636
 
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']
2016
- else
2017
- title = i['title'].link_urls({format: :markdown})
2018
- note = i['note'].map { |line| line.strip.link_urls({format: :markdown}) } if i['note']
2019
- end
1637
+ section = choose_section if section.nil? || section =~ /choose/i
1638
+ archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1639
+ section = guess_section(section) unless archive_all
2020
1640
 
2021
- title = "#{title} @project(#{i['section']})" unless is_single
1641
+ add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
2022
1642
 
2023
- interval = get_interval(i) if i['title'] =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/ && opt[:times]
2024
- interval ||= false
1643
+ destination = guess_section(destination)
2025
1644
 
2026
- done = i['title'] =~ /(?<= |^)@done/ ? 'x' : ' '
1645
+ if sections.include?(destination) && (sections.include?(section) || archive_all)
1646
+ do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before] })
1647
+ write(doing_file)
1648
+ else
1649
+ raise Errors::InvalidArgument, 'Either source or destination does not exist'
1650
+ end
1651
+ end
2027
1652
 
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
- }
1653
+ ##
1654
+ ## @brief Helper function, performs the actual archiving
1655
+ ##
1656
+ ## @param section (String) The source section
1657
+ ## @param destination (String) The destination section
1658
+ ## @param opt (Hash) Additional Options
1659
+ ##
1660
+ def do_archive(sect, destination, opt = {})
1661
+ count = opt[:count] || 0
1662
+ tags = opt[:tags] || []
1663
+ bool = opt[:bool] || :and
1664
+ label = opt[:label] || true
1665
+
1666
+ if sect =~ /^all$/i
1667
+ all_sections = sections.dup
1668
+ all_sections.delete(destination)
1669
+ else
1670
+ all_sections = [sect]
2037
1671
  end
2038
1672
 
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
1673
+ counter = 0
2044
1674
 
2045
- totals = opt[:totals] ? tag_times(format: :markdown, sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order]) : ''
1675
+ all_sections.each do |section|
1676
+ items = @content[section][:items].dup
2046
1677
 
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
1678
+ moved_items = []
1679
+ if !tags.empty? || opt[:search] || opt[:before]
1680
+ if opt[:before]
1681
+ time_string = opt[:before]
1682
+ cutoff = chronify(time_string, guess: :begin)
1683
+ end
2059
1684
 
2060
- if (item.key?('note') && !item['note'].empty?) && @config[:include_notes]
2061
- note_lines = item['note'].delete_if do |line|
2062
- line =~ /^\s*$/
1685
+ items.delete_if do |item|
1686
+ if ((!tags.empty? && item.tags?(tags, bool)) || (opt[:search] && item.search(opt[:search].to_s)) || (opt[:before] && item.date < cutoff))
1687
+ moved_items.push(item)
1688
+ counter += 1
1689
+ true
1690
+ else
1691
+ false
1692
+ end
2063
1693
  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")
1694
+ moved_items.each do |item|
1695
+ if label
1696
+ item.title = if section == @config['current_section']
1697
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
1698
+ else
1699
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1700
+ end
1701
+ logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
2071
1702
  end
2072
1703
  end
2073
- note = "\n#{note_lines.join("\n").chomp}"
2074
- else
2075
- note = ''
2076
- end
2077
- output = opt[:template].dup
2078
1704
 
2079
- output.gsub!(/%[a-z]+/) do |m|
2080
- if colors.key?(m.sub(/^%/, ''))
2081
- colors[m.sub(/^%/, '')]
2082
- else
2083
- m
1705
+ @content[section][:items] = items
1706
+ @content[destination][:items].concat(moved_items)
1707
+ if moved_items.length.positive?
1708
+ logger.info('Archived:', "#{moved_items.length} items from #{section} to #{destination}")
2084
1709
  end
2085
- end
2086
-
2087
- output.sub!(/%date/, item['date'].strftime(opt[:format]))
2088
-
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)
1710
+ else
1711
+ count = items.length if items.length < count
2092
1712
 
2093
- output.sub!(/%shortdate/) do
2094
- item['date'].relative_date
2095
- end
1713
+ items.map! do |item|
1714
+ if label
1715
+ item.title = if section == @config['current_section']
1716
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, '\1')
1717
+ else
1718
+ item.title.sub(/(?: ?@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1719
+ end
1720
+ logger.debug('Moved:', "#{item.title} from #{section} to #{destination}")
1721
+ end
1722
+ item
1723
+ end
2096
1724
 
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
1725
+ if items.count > count
1726
+ @content[destination][:items].concat(items[count..-1])
2100
1727
  else
2101
- flag + item['title'].chomp + reset
1728
+ @content[destination][:items].concat(items)
2102
1729
  end
2103
- end
2104
-
2105
- output.sub!(/%section/, item['section']) if item['section']
2106
1730
 
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}")
1731
+ @content[section][:items] = if count.zero?
1732
+ []
1733
+ else
1734
+ items[0..count - 1]
1735
+ end
1736
+ logger.count(destination == 'Archive' ? :archived : :moved,
1737
+ count: items.length - count,
1738
+ message: "%count %items from #{section} to #{destination}")
1739
+ # logger.info('Archived:', "#{items.length - count} items from #{section} to #{destination}")
2115
1740
  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
2125
- end
2126
- output.gsub!(/%n/, "\n")
2127
- output.gsub!(/%t/, "\t")
2128
-
2129
- out += "#{output}\n"
2130
1741
  end
2131
-
2132
- out += tag_times(format: :text, sort_by_name: opt[:sort_tags], sort_order: opt[:tag_order]) if opt[:totals]
2133
1742
  end
2134
- out
2135
- end
2136
1743
 
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
1744
+ ##
1745
+ ## @brief Show all entries from the current day
1746
+ ##
1747
+ ## @param times (Boolean) show times
1748
+ ## @param output (String) output format
1749
+ ## @param opt (Hash) Options
1750
+ ##
1751
+ def today(times = true, output = nil, opt = {})
1752
+ opt[:totals] ||= false
1753
+ opt[:sort_tags] ||= false
1754
+
1755
+ cfg = @config['templates']['today']
1756
+ options = {
1757
+ after: opt[:after],
1758
+ before: opt[:before],
1759
+ count: 0,
1760
+ format: cfg['date_format'],
1761
+ order: 'asc',
1762
+ output: output,
1763
+ section: opt[:section],
1764
+ sort_tags: opt[:sort_tags],
1765
+ template: cfg['template'],
1766
+ times: times,
1767
+ today: true,
1768
+ totals: opt[:totals],
1769
+ wrap_width: cfg['wrap_width']
1770
+ }
1771
+ list_section(options)
1772
+ end
1773
+
1774
+ ##
1775
+ ## @brief Display entries within a date range
1776
+ ##
1777
+ ## @param dates (Array) [start, end]
1778
+ ## @param section (String) The section
1779
+ ## @param times (Bool) Show times
1780
+ ## @param output (String) Output format
1781
+ ## @param opt (Hash) Additional Options
1782
+ ##
1783
+ def list_date(dates, section, times = nil, output = nil, opt = {})
1784
+ opt[:totals] ||= false
1785
+ opt[:sort_tags] ||= false
1786
+ section = guess_section(section)
1787
+ # :date_filter expects an array with start and end date
1788
+ dates = [dates, dates] if dates.instance_of?(String)
1789
+
1790
+ list_section({ section: section, count: 0, order: 'asc', date_filter: dates, times: times,
1791
+ output: output, totals: opt[:totals], sort_tags: opt[:sort_tags] })
1792
+ end
1793
+
1794
+ ##
1795
+ ## @brief Show entries from the previous day
1796
+ ##
1797
+ ## @param section (String) The section
1798
+ ## @param times (Bool) Show times
1799
+ ## @param output (String) Output format
1800
+ ## @param opt (Hash) Additional Options
1801
+ ##
1802
+ def yesterday(section, times = nil, output = nil, opt = {})
1803
+ opt[:totals] ||= false
1804
+ opt[:sort_tags] ||= false
1805
+ section = guess_section(section)
1806
+ y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
1807
+ opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
1808
+ opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
1809
+
1810
+ options = {
1811
+ after: opt[:after],
1812
+ before: opt[:before],
1813
+ count: 0,
1814
+ order: opt[:order],
1815
+ output: output,
1816
+ section: section,
1817
+ sort_tags: opt[:sort_tags],
1818
+ tag_order: opt[:tag_order],
1819
+ times: times,
1820
+ totals: opt[:totals],
1821
+ yesterday: true
1822
+ }
2149
1823
 
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
1824
+ list_section(options)
1825
+ end
1826
+
1827
+ ##
1828
+ ## @brief Show recent entries
1829
+ ##
1830
+ ## @param count (Integer) The number to show
1831
+ ## @param section (String) The section to show from, default Currently
1832
+ ## @param opt (Hash) Additional Options
1833
+ ##
1834
+ def recent(count = 10, section = nil, opt = {})
1835
+ times = opt[:t] || true
1836
+ opt[:totals] ||= false
1837
+ opt[:sort_tags] ||= false
1838
+
1839
+ cfg = @config['templates']['recent']
1840
+ section ||= @config['current_section']
1841
+ section = guess_section(section)
1842
+
1843
+ list_section({ section: section, wrap_width: cfg['wrap_width'], count: count,
1844
+ format: cfg['date_format'], template: cfg['template'],
1845
+ order: 'asc', times: times, totals: opt[:totals],
1846
+ sort_tags: opt[:sort_tags], tags_color: opt[:tags_color] })
1847
+ end
1848
+
1849
+ ##
1850
+ ## @brief Show the last entry
1851
+ ##
1852
+ ## @param times (Bool) Show times
1853
+ ## @param section (String) Section to pull from, default Currently
1854
+ ##
1855
+ def last(times: true, section: nil, options: {})
1856
+ section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
1857
+ cfg = @config['templates']['last']
1858
+
1859
+ opts = {
1860
+ section: section,
1861
+ wrap_width: cfg['wrap_width'],
1862
+ count: 1,
1863
+ format: cfg['date_format'],
1864
+ template: cfg['template'],
1865
+ times: times
1866
+ }
2153
1867
 
2154
- add_section('Archive') if destination =~ /^archive$/i && !sections.include?('Archive')
1868
+ if options[:tag]
1869
+ opts[:tag_filter] = {
1870
+ 'tags' => options[:tag],
1871
+ 'bool' => options[:tag_bool]
1872
+ }
1873
+ end
2155
1874
 
2156
- destination = guess_section(destination)
1875
+ opts[:search] = options[:search] if options[:search]
2157
1876
 
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'
1877
+ list_section(opts)
2163
1878
  end
2164
- end
2165
1879
 
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
1880
+ ##
1881
+ ## @brief Uses 'autotag' configuration to turn keywords into tags for time tracking.
1882
+ ## Does not repeat tags in a title, and only converts the first instance of an
1883
+ ## untagged keyword
1884
+ ##
1885
+ ## @param text (String) The text to tag
1886
+ ##
1887
+ def autotag(text)
1888
+ return unless text
1889
+ return text unless @auto_tag
2185
1890
 
2186
- counter = 0
1891
+ original = text.dup
2187
1892
 
2188
- all_sections.each do |section|
2189
- items = @content[section]['items'].dup
1893
+ current_tags = text.scan(/@\w+/)
1894
+ whitelisted = []
1895
+ @config['autotag']['whitelist'].each do |tag|
1896
+ next if text =~ /@#{tag}\b/i
2190
1897
 
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)
1898
+ text.sub!(/(?<!@)\b(#{tag.strip})\b/i) do |m|
1899
+ m.downcase! if tag =~ /[a-z]/
1900
+ whitelisted.push("@#{m}")
1901
+ "@#{m}"
2197
1902
  end
1903
+ end
1904
+ tail_tags = []
1905
+ @config['autotag']['synonyms'].each do |tag, v|
1906
+ v.each do |word|
1907
+ next unless text =~ /\b#{word}\b/i
2198
1908
 
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
2206
- end
1909
+ tail_tags.push(tag) unless current_tags.include?("@#{tag}") || whitelisted.include?("@#{tag}")
2207
1910
  end
2208
- moved_items.each do |item|
2209
- if label && section != @current_section
2210
- item['title'] =
2211
- item['title'].sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
2212
- end
2213
- end
2214
-
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
2220
-
2221
- items.map! do |item|
2222
- if label && section != @current_section
2223
- item['title'] =
2224
- item['title'].sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{section})")
1911
+ end
1912
+ if @config['autotag'].key? 'transform'
1913
+ @config['autotag']['transform'].each do |tag|
1914
+ next unless tag =~ /\S+:\S+/
1915
+
1916
+ rx, r = tag.split(/:/)
1917
+ r.gsub!(/\$/, '\\')
1918
+ rx.sub!(/^@/, '')
1919
+ regex = Regexp.new('@' + rx + '\b')
1920
+
1921
+ matches = text.scan(regex)
1922
+ next unless matches
1923
+
1924
+ matches.each do |m|
1925
+ new_tag = r
1926
+ if m.is_a?(Array)
1927
+ index = 1
1928
+ m.each do |v|
1929
+ new_tag.gsub!('\\' + index.to_s, v)
1930
+ index += 1
1931
+ end
1932
+ end
1933
+ tail_tags.push(new_tag)
2225
1934
  end
2226
- item
2227
1935
  end
2228
-
2229
- if items.count > count
2230
- @content[destination]['items'].concat(items[count..-1])
2231
- else
2232
- @content[destination]['items'].concat(items)
2233
- end
2234
-
2235
- @content[section]['items'] = if count.zero?
2236
- []
2237
- else
2238
- items[0..count - 1]
2239
- end
2240
-
2241
- @results.push("Archived #{items.length - count} items from #{section} to #{destination}")
2242
1936
  end
2243
- end
2244
- end
2245
1937
 
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
1938
+ logger.debug('Autotag:', "Whitelisted tags: #{whitelisted.join(', ')}") unless whitelisted.empty?
1939
+ new_tags = whitelisted
1940
+ unless tail_tags.empty?
1941
+ tags = tail_tags.uniq.map { |t| "@#{t}".cyan }.join(' ')
1942
+ logger.debug('Autotag:', "Synonym tags: #{tags}")
1943
+ tags_a = tail_tags.map { |t| "@#{t}" }
1944
+ text.add_tags!(tags_a.join(' '))
1945
+ new_tags.concat(tags_a)
1946
+ end
2324
1947
 
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
1948
+ unless text == original
1949
+ logger.info('Autotag:', "added #{new_tags.join(', ')} to \"#{text}\"")
1950
+ else
1951
+ logger.debug('Autotag:', "no change to \"#{text}\"")
1952
+ end
2344
1953
 
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
1954
+ text
1955
+ end
2377
1956
 
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
1957
+ ##
1958
+ ## @brief Get total elapsed time for all tags in
1959
+ ## selection
1960
+ ##
1961
+ ## @param format (String) return format (html,
1962
+ ## json, or text)
1963
+ ## @param sort_by_name (Boolean) Sort by name if true, otherwise by time
1964
+ ## @param sort_order (String) The sort order (asc or desc)
1965
+ ##
1966
+ def tag_times(format: :text, sort_by_name: false, sort_order: 'asc')
1967
+ return '' if @timers.empty?
2399
1968
 
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
- }
2424
- end
1969
+ max = @timers.keys.sort_by { |k| k.length }.reverse[0].length + 1
2425
1970
 
2426
- opts[:search] = options[:search] if options[:search]
1971
+ total = @timers.delete('All')
2427
1972
 
2428
- list_section(opts)
2429
- end
1973
+ tags_data = @timers.delete_if { |_k, v| v == 0 }
1974
+ sorted_tags_data = if sort_by_name
1975
+ tags_data.sort_by { |k, _v| k }
1976
+ else
1977
+ tags_data.sort_by { |_k, v| v }
1978
+ end
2430
1979
 
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>
1980
+ sorted_tags_data.reverse! if sort_order =~ /^asc/i
1981
+ case format
1982
+ when :html
1983
+
1984
+ output = <<EOS
1985
+ <table>
1986
+ <caption id="tagtotals">Tag Totals</caption>
1987
+ <colgroup>
1988
+ <col style="text-align:left;"/>
1989
+ <col style="text-align:left;"/>
1990
+ </colgroup>
1991
+ <thead>
1992
+ <tr>
1993
+ <th style="text-align:left;">project</th>
1994
+ <th style="text-align:left;">time</th>
1995
+ </tr>
1996
+ </thead>
1997
+ <tbody>
1998
+ EOS
1999
+ sorted_tags_data.reverse.each do |k, v|
2000
+ if v > 0
2001
+ output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{'%02d:%02d:%02d' % format_time(v)}</td></tr>\n"
2002
+ end
2003
+ end
2004
+ tail = <<EOS
2005
+ <tr>
2006
+ <td style="text-align:left;" colspan="2"></td>
2007
+ </tr>
2008
+ </tbody>
2009
+ <tfoot>
2466
2010
  <tr>
2467
- <th style="text-align:left;">project</th>
2468
- <th style="text-align:left;">time</th>
2011
+ <td style="text-align:left;"><strong>Total</strong></td>
2012
+ <td style="text-align:left;">#{'%02d:%02d:%02d' % format_time(total)}</td>
2469
2013
  </tr>
2470
- </thead>
2471
- <tbody>
2014
+ </tfoot>
2015
+ </table>
2472
2016
  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"
2017
+ output + tail
2018
+ when :markdown
2019
+ pad = sorted_tags_data.map {|k, v| k }.group_by(&:size).max.last[0].length
2020
+ output = <<~EOS
2021
+ | #{' ' * (pad - 7) }project | time |
2022
+ | #{'-' * (pad - 1)}: | :------- |
2023
+ EOS
2024
+ sorted_tags_data.reverse.each do |k, v|
2025
+ if v > 0
2026
+ output += "| #{' ' * (pad - k.length)}#{k} | #{'%02d:%02d:%02d' % format_time(v)} |\n"
2027
+ end
2476
2028
  end
2477
- 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"
2029
+ tail = "[Tag Totals]"
2030
+ output + tail
2031
+ when :json
2032
+ output = []
2033
+ sorted_tags_data.reverse.each do |k, v|
2034
+ d, h, m = format_time(v)
2035
+ output << {
2036
+ 'tag' => k,
2037
+ 'seconds' => v,
2038
+ 'formatted' => format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)
2039
+ }
2501
2040
  end
2502
- 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|
2041
+ output
2042
+ when :human
2043
+ output = []
2044
+ sorted_tags_data.reverse.each do |k, v|
2045
+ spacer = ''
2046
+ (max - k.length).times do
2047
+ spacer += ' '
2048
+ end
2049
+ d, h, m = format_time(v, human: true)
2050
+ output.push("┃ #{spacer}#{k}:#{format('%<h> 4dh %<m>02dm', h: h, m: m)} ┃")
2051
+ end
2052
+
2053
+ header = '┏━━ Tag Totals '
2054
+ (max - 2).times { header += '━' }
2055
+ header += '┓'
2056
+ footer = '┗'
2057
+ (max + 12).times { footer += '━' }
2058
+ footer += '┛'
2059
+ divider = '┣'
2060
+ (max + 12).times { divider += '━' }
2061
+ divider += '┫'
2062
+ output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
2063
+ d, h, m = format_time(total, human: true)
2064
+ output += "\n#{divider}"
2518
2065
  spacer = ''
2519
- (max - k.length).times do
2066
+ (max - 6).times do
2520
2067
  spacer += ' '
2521
2068
  end
2522
- output.push("#{k}:#{spacer}#{'%02d:%02d:%02d' % fmt_time(v)}")
2523
- end
2524
-
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
2069
+ total = "#{spacer}total: "
2070
+ total += format('%<h> 4dh %<m>02dm', h: h, m: m)
2071
+ total += ' ┃'
2072
+ output += "\n#{total}"
2073
+ output += "\n#{footer}"
2074
+ output
2075
+ else
2076
+ output = []
2077
+ sorted_tags_data.reverse.each do |k, v|
2078
+ spacer = ''
2079
+ (max - k.length).times do
2080
+ spacer += ' '
2081
+ end
2082
+ d, h, m = format_time(v)
2083
+ output.push("#{k}:#{spacer}#{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}")
2084
+ end
2530
2085
 
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}"
2086
+ output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
2087
+ d, h, m = format_time(total)
2088
+ output += "\n\nTotal tracked: #{format('%<d>02d:%<h>02d:%<m>02d', d: d, h: h, m: m)}\n"
2089
+ output
2550
2090
  end
2551
2091
  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
2092
 
2557
- tail_tags.push(tag) unless current_tags.include?("@#{tag}") || whitelisted.include?("@#{tag}")
2093
+ ##
2094
+ ## @brief Gets the interval between entry's start
2095
+ ## date and @done date
2096
+ ##
2097
+ ## @param item (Hash) The entry
2098
+ ## @param formatted (Bool) Return human readable
2099
+ ## time (default seconds)
2100
+ ## @param record (Bool) Add the interval to the
2101
+ ## total for each tag
2102
+ ##
2103
+ ## @return Interval in seconds, or [d, h, m] array if
2104
+ ## formatted is true. False if no end date or
2105
+ ## interval is 0
2106
+ ##
2107
+ def get_interval(item, formatted: true, record: true)
2108
+ if item.interval
2109
+ seconds = item.interval
2110
+ record_tag_times(item, seconds) if record
2111
+ return seconds.positive? ? seconds : false unless formatted
2112
+
2113
+ return seconds.positive? ? format('%02d:%02d:%02d', *format_time(seconds)) : false
2558
2114
  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)
2115
+
2116
+ false
2117
+ end
2118
+
2119
+ ##
2120
+ ## @brief Record times for item tags
2121
+ ##
2122
+ ## @param item The item
2123
+ ##
2124
+ def record_tag_times(item, seconds)
2125
+ item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
2126
+ return if @recorded_items.include?(item_hash)
2127
+ item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2128
+ k = m[0] == 'done' ? 'All' : m[0].downcase
2129
+ if @timers.key?(k)
2130
+ @timers[k] += seconds
2131
+ else
2132
+ @timers[k] = seconds
2582
2133
  end
2134
+ @recorded_items.push(item_hash)
2583
2135
  end
2584
2136
  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
2591
- text
2592
- end
2593
- end
2594
2137
 
2595
- private
2138
+ ##
2139
+ ## @brief Format human readable time from seconds
2140
+ ##
2141
+ ## @param seconds The seconds
2142
+ ##
2143
+ def format_time(seconds, human: false)
2144
+ return [0, 0, 0] if seconds.nil?
2596
2145
 
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
2146
+ if seconds.class == String && seconds =~ /(\d+):(\d+):(\d+)/
2147
+ h = Regexp.last_match(1)
2148
+ m = Regexp.last_match(2)
2149
+ s = Regexp.last_match(3)
2150
+ seconds = (h.to_i * 60 * 60) + (m.to_i * 60) + s.to_i
2151
+ end
2152
+ minutes = (seconds / 60).to_i
2153
+ hours = (minutes / 60).to_i
2154
+ if human
2155
+ minutes = (minutes % 60).to_i
2156
+ [0, hours, minutes]
2157
+ else
2158
+ days = (hours / 24).to_i
2159
+ hours = (hours % 24).to_i
2160
+ minutes = (minutes % 60).to_i
2161
+ [days, hours, minutes]
2162
+ end
2611
2163
  end
2612
2164
 
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
2165
+ private
2618
2166
 
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
2167
+ def run_after
2168
+ return unless @config.key?('run_after')
2624
2169
 
2625
- seconds = (done - start).to_i
2170
+ _, stderr, status = Open3.capture3(@config['run_after'])
2171
+ return unless status.exitstatus.positive?
2626
2172
 
2627
- if record
2628
- record_tag_times(item, seconds)
2173
+ logger.log_now(:error, 'Script error:', "Error running #{@config['run_after']}")
2174
+ logger.log_now(:error, 'STDERR output:', stderr)
2629
2175
  end
2630
2176
 
2631
- @interval_cache[item['title']] = seconds
2632
-
2633
- return seconds > 0 ? seconds : false unless formatted
2634
-
2635
- seconds > 0 ? '%02d:%02d:%02d' % fmt_time(seconds) : false
2636
- end
2637
-
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)
2645
-
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
2177
+ def log_change(tags_added: [], tags_removed: [], count: 1)
2178
+ if tags_added.empty? && tags_removed.empty?
2179
+ logger.count(:skipped, level: :debug, message: '%count %items with no change', count: count)
2650
2180
  else
2651
- @timers[k] = seconds
2652
- end
2653
- @recorded_items.push(item)
2654
- end
2655
- end
2656
2181
 
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
2670
- 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
2182
+ if tags_added.empty?
2183
+ logger.count(:skipped, level: :debug, message: 'no tags added to %count %items')
2184
+ # logger.debug('No tags added:', %("#{item.title}" in #{item.section}))
2185
+ else
2186
+ logger.count(:added_tags, tag: tags_added, message: '%tags added to %count %items')
2187
+ # logger.info('Added tags:', %(#{did_add} to "#{item.title}" in #{item.section}))
2188
+ end
2678
2189
 
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
2190
+ if tags_removed.empty?
2191
+ logger.count(:skipped, level: :debug, message: 'no tags removed from %count %items')
2192
+ else
2193
+ logger.count(:removed_tags, tag: tags_removed, message: '%tags removed from %count %items')
2194
+ end
2195
+ end
2684
2196
  end
2685
2197
  end
2686
2198
  end