doing 2.0.22 → 2.1.0pre

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 (88) hide show
  1. checksums.yaml +4 -4
  2. data/.yardoc/checksums +18 -15
  3. data/.yardoc/object_types +0 -0
  4. data/.yardoc/objects/root.dat +0 -0
  5. data/CHANGELOG.md +36 -1
  6. data/Gemfile.lock +8 -1
  7. data/README.md +7 -1
  8. data/Rakefile +23 -4
  9. data/bin/doing +323 -173
  10. data/doc/Array.html +354 -1
  11. data/doc/Doing/Color.html +104 -92
  12. data/doc/Doing/Completion.html +216 -0
  13. data/doc/Doing/Configuration.html +340 -5
  14. data/doc/Doing/Content.html +229 -0
  15. data/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  16. data/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  17. data/doc/Doing/Errors/DoingStandardError.html +1 -1
  18. data/doc/Doing/Errors/EmptyInput.html +1 -1
  19. data/doc/Doing/Errors/NoResults.html +1 -1
  20. data/doc/Doing/Errors/PluginException.html +1 -1
  21. data/doc/Doing/Errors/UserCancelled.html +1 -1
  22. data/doc/Doing/Errors/WrongCommand.html +1 -1
  23. data/doc/Doing/Errors.html +1 -1
  24. data/doc/Doing/Hooks.html +1 -1
  25. data/doc/Doing/Item.html +337 -49
  26. data/doc/Doing/Items.html +444 -35
  27. data/doc/Doing/LogAdapter.html +139 -51
  28. data/doc/Doing/Note.html +253 -22
  29. data/doc/Doing/Pager.html +74 -36
  30. data/doc/Doing/Plugins.html +1 -1
  31. data/doc/Doing/Prompt.html +674 -0
  32. data/doc/Doing/Section.html +354 -0
  33. data/doc/Doing/Util.html +57 -1
  34. data/doc/Doing/WWID.html +477 -670
  35. data/doc/Doing/WWIDFile.html +398 -0
  36. data/doc/Doing.html +5 -5
  37. data/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  38. data/doc/GLI/Commands.html +1 -1
  39. data/doc/GLI.html +1 -1
  40. data/doc/Hash.html +97 -1
  41. data/doc/Status.html +37 -3
  42. data/doc/String.html +599 -23
  43. data/doc/Symbol.html +3 -3
  44. data/doc/Time.html +1 -1
  45. data/doc/_index.html +22 -1
  46. data/doc/class_list.html +1 -1
  47. data/doc/file.README.html +8 -2
  48. data/doc/index.html +8 -2
  49. data/doc/method_list.html +453 -173
  50. data/doc/top-level-namespace.html +1 -1
  51. data/doing.gemspec +3 -0
  52. data/doing.rdoc +79 -27
  53. data/example_plugin.rb +5 -5
  54. data/lib/completion/_doing.zsh +42 -42
  55. data/lib/completion/doing.bash +10 -10
  56. data/lib/completion/doing.fish +1 -280
  57. data/lib/doing/array.rb +36 -0
  58. data/lib/doing/colors.rb +70 -66
  59. data/lib/doing/completion/bash_completion.rb +1 -2
  60. data/lib/doing/completion/fish_completion.rb +1 -1
  61. data/lib/doing/completion/zsh_completion.rb +1 -1
  62. data/lib/doing/completion.rb +6 -0
  63. data/lib/doing/configuration.rb +134 -23
  64. data/lib/doing/hash.rb +37 -0
  65. data/lib/doing/item.rb +77 -12
  66. data/lib/doing/items.rb +125 -0
  67. data/lib/doing/log_adapter.rb +58 -4
  68. data/lib/doing/note.rb +53 -1
  69. data/lib/doing/pager.rb +49 -38
  70. data/lib/doing/plugins/export/markdown_export.rb +4 -4
  71. data/lib/doing/plugins/export/template_export.rb +2 -2
  72. data/lib/doing/plugins/import/calendar_import.rb +4 -4
  73. data/lib/doing/plugins/import/doing_import.rb +5 -7
  74. data/lib/doing/plugins/import/timing_import.rb +3 -3
  75. data/lib/doing/prompt.rb +206 -0
  76. data/lib/doing/section.rb +30 -0
  77. data/lib/doing/string.rb +123 -35
  78. data/lib/doing/util.rb +14 -6
  79. data/lib/doing/version.rb +1 -1
  80. data/lib/doing/wwid.rb +307 -614
  81. data/lib/doing.rb +6 -2
  82. data/lib/examples/plugins/capture_thing_import.rb +162 -0
  83. data/rdoc_to_mmd.rb +14 -8
  84. data/scripts/generate_bash_completions.rb +1 -1
  85. data/scripts/generate_fish_completions.rb +1 -1
  86. data/scripts/generate_zsh_completions.rb +1 -1
  87. metadata +73 -5
  88. data/lib/doing/wwidfile.rb +0 -117
