doing 2.1.30 → 2.1.34

Sign up to get free protection for your applications and to get access to all the features.
Files changed (142) hide show
  1. checksums.yaml +4 -4
  2. data/.irbrc +1 -0
  3. data/CHANGELOG.md +4972 -0
  4. data/Dockerfile-2.6 +3 -1
  5. data/Dockerfile-2.7 +4 -2
  6. data/Dockerfile-3.0 +3 -1
  7. data/Gemfile.lock +1 -67
  8. data/README.md +1 -1
  9. data/bash_profile +13 -0
  10. data/bin/commands/again.rb +1 -1
  11. data/bin/commands/archive.rb +3 -3
  12. data/bin/commands/cancel.rb +1 -1
  13. data/bin/commands/commands.rb +8 -8
  14. data/bin/commands/completion.rb +61 -19
  15. data/bin/commands/config.rb +22 -19
  16. data/bin/commands/done.rb +2 -2
  17. data/bin/commands/flag.rb +1 -1
  18. data/bin/commands/grep.rb +6 -33
  19. data/bin/commands/last.rb +1 -1
  20. data/bin/commands/meanwhile.rb +2 -2
  21. data/bin/commands/now.rb +2 -2
  22. data/bin/commands/on.rb +6 -16
  23. data/bin/commands/open.rb +1 -1
  24. data/bin/commands/recent.rb +5 -17
  25. data/bin/commands/rotate.rb +17 -0
  26. data/bin/commands/sections.rb +82 -7
  27. data/bin/commands/show.rb +8 -28
  28. data/bin/commands/since.rb +5 -16
  29. data/bin/commands/tag_dir.rb +27 -3
  30. data/bin/commands/today.rb +3 -28
  31. data/bin/commands/view.rb +3 -3
  32. data/bin/commands/yesterday.rb +3 -36
  33. data/bin/doing +29 -139
  34. data/docs/doc/Array.html +1 -1
  35. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  36. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  37. data/docs/doc/BooleanTermParser/Query.html +1 -1
  38. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  39. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  40. data/docs/doc/BooleanTermParser.html +1 -1
  41. data/docs/doc/Doing/Color.html +1 -1
  42. data/docs/doc/Doing/Completion.html +324 -4
  43. data/docs/doc/Doing/Configuration.html +3 -3
  44. data/docs/doc/Doing/Errors/DoingNoTraceError.html +1 -1
  45. data/docs/doc/Doing/Errors/DoingRuntimeError.html +1 -1
  46. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  47. data/docs/doc/Doing/Errors/EmptyInput.html +1 -1
  48. data/docs/doc/Doing/Errors/NoResults.html +1 -1
  49. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  50. data/docs/doc/Doing/Errors/UserCancelled.html +1 -1
  51. data/docs/doc/Doing/Errors/WrongCommand.html +1 -1
  52. data/docs/doc/Doing/Errors.html +1 -1
  53. data/docs/doc/Doing/Hooks.html +1 -1
  54. data/docs/doc/Doing/Item.html +144 -3
  55. data/docs/doc/Doing/Items.html +209 -1
  56. data/docs/doc/Doing/LogAdapter.html +1 -1
  57. data/docs/doc/Doing/Logger.html +1807 -0
  58. data/docs/doc/Doing/Note.html +109 -3
  59. data/docs/doc/Doing/Pager.html +1 -1
  60. data/docs/doc/Doing/Plugins.html +1 -1
  61. data/docs/doc/Doing/Prompt.html +1 -1
  62. data/docs/doc/Doing/Section.html +1 -1
  63. data/docs/doc/Doing/TemplateString.html +1 -1
  64. data/docs/doc/Doing/Types.html +3 -3
  65. data/docs/doc/Doing/Util/Backup.html +1 -1
  66. data/docs/doc/Doing/Util.html +1 -1
  67. data/docs/doc/Doing/WWID.html +8 -58
  68. data/docs/doc/Doing.html +4 -4
  69. data/docs/doc/FalseClass.html +1 -1
  70. data/docs/doc/GLI/Commands/Help.html +1 -1
  71. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  72. data/docs/doc/GLI/Commands.html +1 -1
  73. data/docs/doc/GLI.html +1 -1
  74. data/docs/doc/Hash.html +1 -1
  75. data/docs/doc/Object.html +1 -1
  76. data/docs/doc/PhraseParser/Operator.html +1 -1
  77. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  78. data/docs/doc/PhraseParser/Query.html +1 -1
  79. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  80. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  81. data/docs/doc/PhraseParser/TermClause.html +1 -1
  82. data/docs/doc/PhraseParser.html +1 -1
  83. data/docs/doc/Status.html +1 -1
  84. data/docs/doc/String.html +1 -1
  85. data/docs/doc/Symbol.html +1 -1
  86. data/docs/doc/Time.html +1 -1
  87. data/docs/doc/TrueClass.html +1 -1
  88. data/docs/doc/_index.html +12 -10
  89. data/docs/doc/class_list.html +1 -1
  90. data/docs/doc/file.README.html +2 -2
  91. data/docs/doc/index.html +2 -2
  92. data/docs/doc/method_list.html +424 -304
  93. data/docs/doc/top-level-namespace.html +105 -1
  94. data/docs/index.md +1 -1
  95. data/doing.gemspec +24 -24
  96. data/doing.rdoc +259 -26
  97. data/example_plugin.rb +7 -5
  98. data/inputrc +57 -0
  99. data/lib/completion/_doing.zsh +48 -52
  100. data/lib/completion/doing.bash +14 -25
  101. data/lib/completion/doing.fish +41 -15
  102. data/lib/doing/add_options.rb +152 -0
  103. data/lib/doing/array/array.rb +16 -0
  104. data/lib/doing/changelog/changes.rb +1 -1
  105. data/lib/doing/chronify/string.rb +1 -1
  106. data/lib/doing/completion/bash_completion.rb +12 -51
  107. data/lib/doing/completion/fish_completion.rb +17 -53
  108. data/lib/doing/completion/zsh_completion.rb +21 -59
  109. data/lib/doing/completion.rb +203 -17
  110. data/lib/doing/configuration.rb +7 -1
  111. data/lib/doing/item.rb +30 -5
  112. data/lib/doing/items.rb +53 -5
  113. data/lib/doing/{log_adapter.rb → logger.rb} +8 -2
  114. data/lib/doing/note.rb +24 -8
  115. data/lib/doing/plugins/export/dayone_export.rb +8 -6
  116. data/lib/doing/plugins/export/html_export.rb +4 -4
  117. data/lib/doing/plugins/export/json_export.rb +19 -20
  118. data/lib/doing/plugins/export/markdown_export.rb +2 -2
  119. data/lib/doing/plugins/export/template_export.rb +4 -4
  120. data/lib/doing/plugins/import/calendar_import.rb +2 -2
  121. data/lib/doing/plugins/import/doing_import.rb +2 -2
  122. data/lib/doing/plugins/import/timing_import.rb +2 -2
  123. data/lib/doing/string/highlight.rb +3 -4
  124. data/lib/doing/string/string.rb +8 -0
  125. data/lib/doing/string/tags.rb +1 -1
  126. data/lib/doing/types.rb +2 -2
  127. data/lib/doing/util.rb +1 -1
  128. data/lib/doing/util_backup.rb +12 -12
  129. data/lib/doing/version.rb +1 -1
  130. data/lib/doing/wwid.rb +119 -120
  131. data/lib/doing.rb +61 -3
  132. data/lib/examples/commands/wiki.rb +27 -19
  133. data/lib/examples/plugins/capture_thing_import.rb +1 -1
  134. data/lib/helpers/threaded_tests.rb +2 -0
  135. data/scripts/setting_replace.rb +11 -0
  136. metadata +109 -124
  137. data/.yardoc/checksums +0 -29
  138. data/.yardoc/complete +0 -0
  139. data/.yardoc/object_types +0 -0
  140. data/.yardoc/objects/root.dat +0 -0
  141. data/.yardoc/proxy_types +0 -0
  142. data/bin/commands/add_section.rb +0 -15