@@ -90,21 +90,70 @@ module Doing
90
90
  }
91
91
 
92
92
  def initialize(file = nil, options: {})
93
- if file
94
- cf = File.expand_path(file)
95
- # raise MissingConfigFile, "Config not found (#{cf})" unless File.exist?(cf)
93
+ @config_file = file.nil? ? default_config_file : File.expand_path(file)
96
94
 
97
- @config_file = cf
95
+ @settings = configure(options)
96
+ end
97
+
98
+ def config_file
99
+ @config_file ||= default_config_file
100
+ end
101
+
102
+ def config_file=(file)
103
+ @config_file = file
104
+ end
105
+
106
+ def config_dir
107
+ @config_dir ||= File.join(Util.user_home, '.config', 'doing')
108
+ # @config_dir ||= Util.user_home
109
+ end
110
+
111
+ def default_config_file
112
+ raise DoingRuntimeError, "#{config_dir} exists but is not a directory" if File.exist?(config_dir) && !File.directory?(config_dir)
113
+
114
+ unless File.exist?(config_dir)
115
+ FileUtils.mkdir_p(config_dir)
116
+ Doing.logger.log_now(:warn, "Config directory created at #{config_dir}")
98
117
  end
99
118
 
100
- @settings = configure(options)
119
+ # File.join(config_dir, 'config.yml')
120
+ File.join(config_dir, 'config.yml')
101
121
  end
102
122
 
103
123
  def additional_configs
104
124
  @additional_configs ||= find_local_config
105
125
  end
106
126
 
107
- def value_for_key(keypath = '')
127
+ ##
128
+ ## Present a menu if there are multiple configs found
129
+ ##
130
+ ## @return [String] file path
131
+ ##
132
+ def choose_config
133
+ if @additional_configs.count.positive?
134
+ choices = [@config_file]
135
+ choices.concat(@additional_configs)
136
+ res = Doing::Prompt.choose_from(choices.uniq.sort.reverse, sorted: false, prompt: 'Local configs found, select which to update > ')
137
+
138
+ raise UserCancelled, 'Cancelled' unless res
139
+
140
+ res.strip || @config_file
141
+ else
142
+ @config_file
143
+ end
144
+ end
145
+
146
+ ##
147
+ ## Resolve a fuzzy-matched key path
148
+ ##
149
+ ## @param keypath [String] A dot-separated key
150
+ ## path, e.g.
151
+ ## "plugins.plugin_path". Will also
152
+ ## work with "plug.path" (fuzzy
153
+ ## matched, first match wins)
154
+ ## @return [Array] ordered array of resolved keys
155
+ ##
156
+ def resolve_key_path(keypath)
108
157
  cfg = @settings
109
158
  real_path = []
110
159
  unless keypath =~ /^[.*]?$/
@@ -113,7 +162,8 @@ module Doing
113
162
  path = paths.shift
114
163
  new_cfg = nil
115
164
  cfg.each do |key, val|
116
- next unless key =~ path.to_rx(distance: 2)
165
+ next unless key =~ path.to_rx(distance: 4)
166
+
117
167
  real_path << key
118
168
  new_cfg = val
119
169
  break