@@ -1,7 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Doing
2
4
  module Completion
3
- class ZshCompletions
5
+ class ::String
6
+ def sanitize
7
+ gsub(/'/, '\\\'').gsub(/\[/, '(').gsub(/\]/, ')')
8
+ end
9
+ end
4
10
 
11
+ # Generate completions for zsh
12
+ class ZshCompletions
5
13
  attr_accessor :commands, :global_options
6
14
 
7
15
  def generate_helpers
@@ -34,58 +42,11 @@ module Doing
34
42
  }
35
43
 
36
44
  EOFUNCTIONS
45
+ @bar.advance(status: '✅')
37
46
  @bar.finish
38
47
  out
39
48
  end
40
49
 
41
- def get_help_sections(command = '')
42
- res = `doing help #{command}`.strip
43
- scanned = res.scan(/(?m-i)^([A-Z ]+)\n([\s\S]*?)(?=\n+[A-Z]+|\Z)/)
44
- sections = {}
45
- scanned.each do |sect|
46
- title = sect[0].downcase.strip.gsub(/ +/, '_').to_sym
47
- content = sect[1].split(/\n/).map(&:strip).delete_if(&:empty?)
48
- sections[title] = content
49
- end
50
- sections
51
- end
52
-
53
- def parse_option(option)
54
- res = option.match(/(?:-(?<short>\w), )?(?:--(?:\[no-\])?(?<long>\w+)(?:=(?<arg>\w+))?)\s+- (?<desc>.*?)$/)
55
- return nil unless res
56
-
57
- {
58
- short: res['short'],
59
- long: res['long'],
60
- arg: res[:arg],
61
- description: res['desc'].short_desc
62
- }
63
- end
64
-
65
- def parse_options(options)
66
- options.map { |opt| parse_option(opt) }
67
- end
68
-
69
- def parse_command(command)
70
- res = command.match(/^(?<cmd>[^, \t]+)(?<alias>(?:, [^, \t]+)*)?\s+- (?<desc>.*?)$/)
71
- if res.nil?
72
- Doing.logger.error('Completion:', "Error parsing #{command}")
73
- return nil
74
-
75
- end
76
- commands = [res['cmd']]
77
- commands.concat(res['alias'].split(/, /).delete_if(&:empty?)) if res['alias']
78
-
79
- {
80
- commands: commands,
81
- description: res['desc'].short_desc
82
- }
83
- end
84
-
85
- def parse_commands(commands)
86
- commands.map { |cmd| parse_command(cmd) }
87
- end
88
-
89
50
  def generate_subcommand_completions
90
51
  out = []
91
52
  @commands.each_with_index do |cmd, i|
@@ -103,19 +64,19 @@ module Doing
103
64
  @commands.each_with_index do |cmd, i|
104
65
  @bar.advance(status: cmd[:commands].first)
105
66
 
106
- data = get_help_sections(cmd[:commands].first)
67
+ data = Completion.get_help_sections(cmd[:commands].first)
107
68
  option_arr = []
108
69
 
109
70
  if data[:command_options]
110
- parse_options(data[:command_options]).each do |option|
71
+ Completion.parse_options(data[:command_options]).each do |option|
111
72
  next if option.nil?
112
73
 
113
- arg = option[:arg] ? '=' : ''
74
+ arg = option[:arg] ? ":#{option[:arg]}:" : ''
114
75
 
115
76
  option_arr << if option[:short]
116
- %({-#{option[:short]},--#{option[:long]}#{arg}}"[#{option[:description].gsub(/'/, '\\\'')}]")
77
+ %({'(--#{option[:long]})-#{option[:short]}','(-#{option[:short]})--#{option[:long]}'}"[#{option[:description].sanitize}]#{arg}")
117
78
  else
118
- %("(--#{option[:long]}#{arg})--#{option[:long]}#{arg}}[#{option[:description].gsub(/'/, '\\\'')}]")
79
+ %("--#{option[:long]}[#{option[:description].sanitize}]#{arg}")
119
80
  end
120
81
  end
121
82
  end
@@ -129,11 +90,12 @@ module Doing
129
90
  end
130
91
 
131
92
  def initialize
132
- data = get_help_sections
133
- @global_options = parse_options(data[:global_options])
134
- @commands = parse_commands(data[:commands])
135
- @bar = TTY::ProgressBar.new(" \033[0;0;33mGenerating Zsh completions: \033[0;35;40m[:bar] :status\033[0m", total: @commands.count, bar_format: :blade, status: 'processing subcommands')
136
- @bar.resize(25)
93
+ data = Completion.get_help_sections
94
+ @global_options = Completion.parse_options(data[:global_options])
95
+ @commands = Completion.parse_commands(data[:commands])
96
+ @bar = TTY::ProgressBar.new(" \033[0;0;33mGenerating Zsh completions: \033[0;35;40m[:bar] :status\033[0m", total: @commands.count + 1, bar_format: :square, hide_cursor: true, status: 'processing subcommands')
97
+ width = TTY::Screen.columns - 45
98
+ @bar.resize(width)
137
99
  end
138
100
 
139
101
  def generate_completions
@@ -11,25 +11,118 @@ require 'bash_completion'
11
11
  module Doing
12
12
  # Completion script generator
13
13
  module Completion
14
+ OPTIONS_RX = /(?:-(?<short>\w), )?(?:--(?:\[no-\])?(?<long>\w+)(?:=(?<arg>\w+))?)\s+- (?<desc>.*?)$/.freeze
15
+ SECTIONS_RX = /(?m-i)^([A-Z ]+)\n([\s\S]*?)(?=\n+[A-Z]+|\Z)/.freeze
16
+ COMMAND_RX = /^(?<cmd>[^, \t]+)(?<alias>(?:, [^, \t]+)*)?\s+- (?<desc>.*?)$/.freeze
17
+
14
18
  class << self
19
+ def get_help_sections(command = '')
20
+ res = `doing help #{command}`.strip
21
+ scanned = res.scan(SECTIONS_RX)
22
+ sections = {}
23
+ scanned.each do |sect|
24
+ title = sect[0].downcase.strip.gsub(/ +/, '_').to_sym
25
+ content = sect[1].split(/\n/).map(&:strip).delete_if(&:empty?)
26
+ sections[title] = content
27
+ end
28
+ sections
29
+ end
30
+
31
+ def parse_option(option)
32
+ res = option.match(OPTIONS_RX)
33
+ return nil unless res
34
+
35
+ {
36
+ short: res['short'],
37
+ long: res['long'],
38
+ arg: res['arg'],
39
+ description: res['desc'].short_desc
40
+ }
41
+ end
42
+
43
+ def parse_options(options)
44
+ options.map { |opt| parse_option(opt) }
45
+ end
46
+
47
+ def parse_command(command)
48
+ res = command.match(COMMAND_RX)
49
+ commands = [res['cmd']]
50
+ commands.concat(res['alias'].split(/, /).delete_if(&:empty?)) if res['alias']
51
+
52
+ {
53
+ commands: commands,
54
+ description: res['desc'].short_desc
55
+ }
56
+ end
57
+
58
+ def parse_commands(commands)
59
+ commands.map { |cmd| parse_command(cmd) }
60
+ end
61
+
15
62
  # Generate a completion script and output to file or
16
63
  # stdout
17
64
  #
18
65
  # @param type [String] shell to generate for (zsh|bash|fish)
19
66
  # @param file [String] Path to save to, or 'stdout'
20
67
  #
21
- def generate_completion(type: 'zsh', file: 'stdout')
22
- if type =~ /^all$/i
23
- Doing.logger.log_now(:warn, 'Generating:', 'all completion types, will use default paths')
24
- generate_completion(type: 'fish', file: 'lib/completion/doing.fish')
25
- Doing.logger.warn('File written:', "fish completions written to lib/completion/doing.fish")
26
- generate_completion(type: 'zsh', file: 'lib/completion/_doing.zsh')
27
- Doing.logger.warn('File written:', "zsh completions written to lib/completion/_doing.zsh")
28
- generate_completion(type: 'bash', file: 'lib/completion/doing.bash')
29
- Doing.logger.warn('File written:', "bash completions written to lib/completion/doing.bash")
30
- return
68
+ def generate_completion(type: 'zsh', file: :default, link: true)
69
+ return generate_all if type =~ /^all$/i
70
+
71
+ file = file == :default ? default_file(type) : file
72
+ file = validate_target(file)
73
+ result = generate_type(type)
74
+
75
+ if file =~ /^stdout$/i
76
+ $stdout.puts result
77
+ else
78
+ File.open(file, 'w') { |f| f.puts result }
79
+ Doing.logger.warn('File written:', "#{type} completions written to #{file}")
80
+
81
+ link_completion_type(type, file) if link
31
82
  end
83
+ end
84
+
85
+ def link_default(type)
86
+ type = normalize_type(type)
87
+ raise InvalidArgument, 'Unrecognized shell specified' if type == :invalid
88
+
89
+ return %i[zsh bash fish].each { |t| link_default(t) } if type == :all
32
90
 
91
+ install_builtin(type)
92
+
93
+ link_completion_type(type, File.join(default_dir, default_filenames[type]))
94
+ end
95
+
96
+ def install_builtin(type)
97
+ FileUtils.mkdir_p(default_dir)
98
+ src = File.expand_path(File.join(File.dirname(__FILE__), '..', 'completion', default_filenames[type]))
99
+
100
+ if File.exist?(File.join(default_dir, default_filenames[type]))
101
+ return unless Doing::Prompt.yn("Update #{type} completion script", default_response: 'n')
102
+ end
103
+
104
+ FileUtils.cp(src, default_dir)
105
+ Doing.logger.warn('File written:', "#{type} completions saved to #{default_file(type)}")
106
+ end
107
+
108
+ def normalize_type(type)
109
+ case type.to_s
110
+ when /^f/i
111
+ :fish
112
+ when /^b/i
113
+ :bash
114
+ when /^z/i
115
+ :zsh
116
+ when /^a/i
117
+ :all
118
+ else
119
+ :invalid
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def generate_type(type)
33
126
  generator = case type.to_s
34
127
  when /^f/i
35
128
  FishCompletions.new
@@ -39,16 +132,109 @@ module Doing
39
132
  ZshCompletions.new
40
133
  end
41
134
 
42
- result = generator.generate_completions
135
+ generator.generate_completions
136
+ end
43
137
 
44
- if file =~ /^stdout$/i
45
- $stdout.puts result
46
- else
47
- File.open(File.expand_path(file), 'w') do |f|
48
- f.puts result
138
+ def validate_target(file)
139
+ unless file =~ /stdout/i
140
+ file = validate_file(file)
141
+
142
+ validate_dir(file)
143
+ end
144
+
145
+ file
146
+ end
147
+
148
+ def default_dir
149
+ File.expand_path('~/.local/share/doing/completion')
150
+ end
151
+
152
+ def default_filenames
153
+ { zsh: '_doing.zsh', bash: 'doing.bash', fish: 'doing.fish' }
154
+ end
155
+
156
+ def default_file(type)
157
+ type = normalize_type(type)
158
+
159
+ File.join(default_dir, default_filenames[type])
160
+ end
161
+
162
+ def validate_file(file)
163
+ file = File.expand_path(file)
164
+ if File.exist?(file)
165
+ res = Doing::Prompt.yn("Overwrite #{file}", default_response: 'y')
166
+ raise UserCancelled unless res
167
+
168
+ FileUtils.rm(file) if res
169
+ end
170
+ file
171
+ end
172
+
173
+ def validate_dir(file)
174
+ dir = File.dirname(file)
175
+ unless File.directory?(dir)
176
+ res = Doing::Prompt.yn("#{dir} doesn't exist, create it", default_response: 'y')
177
+ raise UserCancelled unless res
178
+
179
+ FileUtils.mkdir_p(dir)
180
+ end
181
+ dir
182
+ end
183
+
184
+ def generate_all
185
+ Doing.logger.log_now(:warn, 'Generating:', 'all completion types, will use default paths')
186
+ generate_completion(type: 'fish', file: 'lib/completion/doing.fish', link: false)
187
+ Doing.logger.warn('File written:', 'fish completions written to lib/completion/doing.fish')
188
+ generate_completion(type: 'zsh', file: 'lib/completion/_doing.zsh', link: false)
189
+ Doing.logger.warn('File written:', 'zsh completions written to lib/completion/_doing.zsh')
190
+ generate_completion(type: 'bash', file: 'lib/completion/doing.bash', link: false)
191
+ Doing.logger.warn('File written:', 'bash completions written to lib/completion/doing.bash')
192
+ end
193
+
194
+ def link_completion_type(type, file)
195
+ dir = File.dirname(file)
196
+ case type.to_s
197
+ when /^b/i
198
+ unless dir =~ %r{(\.bash_it/completion|bash_completion/completions)}
199
+ link_completion(file, ['~/.bash_it/completion/enabled', '/usr/share/bash_completion/completions'], 'doing.bash')
49
200
  end
50
- Doing.logger.warn('File written:', "#{type} completions written to #{file}")
201
+ when /^f/i
202
+ link_completion(file, ['~/.config/fish/completions'], 'doing.fish') unless dir =~ %r{.config/fish/completions}
203
+ when /^z/i
204
+ unless dir =~ %r{(\.oh-my-zsh/completions|share/site-functions)}
205
+ link_completion(file, ['~/.oh-my-zsh/completions', '/usr/local/share/zsh/site-functions'], '_doing.zsh')
206
+ end
207
+ end
208
+ end
209
+
210
+ def link_completion(file, targets, filename)
211
+ return if targets.map { |t| File.expand_path(t) }.include?(File.dirname(file))
212
+
213
+ found = false
214
+ linked = false
215
+
216
+ targets.each do |target|
217
+ next unless File.directory?(File.expand_path(target))
218
+ found = true
219
+
220
+ target_file = File.join(File.expand_path(target), filename)
221
+ next unless Doing::Prompt.yn("Create link to #{target_file}", default_response: 'n')
222
+
223
+ FileUtils.ln_s(File.expand_path(file), target_file, force: true)
224
+ Doing.logger.warn('File linked:', "#{File.expand_path(file)} -> #{target_file}")
225
+ linked = true
226
+ break
227
+ end
228
+
229
+ return if linked
230
+
231
+ unless found
232
+ $stdout.puts 'No known auto-load directory found for specified shell'.red
233
+ $stdout.puts "Looked for #{targets.join(', ')}, found no existing directory".yellow
51
234
  end
235
+ $stdout.puts 'If you don\'t want to autoload completions'.yellow
236
+ $stdout.puts 'you can source the script directly in your shell\'s startup file:'.yellow
237
+ $stdout.puts %(source "#{file}").boldwhite
52
238
  end
53
239
  end
54
240
  end
@@ -156,7 +156,13 @@ module Doing
156
156
  ##
157
157
  ## @return [String] file path
158
158
  ##
159
- def choose_config(create: false)
159
+ def choose_config(create: false, local: false)
160
+ if local && create
161
+ res = File.expand_path('.doingrc')
162
+ FileUtils.touch(res)
163
+ return res
164
+ end
165
+
160
166
  return @config_file if @force_answer
161
167
 
162
168
  if @additional_configs&.count&.positive? || create
data/lib/doing/item.rb CHANGED
@@ -35,6 +35,8 @@ module Doing
35
35
 
36
36
  ## If the entry doesn't have a @done date, return the elapsed time
37
37
  def duration
38
+ return nil unless should_time? && should_finish?
39
+
38
40
  return nil if @title =~ /(?<=^| )@done\b/
39
41
 
40
42
  return Time.now - @date
@@ -88,17 +90,20 @@ module Doing
88
90
  ##
89
91
  ## Test for equality between items
90
92
  ##
91
- ## @param other [Item] The other item
93
+ ## @param other [Item] The other item
94
+ ## @param match_section [Boolean] If true, require item sections to match
92
95
  ##
93
96
  ## @return [Boolean] is equal?
94
97
  ##
95
- def equal?(other)
98
+ def equal?(other, match_section: false)
96
99
  return false if @title.strip != other.title.strip
97
100
 
98
101
  return false if @date != other.date
99
102
 
100
103
  return false unless @note.equal?(other.note)
101
104
 
105
+ return false if match_section && @section != other.section
106
+
102
107
  true
103
108
  end
104
109
 
@@ -272,7 +277,7 @@ module Doing
272
277
  end
273
278
 
274
279
  def highlight_search(search, distance: nil, negate: false, case_type: nil)
275
- prefs = Doing.config.settings['search'] || {}
280
+ prefs = Doing.setting('search', {})
276
281
  matching = prefs.fetch('matching', 'pattern').normalize_matching
277
282
  distance ||= prefs.fetch('distance', 3).to_i
278
283
  case_type ||= prefs.fetch('case', 'smart').normalize_case
@@ -311,7 +316,7 @@ module Doing
311
316
  ## @return [Boolean] matches search criteria
312
317
  ##
313
318
  def search(search, distance: nil, negate: false, case_type: nil)
314
- prefs = Doing.config.settings['search'] || {}
319
+ prefs = Doing.setting('search', {})
315
320
  matching = prefs.fetch('matching', 'pattern').normalize_matching
316
321
  distance ||= prefs.fetch('distance', 3).to_i
317
322
  case_type ||= prefs.fetch('case', 'smart').normalize_case
@@ -354,6 +359,24 @@ module Doing
354
359
  negate ? !matches : matches
355
360
  end
356
361
 
362
+ ##
363
+ ## Test if item has a @done tag
364
+ ##
365
+ ## @return [Boolean] true item has @done tag
366
+ ##
367
+ def finished?
368
+ tags?('done')
369
+ end
370
+
371
+ ##
372
+ ## Test if item does not contain @done tag
373
+ ##
374
+ ## @return [Boolean] true if item is missing @done tag
375
+ ##
376
+ def unfinished?
377
+ tags?('done', negate: true)
378
+ end
379
+
357
380
  ##
358
381
  ## Test if item is included in never_finish config and
359
382
  ## thus should not receive a @done tag
@@ -436,7 +459,7 @@ module Doing
436
459
  private
437
460
 
438
461
  def should?(key)
439
- config = Doing.config.settings
462
+ config = Doing.settings
440
463
  return true unless config[key].is_a?(Array)
441
464
 
442
465
  config[key].each do |tag|
@@ -451,6 +474,8 @@ module Doing
451
474
  end
452
475
 
453
476
  def calc_interval
477
+ return nil unless should_time? && should_finish?
478
+
454
479
  done = end_date
455
480
  return nil if done.nil?
456
481
 
data/lib/doing/items.rb CHANGED
@@ -58,6 +58,24 @@ module Doing
58
58
  Doing.logger.info('New section:', %("#{section}" added)) if log
59
59
  end
60
60
 
61
+ def delete_section(section, log: false)
62
+ return unless section?(section)
63
+
64
+ raise DoingRuntimeError, 'Section not empty' if in_section(section).count > 0
65
+
66
+ deleted = false
67
+
68
+ @sections.each do |sect|
69
+ if sect.title == section && in_section(sect).count.zero?
70
+ @sections.delete(sect)
71
+ Doing.logger.info('Removed section:', %("#{section}" removed)) if log
72
+ return
73
+ end
74
+ end
75
+
76
+ Doing.logger.error('Not found:', %("#{section}" not found))
77
+ end
78
+
61
79
  # Get a new Items object containing only items in a
62
80
  # specified section
63
81
  #
@@ -126,14 +144,44 @@ module Doing
126
144
  diff
127
145
  end
128
146
 
147
+ ##
148
+ ## Remove duplicated entries. Duplicate entries must have matching start date, title, note, and section
149
+ ##
150
+ ## @return [Items] Items array with duplicate entries removed
151
+ ##
152
+ def dedup(match_section: true)
153
+ unique = Items.new
154
+ each do |item|
155
+ unique.push(item) unless unique.include?(item, match_section: match_section)
156
+ end
157
+
158
+ unique
159
+ end
160
+
161
+ def dedup!(match_section: true)
162
+ replace dedup(match_section: match_section)
163
+ end
164
+
165
+ def include?(item, match_section: true)
166
+ includes = false
167
+ each do |other_item|
168
+ if other_item.equal?(item, match_section: match_section)
169
+ includes = true
170
+ break
171
+ end
172
+ end
173
+
174
+ includes
175
+ end
176
+
129
177
  # Output sections and items in Doing file format
130
178
  def to_s
131
179
  out = []
132
180
  @sections.each do |section|
133
181
  out.push(section.original)
134
- items = in_section(section.title).sort_by { |i| i.date }
135
- items.reverse! if Doing.config.settings['doing_file_sort'].normalize_order == :desc
136
- items.each { |item| out.push(item.to_s)}
182
+ items = in_section(section.title).sort_by(&:date)
183
+ items.reverse! if Doing.setting('doing_file_sort').normalize_order == :desc
184
+ items.each { |item| out.push(item.to_s) }
137
185
  end
138
186
 
139
187
  out.join("\n")
@@ -141,8 +189,8 @@ module Doing
141
189
 
142
190
  # @private
143
191
  def inspect
144
- "#<Doing::Items #{count} items, #{@sections.count} sections: #{@sections.map { |s| "<Section:#{s.title} #{in_section(s.title).count} items>" }.join(', ')}>"
192
+ sections = @sections.map { |s| "<Section:#{s.title} #{in_section(s.title).count} items>" }.join(', ')
193
+ "#<Doing::Items #{count} items, #{@sections.count} sections: #{sections}>"
145
194
  end
146
-
147
195
  end
148
196
  end
@@ -4,7 +4,7 @@ module Doing
4
4
  ##
5
5
  ## Log adapter
6
6
  ##
7
- class LogAdapter
7
+ class Logger
8
8
  # Sets the log device
9
9
  attr_writer :logdev
10
10
 
@@ -332,9 +332,15 @@ module Doing
332
332
  if tags_added.empty?
333
333
  count(:skipped, level: :debug, message: 'no tags added to %count %items')
334
334
  elsif single && item
335
+ elapsed = if item && tags_added.include?('done')
336
+ item.interval ? " (#{item.interval&.time_string(format: :dhm)})" : ''
337
+ else
338
+ ''
339
+ end
340
+
335
341
  added = tags_added.log_tags
336
342
  info('Tagged:',
337
- %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{added} to #{item.title}))
343
+ %(added #{tags_added.count == 1 ? 'tag' : 'tags'} #{added}#{elapsed} to #{item.title}))
338
344
  else
339
345
  count(:added_tags, level: :info, tag: tags_added, message: '%tags added to %count %items')
340
346
  end
data/lib/doing/note.rb CHANGED
@@ -5,7 +5,6 @@ module Doing
5
5
  ## This class describes an item note.
6
6
  ##
7
7
  class Note < Array
8
-
9
8
  ##
10
9
  ## Initializes a new note
11
10
  ##
@@ -28,9 +27,10 @@ module Doing
28
27
  ##
29
28
  def add(note, replace: false)
30
29
  clear if replace
31
- if note.is_a?(String)
30
+ case note
31
+ when String
32
32
  append_string(note)
33
- elsif note.is_a?(Array)
33
+ when Array
34
34
  append(note)
35
35
  end
36
36
  end
@@ -55,7 +55,7 @@ module Doing
55
55
  ## @return [Array] Stripped note
56
56
  ##
57
57
  def strip_lines
58
- map(&:strip)
58
+ Note.new(map(&:strip))
59
59
  end
60
60
 
61
61
  def strip_lines!
@@ -64,8 +64,24 @@ module Doing
64
64
 
65
65
  ##
66
66
  ## Note as multi-line string
67
- def to_s
68
- compress.strip_lines.map { |l| "\t\t#{l}" }.join("\n")
67
+ ##
68
+ ## @param prefix [String] prefix for each line (default two tabs, TaskPaper format)
69
+ ##
70
+ def to_s(prefix: "\t\t")
71
+ compress.strip_lines.map { |l| "#{prefix}#{l}" }.join("\n")
72
+ end
73
+
74
+ ##
75
+ ## Returns note as a single line, newlines separated by
76
+ ## space
77
+ ##
78
+ ## @return [String] Line representation of the Note.
79
+ ##
80
+ ## @param separator The separator with which to
81
+ ## join multiple lines
82
+ ##
83
+ def to_line(separator: ' ')
84
+ compress.strip_lines.join(separator)
69
85
  end
70
86
 
71
87
  # @private
@@ -94,7 +110,7 @@ module Doing
94
110
  ## @param lines [Array] Array of strings
95
111
  ##
96
112
  def append(lines)
97
- concat(lines)
113
+ concat(lines.utf8)
98
114
  replace compress
99
115
  end
100
116
 
@@ -105,7 +121,7 @@ module Doing
105
121
  ## newlines will be split
106
122
  ##
107
123
  def append_string(input)
108
- concat(input.split(/\n/).map(&:strip))
124
+ concat(input.utf8.split(/\n/).map(&:strip))
109
125
  replace compress
110
126
  end
111
127
  end