@@ -127,9 +177,30 @@ module Doing
127
177
  end
128
178
  end
129
179
  Doing.logger.debug('Config:', "translated key path #{keypath} to #{real_path.join('.')}")
130
- result = {}
131
- result[real_path[-1]] = cfg
132
- result
180
+ real_path
181
+ end
182
+
183
+ ##
184
+ ## Get the value for a fuzzy-matched key path
185
+ ##
186
+ ## @param keypath [String] A dot-separated key
187
+ ## path, e.g.
188
+ ## "plugins.plugin_path". Will also
189
+ ## work with "plug.path" (fuzzy
190
+ ## matched, first match wins)
191
+ ## @return [Hash] Config value
192
+ ##
193
+ def value_for_key(keypath = '')
194
+ cfg = @settings
195
+ real_path = ['config']
196
+ unless keypath =~ /^[.*]?$/
197
+ real_path = resolve_key_path(keypath)
198
+ return nil unless real_path&.count&.positive?
199
+
200
+ cfg = cfg.dig(*real_path)
201
+ end
202
+
203
+ cfg.nil? ? nil : { real_path[-1] => cfg }
133
204
  end
134
205
 
135
206
  # It takes the input, fills in the defaults where values do not exist.
@@ -141,12 +212,37 @@ module Doing
141
212
  Util.deep_merge_hashes(DEFAULTS, Configuration[user_config].stringify_keys)
142
213
  end
143
214
 
144
- def config_file
145
- @config_file ||= File.join(Util.user_home, '.doingrc')
146
- end
215
+ ##
216
+ ## Method for transitioning from ~/.doingrc to ~/.config/doing/config.yml
217
+ ##
218
+ def update_deprecated_config
219
+ # return # Until further notice
220
+ return if File.exist?(default_config_file)
147
221
 
148
- def config_file=(file)
149
- @config_file = file
222
+ old_file = File.join(Util.user_home, '.doingrc')
223
+ return unless File.exist?(old_file)
224
+
225
+ wwid = Doing::WWID.new
226
+ Doing.logger.log_now(:warn, 'Deprecated:', "main config file location has changed to #{config_file}")
227
+ res = wwid.yn("Move #{old_file} to new location, preserving settings?", default_response: true)
228
+
229
+ return unless res
230
+
231
+ if File.exist?(default_config_file)
232
+ res = wwid.yn("#{default_config_file} already exists, overwrite it?", default_response: false)
233
+
234
+ unless res
235
+ @config_file = old_file
236
+ return
237
+ end
238
+ end
239
+
240
+ FileUtils.mv old_file, default_config_file, force: true
241
+ Doing.logger.log_now(:warn, 'Config:', "Config file moved to #{default_config_file}")
242
+ Doing.logger.log_now(:warn, 'Config:', %(If ~/.doingrc exists in the future,
243
+ it will be considered a local config and its values will override the
244
+ default configuration.))
245
+ Process.exit 0
150
246
  end
151
247
 
152
248
  ##
@@ -155,6 +251,8 @@ module Doing
155
251
  ## @param opt [Hash] Additional Options
156
252
  ##
157
253
  def configure(opt = {})
254
+ update_deprecated_config if config_file == default_config_file
255
+
158
256
  @ignore_local = opt[:ignore_local] if opt[:ignore_local]
159
257
 
160
258
  config = read_config.dup
@@ -191,8 +289,23 @@ module Doing
191
289
  config
192
290
  end
193
291
 
292
+ # @private
293
+ def inspect
294
+ %(<Doing::Configuration #{@settings.hash}>)
295
+ end
296
+
297
+ # @private
298
+ def to_s
299
+ YAML.dump(@settings)
300
+ end
301
+
194
302
  private
195
303
 
304
+ ##
305
+ ## Test for deprecated config keys
306
+ ##
307
+ ## @param config The configuration
308
+ ##
196
309
  def find_deprecations(config)
197
310
  deprecated = false
198
311
  if config.key?('editor')
@@ -206,14 +319,16 @@ module Doing
206
319
  deprecated = true
207
320
  config['editors']['config'] = config['config_editor_app']
208
321
  config.delete('config_editor_app')
209
- Doing.logger.debug('Deprecated:', "config key 'config_editor_app' is now 'editors->config', please update your config.")
322
+ Doing.logger.debug('Deprecated:',
323
+ "config key 'config_editor_app' is now 'editors->config', please update your config.")
210
324
  end
211
325
 
212
326
  if config.key?('editor_app') && !config['editors']['doing_file']
213
327
  deprecated = true
214
328
  config['editors']['doing_file'] = config['editor_app']
215
329
  config.delete('editor_app')
216
- Doing.logger.debug('Deprecated:', "config key 'editor_app' is now 'editors->doing_file', please update your config.")
330
+ Doing.logger.debug('Deprecated:',
331
+ "config key 'editor_app' is now 'editors->doing_file', please update your config.")
217
332
  end
218
333
 
219
334
  Doing.logger.warn('Deprecated:', 'outdated keys found, please run `doing config --update`.') if deprecated
@@ -257,7 +372,7 @@ module Doing
257
372
  ##
258
373
  def read_config
259
374
  unless File.exist?(config_file)
260
- Doing.logger.info('Config:', 'Config file doesn\'t exist, using default configuration' )
375
+ Doing.logger.info('Config:', 'Config file doesn\'t exist, using default configuration')
261
376
  return {}.deep_merge(DEFAULTS)
262
377
  end
263
378
 
@@ -302,11 +417,7 @@ module Doing
302
417
  end
303
418
 
304
419
  def load_plugins(add_dir = nil)
305
- begin
306
- FileUtils.mkdir_p(add_dir) if add_dir && !File.exist?(add_dir)
307
- rescue
308
- nil
309
- end
420
+ FileUtils.mkdir_p(add_dir) if add_dir && !File.exist?(add_dir)
310
421
 
311
422
  Plugins.load_plugins(add_dir)
312
423
  end
data/lib/doing/hash.rb CHANGED
@@ -27,5 +27,42 @@ module Doing
27
27
  def symbolize_keys
28
28
  each_with_object({}) { |(k, v), hsh| hsh[k.to_sym] = v.is_a?(Hash) ? v.symbolize_keys : v }
29
29
  end
30
+
31
+ # Set a nested hash value using an array
32
+ #
33
+ # @example `{}.deep_set(['one', 'two'], 'value')`
34
+ # @example `=> { 'one' => { 'two' => 'value' } }
35
+ #
36
+ # @param path [Array] key path
37
+ # @param value The value
38
+ #
39
+ def deep_set(path, value)
40
+ if path.count == 1
41
+ if value
42
+ self[path[0]] = value
43
+ else
44
+ delete(path[0])
45
+ end
46
+ else
47
+ if value
48
+ self.default_proc = ->(h, k) { h[k] = Hash.new(&h.default_proc) }
49
+ dig(*path[0..-2])[path.fetch(-1)] = value
50
+ else
51
+ return self unless dig(*path)
52
+
53
+ dig(*path[0..-2]).delete(path.fetch(-1))
54
+ path.pop
55
+ cleaned = self
56
+ path.each do |key|
57
+ if cleaned[key].empty?
58
+ cleaned.delete(key)
59
+ break
60
+ end
61
+ cleaned = cleaned[key]
62
+ end
63
+ empty? ? nil : self
64
+ end
65
+ end
66
+ end
30
67
  end
31
68
  end
data/lib/doing/item.rb CHANGED
@@ -5,10 +5,10 @@ module Doing
5
5
  ## This class describes a single WWID item
6
6
  ##
7
7
  class Item
8
- # include Amatch
9
-
10
8
  attr_accessor :date, :title, :section, :note
11
9
 
10
+ attr_reader :id
11
+
12
12
  ##
13
13
  ## Initialize an item with date, title, section, and
14
14
  ## optional note
@@ -50,8 +50,11 @@ module Doing
50
50
  @end_date ||= Time.parse(Regexp.last_match(1)) if @title =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/
51
51
  end
52
52
 
53
- def hash_id
54
- (@date.to_s + @title + @section + @note.join(' ')).hash
53
+ # Generate a hash that represents the entry
54
+ #
55
+ # @return [String] entry hash
56
+ def id
57
+ @id ||= (@date.to_s + @title + @section).hash
55
58
  end
56
59
 
57
60
  ##
@@ -105,14 +108,42 @@ module Doing
105
108
  ##
106
109
  ## Add (or remove) tags from the title of the item
107
110
  ##
108
- ## @param tag [String] The tag to add
109
- ## @param value [String] A value to include as @tag(value)
110
- ## @param remove [Boolean] if true remove instead of adding
111
- ## @param rename_to [String] if not nil, rename target tag to this tag name
112
- ## @param regex [Boolean] treat target tag string as regex pattern
111
+ ## @param tags [Array] The tags to apply
112
+ ## @param **options Additional options
113
+ ##
114
+ ## @option options :date [Boolean] Include timestamp?
115
+ ## @option options :single [Boolean] Log as a single change?
116
+ ## @option options :value [String] A value to include as @tag(value)
117
+ ## @option options :remove [Boolean] if true remove instead of adding
118
+ ## @option options :rename_to [String] if not nil, rename target tag to this tag name
119
+ ## @option options :regex [Boolean] treat target tag string as regex pattern
120
+ ## @option options :force [Boolean] with rename_to, add tag if it doesn't exist
113
121
  ##
114
- def tag(tag, value: nil, remove: false, rename_to: nil, regex: false)
115
- @title.tag!(tag, value: value, remove: remove, rename_to: rename_to, regex: regex).strip!
122
+ def tag(tags, **options)
123
+ added = []
124
+ removed = []
125
+
126
+ date = options.fetch(:date, false)
127
+ options[:value] ||= date ? Time.now.strftime('%F %R') : nil
128
+ options.delete(:date)
129
+
130
+ single = options.fetch(:single, false)
131
+ options.delete(:single)
132
+
133
+ tags = tags.to_tags if tags.is_a? ::String
134
+
135
+ remove = options.fetch(:remove, false)
136
+ tags.each do |tag|
137
+ bool = remove ? :and : :not
138
+ if tags?(tag, bool)
139
+ @title.tag!(tag, **options).strip!
140
+ remove ? removed.push(tag) : added.push(tag)
141
+ end
142
+ end
143
+
144
+ Doing.logger.log_change(tags_added: added, tags_removed: removed, count: 1, item: self, single: single)
145
+
146
+ self
116
147
  end
117
148
 
118
149
  ##
@@ -121,7 +152,7 @@ module Doing
121
152
  ## @return [Array] array of tags (no values)
122
153
  ##
123
154
  def tags
124
- @title.scan(/(?<= |\A)@([^\s(]+)/).map {|tag| tag[0]}.sort.uniq
155
+ @title.scan(/(?<= |\A)@([^\s(]+)/).map { |tag| tag[0] }.sort.uniq
125
156
  end
126
157
 
127
158
  ##
@@ -190,6 +221,40 @@ module Doing
190
221
  should?('never_time')
191
222
  end
192
223
 
224
+ ##
225
+ ## Move item from current section to destination section
226
+ ##
227
+ ## @param new_section [String] The destination
228
+ ## section
229
+ ## @param label [Boolean] add @from(original
230
+ ## section) tag
231
+ ## @param log [Boolean] log this action
232
+ ##
233
+ ## @return nothing
234
+ ##
235
+ def move_to(new_section, label: true, log: true)
236
+ from = @section
237
+
238
+ tag('from', rename_to: 'from', value: from, force: true) if label
239
+ @section = new_section
240
+
241
+ Doing.logger.count(@section == 'Archive' ? :archived : :moved) if log
242
+ Doing.logger.debug("#{@section == 'Archive' ? 'Archived' : 'Moved'}:",
243
+ "#{@title.truncate(60)} from #{from} to #{@section}")
244
+ self
245
+ end
246
+
247
+ # outputs item in Doing file format, including leading tab
248
+ def to_s
249
+ "\t- #{@date.strftime('%Y-%m-%d %H:%M')} | #{@title}#{@note.empty? ? '' : "\n#{@note}"}"
250
+ end
251
+
252
+ # @private
253
+ def inspect
254
+ # %(<Doing::Item @date=#{@date} @title="#{@title}" @section:"#{@section}" @note:#{@note.to_s}>)
255
+ %(<Doing::Item @date=#{@date}>)
256
+ end
257
+
193
258
  private
194
259
 
195
260
  def should?(key)
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ # Items Array
5
+ class Items < Array
6
+ attr_accessor :sections
7
+
8
+ def initialize
9
+ super
10
+ @sections = []
11
+ end
12
+
13
+ # List sections, title only
14
+ #
15
+ # @return [Array] section titles
16
+ #
17
+ def section_titles
18
+ @sections.map(&:title)
19
+ end
20
+
21
+ # Test if section already exists
22
+ #
23
+ # @param section [String] section title
24
+ #
25
+ # @return [Boolean] true if section exists
26
+ #
27
+ def section?(section)
28
+ has_section = false
29
+ section = section.is_a?(Section) ? section.title.downcase : section.downcase
30
+ @sections.each do |s|
31
+ if s.title.downcase == section
32
+ has_section = true
33
+ break
34
+ end
35
+ end
36
+ has_section
37
+ end
38
+
39
+ # Add a new section to the sections array. Accepts
40
+ # either a Section object, or a title string that will
41
+ # be converted into a Section.
42
+ #
43
+ # @param section [Section] The section to add. A
44
+ # String value will be converted to
45
+ # Section automatically.
46
+ # @param log [Boolean] Add a log message
47
+ # notifying the user about the
48
+ # creation of the section.
49
+ #
50
+ # @return nothing
51
+ #
52
+ def add_section(section, log: false)
53
+ section = section.is_a?(Section) ? section : Section.new(section.cap_first)
54
+
55
+ return if section?(section)
56
+
57
+ @sections.push(section)
58
+ Doing.logger.info('New section:', %("#{section}" added)) if log
59
+ end
60
+
61
+ # Get a new Items object containing only items in a
62
+ # specified section
63
+ #
64
+ # @param section [String] section title
65
+ #
66
+ # @return [Items] Array of items
67
+ #
68
+ def in_section(section)
69
+ if section =~ /^all$/i
70
+ dup
71
+ else
72
+ items = Items.new.concat(select { |item| item.section == section })
73
+ items.add_section(section, log: false)
74
+ items
75
+ end
76
+ end
77
+
78
+ ##
79
+ ## Delete an item from the index
80
+ ##
81
+ ## @param item The item
82
+ ##
83
+ def delete_item(item, single: false)
84
+ deleted = delete(item)
85
+ Doing.logger.count(:deleted)
86
+ Doing.logger.info('Entry deleted:', deleted.title) if single
87
+ end
88
+
89
+ ##
90
+ ## Update an item in the index with a modified item
91
+ ##
92
+ ## @param old_item The old item
93
+ ## @param new_item The new item
94
+ ##
95
+ def update_item(old_item, new_item)
96
+ s_idx = index { |item| item.equal?(old_item) }
97
+
98
+ raise ItemNotFound, 'Unable to find item in index, did it mutate?' unless s_idx
99
+
100
+ return if fetch(s_idx).equal?(new_item)
101
+
102
+ self[s_idx] = new_item
103
+ Doing.logger.count(:updated)
104
+ Doing.logger.info('Entry updated:', self[s_idx].title.truncate(60))
105
+ new_item
106
+ end
107
+
108
+ # Output sections and items in Doing file format
109
+ def to_s
110
+ out = []
111
+ @sections.each do |section|
112
+ out.push(section.original)
113
+ in_section(section.title).each { |item| out.push(item.to_s)}
114
+ end
115
+
116
+ out.join("\n")
117
+ end
118
+
119
+ # @private
120
+ def inspect
121
+ "#<Doing::Items #{count} items, #{@sections.count} sections: #{@sections.map { |s| "<Section:#{s.title} #{in_section(s.title).count} items>" }.join(', ')}>"
122
+ end
123
+
124
+ end
125
+ end
@@ -5,9 +5,16 @@ module Doing
5
5
  ## Log adapter
6
6
  ##
7
7
  class LogAdapter
8
- attr_writer :logdev, :max_length
8
+ # Sets the log device
9
+ attr_writer :logdev
9
10
 
10
- attr_reader :messages, :level, :results
11
+ # Max length of log messages (truncate in middle)
12
+ attr_writer :max_length
13
+
14
+ # Returns the current log level (debug, info, warn, error)
15
+ attr_reader :level
16
+
17
+ attr_reader :messages, :results
11
18
 
12
19
  TOPIC_WIDTH = 12
13
20
 
@@ -28,6 +35,7 @@ module Doing
28
35
  deleted
29
36
  moved
30
37
  removed_tags
38
+ rotated
31
39
  skipped
32
40
  updated
33
41
  ].freeze
@@ -45,6 +53,7 @@ module Doing
45
53
  @logdev = $stderr
46
54
  @max_length = `tput cols`.strip.to_i - 5 || 85
47
55
  self.log_level = level
56
+ @prev_level = level
48
57
  end
49
58
 
50
59
  #
@@ -71,8 +80,26 @@ module Doing
71
80
  @level = level
72
81
  end
73
82
 
83
+ # Set log level temporarily
84
+ def temp_level(level)
85
+ return if level.nil? || level.to_sym == @log_level
86
+
87
+ @prev_level = log_level.dup
88
+ @log_level = level.to_sym
89
+ end
90
+
91
+ # Restore temporary level
92
+ def restore_level
93
+ return if @prev_level.nil? || @prev_level == @log_level
94
+
95
+ self.log_level = @prev_level
96
+ @prev_level = nil
97
+ end
98
+
74
99
  def adjust_verbosity(options = {})
75
- if options[:quiet]
100
+ if options[:log_level]
101
+ self.log_level = options[:log_level].to_sym
102
+ elsif options[:quiet]
76
103
  self.log_level = :error
77
104
  elsif options[:verbose] || options[:debug]
78
105
  self.log_level = :debug
@@ -227,7 +254,6 @@ module Doing
227
254
  ##
228
255
  def output_results
229
256
  total_counters
230
-
231
257
  results = @results.select { |msg| write_message?(msg[:level]) }.uniq
232
258
 
233
259
  if @logdev == $stdout
@@ -239,10 +265,38 @@ module Doing
239
265
  end
240
266
  end
241
267
 
268
+ def log_change(tags_added: [], tags_removed: [], count: 1, item: nil, single: false)
269
+ if tags_added.empty? && tags_removed.empty?
270
+ count(:skipped, level: :debug, message: '%count %items with no change', count: count)
271
+ else
272
+ if tags_added.empty?
273
+ count(:skipped, level: :debug, message: 'no tags added to %count %items')
274
+ elsif single && item
275
+ added = tags_added.log_tags
276
+ info('Tagged:',
277
+ %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{added} to #{item.title}))
278
+ else
279
+ count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items')
280
+ end
281
+
282
+ if tags_removed.empty?
283
+ count(:skipped, level: :debug, message: 'no tags removed from %count %items')
284
+ elsif single && item
285
+ added = tags_added.log_tags
286
+ info('Untagged:',
287
+ %(removed #{tags_removed.count == 1 ? 'tag' : 'tags'} #{added} from #{item.title}))
288
+ else
289
+ count(:removed_tags, level: :info, tag: tags_removed, message: '%tags removed from %count %items')
290
+ end
291
+ end
292
+ end
293
+
242
294
  private
243
295
 
244
296
  def format_counter(key, data)
245
297
  case key
298
+ when :rotated
299
+ ['Rotated:', data[:message] || 'rotated %count %items']
246
300
  when :autotag
247
301
  ['Autotag:', data[:message] || 'autotagged %count %items']
248
302
  when :added_tags