doing 2.1.39 → 2.1.42

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 (229) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +1 -1
  3. data/CHANGELOG.md +67 -0
  4. data/Gemfile.lock +1 -1
  5. data/README.md +1 -1
  6. data/Rakefile +4 -4
  7. data/bin/commands/again.rb +1 -3
  8. data/bin/commands/changes.rb +50 -34
  9. data/bin/commands/commands.rb +77 -52
  10. data/bin/commands/commands_accepting.rb +57 -53
  11. data/bin/commands/config.rb +45 -36
  12. data/bin/commands/done.rb +1 -18
  13. data/bin/commands/finish.rb +90 -59
  14. data/bin/commands/flag.rb +5 -1
  15. data/bin/commands/grep.rb +3 -14
  16. data/bin/commands/last.rb +2 -8
  17. data/bin/commands/meanwhile.rb +13 -6
  18. data/bin/commands/now.rb +151 -107
  19. data/bin/commands/on.rb +8 -18
  20. data/bin/commands/recent.rb +2 -8
  21. data/bin/commands/reset.rb +24 -1
  22. data/bin/commands/select.rb +1 -1
  23. data/bin/commands/show.rb +6 -17
  24. data/bin/commands/since.rb +1 -12
  25. data/bin/commands/tag_dir.rb +49 -15
  26. data/bin/commands/today.rb +2 -13
  27. data/bin/commands/undo.rb +4 -6
  28. data/bin/commands/view.rb +1 -1
  29. data/bin/commands/yesterday.rb +2 -13
  30. data/bin/doing +15 -8
  31. data/{Dockerfile → docker/Dockerfile} +3 -1
  32. data/{Dockerfile-2.6 → docker/Dockerfile-2.6} +2 -2
  33. data/{Dockerfile-2.7 → docker/Dockerfile-2.7} +2 -2
  34. data/{Dockerfile-3.0 → docker/Dockerfile-3.0} +2 -2
  35. data/{bash_profile → docker/bash_profile} +0 -0
  36. data/{inputrc → docker/inputrc} +0 -0
  37. data/docs/doc/Array.html +85 -2
  38. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  39. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  40. data/docs/doc/BooleanTermParser/Query.html +1 -1
  41. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  42. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  43. data/docs/doc/BooleanTermParser.html +1 -1
  44. data/docs/doc/Doing/ArrayNestedHash.html +198 -0
  45. data/docs/doc/Doing/ArrayTags.html +424 -0
  46. data/docs/doc/Doing/CSVExport.html +266 -0
  47. data/docs/doc/Doing/CalendarImport.html +232 -0
  48. data/docs/doc/Doing/Change.html +617 -0
  49. data/docs/doc/Doing/Changes.html +468 -0
  50. data/docs/doc/Doing/ChronifyArray.html +347 -0
  51. data/docs/doc/Doing/ChronifyNumeric.html +271 -0
  52. data/docs/doc/Doing/ChronifyString.html +682 -0
  53. data/docs/doc/Doing/Color.html +167 -21
  54. data/docs/doc/Doing/Completion/BashCompletions.html +445 -0
  55. data/docs/doc/Doing/Completion/FishCompletions.html +445 -0
  56. data/docs/doc/Doing/Completion/StringUtils.html +229 -0
  57. data/docs/doc/Doing/Completion/ZshCompletions.html +445 -0
  58. data/docs/doc/Doing/Completion.html +17 -3
  59. data/docs/doc/Doing/Configuration.html +3 -2
  60. data/docs/doc/Doing/DayOneRenderer.html +383 -0
  61. data/docs/doc/Doing/DayoneExport.html +290 -0
  62. data/docs/doc/Doing/DoingImport.html +391 -0
  63. data/docs/doc/Doing/Entry.html +381 -0
  64. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  65. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  66. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  67. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  68. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  69. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  70. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  71. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  72. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  73. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  74. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  75. data/docs/doc/Doing/Errors.html +9 -9
  76. data/docs/doc/Doing/HTMLExport.html +256 -0
  77. data/docs/doc/Doing/Hooks.html +1 -1
  78. data/docs/doc/Doing/Item.html +179 -1660
  79. data/docs/doc/Doing/ItemDates.html +564 -0
  80. data/docs/doc/Doing/ItemQuery.html +614 -0
  81. data/docs/doc/Doing/ItemState.html +387 -0
  82. data/docs/doc/Doing/ItemTags.html +498 -0
  83. data/docs/doc/Doing/Items.html +581 -15
  84. data/docs/doc/Doing/JSONExport.html +222 -0
  85. data/docs/doc/Doing/Logger.html +1 -1
  86. data/docs/doc/Doing/MarkdownExport.html +266 -0
  87. data/docs/doc/Doing/MarkdownRenderer.html +383 -0
  88. data/docs/doc/Doing/Note.html +18 -4
  89. data/docs/doc/Doing/Pager.html +1 -1
  90. data/docs/doc/Doing/Plugins.html +181 -76
  91. data/docs/doc/Doing/Prompt.html +32 -683
  92. data/docs/doc/Doing/PromptChoose.html +484 -0
  93. data/docs/doc/Doing/PromptFZF.html +391 -0
  94. data/docs/doc/Doing/PromptInput.html +572 -0
  95. data/docs/doc/Doing/PromptSTD.html +293 -0
  96. data/docs/doc/Doing/PromptYN.html +237 -0
  97. data/docs/doc/Doing/Section.html +58 -2
  98. data/docs/doc/Doing/StringHighlight.html +533 -0
  99. data/docs/doc/Doing/StringNormalize.html +929 -0
  100. data/docs/doc/Doing/StringQuery.html +725 -0
  101. data/docs/doc/Doing/StringTags.html +884 -0
  102. data/docs/doc/Doing/StringTransform.html +599 -0
  103. data/docs/doc/Doing/StringTruncate.html +448 -0
  104. data/docs/doc/Doing/StringURL.html +409 -0
  105. data/docs/doc/Doing/SymbolNormalize.html +341 -0
  106. data/docs/doc/Doing/TaskPaperExport.html +222 -0
  107. data/docs/doc/Doing/TemplateExport.html +249 -0
  108. data/docs/doc/Doing/TemplateString.html +102 -3
  109. data/docs/doc/Doing/TimingImport.html +285 -0
  110. data/docs/doc/Doing/Types.html +1 -1
  111. data/docs/doc/Doing/Util/Backup.html +11 -163
  112. data/docs/doc/Doing/Util.html +67 -10
  113. data/docs/doc/Doing/Version.html +523 -0
  114. data/docs/doc/Doing/WWID/WWIDUtil.html +510 -0
  115. data/docs/doc/Doing/WWID.html +476 -139
  116. data/docs/doc/Doing/WWIDDisplay.html +865 -0
  117. data/docs/doc/Doing/WWIDEditor.html +466 -0
  118. data/docs/doc/Doing/WWIDFileTools.html +359 -0
  119. data/docs/doc/Doing/WWIDFilter.html +466 -0
  120. data/docs/doc/Doing/WWIDGuess.html +299 -0
  121. data/docs/doc/Doing/WWIDInteractive.html +752 -0
  122. data/docs/doc/Doing/WWIDModify.html +1078 -0
  123. data/docs/doc/Doing/WWIDTags.html +302 -0
  124. data/docs/doc/Doing/WWIDTimers.html +359 -0
  125. data/docs/doc/Doing/WWIDUtil.html +510 -0
  126. data/docs/doc/Doing.html +9 -6
  127. data/docs/doc/FalseClass.html +1 -1
  128. data/docs/doc/GLI/Commands/Help.html +1 -1
  129. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  130. data/docs/doc/GLI/Commands.html +1 -1
  131. data/docs/doc/GLI.html +1 -1
  132. data/docs/doc/Hash.html +1 -1
  133. data/docs/doc/Numeric.html +23 -78
  134. data/docs/doc/Object.html +1 -1
  135. data/docs/doc/PhraseParser/Operator.html +1 -1
  136. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  137. data/docs/doc/PhraseParser/Query.html +1 -1
  138. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  139. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  140. data/docs/doc/PhraseParser/TermClause.html +1 -1
  141. data/docs/doc/PhraseParser.html +1 -1
  142. data/docs/doc/Status.html +1 -1
  143. data/docs/doc/String.html +58 -633
  144. data/docs/doc/Symbol.html +9 -224
  145. data/docs/doc/Time.html +119 -13
  146. data/docs/doc/TrueClass.html +1 -1
  147. data/docs/doc/_index.html +348 -4
  148. data/docs/doc/class_list.html +1 -1
  149. data/docs/doc/file.README.html +2 -2
  150. data/docs/doc/index.html +2 -2
  151. data/docs/doc/method_list.html +1904 -592
  152. data/docs/doc/top-level-namespace.html +12 -4
  153. data/docs/index.md +1 -1
  154. data/doing.rdoc +67 -15
  155. data/lib/completion/_doing.zsh +6 -6
  156. data/lib/completion/doing.bash +10 -10
  157. data/lib/completion/doing.fish +10 -3
  158. data/lib/doing/add_options.rb +39 -1
  159. data/lib/doing/array/array.rb +18 -12
  160. data/lib/doing/array/cleanup.rb +31 -0
  161. data/lib/doing/array/nested_hash.rb +1 -1
  162. data/lib/doing/array/tags.rb +6 -5
  163. data/lib/doing/changelog/changelog.rb +6 -0
  164. data/lib/doing/chronify/array.rb +65 -25
  165. data/lib/doing/chronify/chronify.rb +12 -0
  166. data/lib/doing/chronify/numeric.rb +3 -2
  167. data/lib/doing/chronify/string.rb +1 -1
  168. data/lib/doing/colors.rb +77 -30
  169. data/lib/doing/completion/completion_string.rb +25 -0
  170. data/lib/doing/completion.rb +4 -5
  171. data/lib/doing/configuration.rb +7 -3
  172. data/lib/doing/errors.rb +51 -35
  173. data/lib/doing/good.rb +8 -0
  174. data/lib/doing/hooks.rb +3 -3
  175. data/lib/doing/item/dates.rb +112 -0
  176. data/lib/doing/item/item.rb +128 -0
  177. data/lib/doing/{item.rb → item/query.rb} +2 -353
  178. data/lib/doing/item/state.rb +59 -0
  179. data/lib/doing/item/tags.rb +87 -0
  180. data/lib/doing/items/filter.rb +67 -0
  181. data/lib/doing/items/items.rb +57 -0
  182. data/lib/doing/items/modify.rb +36 -0
  183. data/lib/doing/items/sections.rb +83 -0
  184. data/lib/doing/items/util.rb +74 -0
  185. data/lib/doing/normalize.rb +10 -2
  186. data/lib/doing/note.rb +1 -1
  187. data/lib/doing/pager.rb +9 -3
  188. data/lib/doing/plugin_manager.rb +33 -8
  189. data/lib/doing/plugins/export/markdown_export.rb +4 -2
  190. data/lib/doing/plugins/export/template_export.rb +4 -4
  191. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  192. data/lib/doing/plugins/import/doing_import.rb +1 -1
  193. data/lib/doing/prompt/choose.rb +118 -0
  194. data/lib/doing/prompt/fzf.rb +84 -0
  195. data/lib/doing/prompt/input.rb +129 -0
  196. data/lib/doing/prompt/prompt.rb +41 -0
  197. data/lib/doing/prompt/std.rb +32 -0
  198. data/lib/doing/prompt/yn.rb +64 -0
  199. data/lib/doing/section.rb +4 -0
  200. data/lib/doing/string/highlight.rb +1 -1
  201. data/lib/doing/string/query.rb +1 -1
  202. data/lib/doing/string/string.rb +18 -7
  203. data/lib/doing/string/tags.rb +14 -3
  204. data/lib/doing/string/transform.rb +7 -1
  205. data/lib/doing/string/truncate.rb +1 -1
  206. data/lib/doing/string/url.rb +1 -1
  207. data/lib/doing/time.rb +19 -1
  208. data/lib/doing/util.rb +12 -6
  209. data/lib/doing/util_backup.rb +62 -57
  210. data/lib/doing/version.rb +1 -1
  211. data/lib/doing/wwid/display.rb +396 -0
  212. data/lib/doing/wwid/editor.rb +214 -0
  213. data/lib/doing/wwid/filetools.rb +183 -0
  214. data/lib/doing/wwid/filter.rb +226 -0
  215. data/lib/doing/wwid/guess.rb +85 -0
  216. data/lib/doing/wwid/interactive.rb +377 -0
  217. data/lib/doing/wwid/modify.rb +617 -0
  218. data/lib/doing/wwid/tags.rb +51 -0
  219. data/lib/doing/wwid/timers.rb +342 -0
  220. data/lib/doing/wwid/wwid.rb +121 -0
  221. data/lib/doing/wwid/wwidutil.rb +101 -0
  222. data/lib/doing.rb +7 -7
  223. data/lib/helpers/threaded_tests.rb +1 -0
  224. metadata +94 -14
  225. data/lib/doing/changelog.rb +0 -6
  226. data/lib/doing/completion/string.rb +0 -17
  227. data/lib/doing/items.rb +0 -196
  228. data/lib/doing/prompt.rb +0 -330
  229. data/lib/doing/wwid.rb +0 -2398
data/lib/doing/wwid.rb DELETED
@@ -1,2398 +0,0 @@
1
- #!/usr/bin/ruby
2
- # frozen_string_literal: true
3
-
4
- require 'deep_merge'
5
- require 'open3'
6
- require 'pp'
7
- require 'shellwords'
8
- require 'erb'
9
-
10
- module Doing
11
- ##
12
- ## 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, :default_option
18
-
19
- include Color
20
- # include Util
21
-
22
- ##
23
- ## Initializes the object.
24
- ##
25
- def initialize
26
- @timers = {}
27
- @recorded_items = []
28
- @content = Items.new
29
- Doing.auto_tag = true
30
- end
31
-
32
- # For backwards compatibility where @wwid.config was accessed instead of Doing.config.settings
33
- def config
34
- Doing.config.settings
35
- end
36
-
37
- ##
38
- ## Logger
39
- ##
40
- ## Responds to :debug, :info, :warn, and :error
41
- ##
42
- ## Each method takes a topic, and a message or block
43
- ##
44
- ## Example: debug('Hooks', 'Hook 1 triggered')
45
- ##
46
- def logger
47
- @logger ||= Doing.logger
48
- end
49
-
50
- ##
51
- ## Initializes the doing file.
52
- ##
53
- ## @param path [String] Override path to a doing file, optional
54
- ##
55
- def init_doing_file(path = nil)
56
- @doing_file = File.expand_path(Doing.setting('doing_file'))
57
-
58
- if path.nil?
59
- create(@doing_file) unless File.exist?(@doing_file)
60
- input = IO.read(@doing_file)
61
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
62
- logger.debug('Read:', "read file #{@doing_file}")
63
- elsif File.exist?(File.expand_path(path)) && File.file?(File.expand_path(path)) && File.stat(File.expand_path(path)).size.positive?
64
- @doing_file = File.expand_path(path)
65
- input = IO.read(File.expand_path(path))
66
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
67
- logger.debug('Read:', "read file #{File.expand_path(path)}")
68
- elsif path.length < 256
69
- @doing_file = File.expand_path(path)
70
- create(path)
71
- input = IO.read(File.expand_path(path))
72
- input = input.force_encoding('utf-8') if input.respond_to? :force_encoding
73
- logger.debug('Read:', "read file #{File.expand_path(path)}")
74
- end
75
-
76
- @other_content_top = []
77
- @other_content_bottom = []
78
-
79
- section = nil
80
- lines = input.split(/[\n\r]/)
81
-
82
- lines.each do |line|
83
- next if line =~ /^\s*$/
84
-
85
- if line =~ /^(\S[\S ]+):\s*(@\S+\s*)*$/
86
- section = Regexp.last_match(1)
87
- @content.add_section(Section.new(section, original: line), log: false)
88
- elsif line =~ /^\s*- (\d{4}-\d\d-\d\d \d\d:\d\d) \| (.*)/
89
- if section.nil?
90
- section = 'Uncategorized'
91
- @content.add_section(Section.new(section, original: 'Uncategorized:'), log: false)
92
- end
93
-
94
- date = Regexp.last_match(1).strip
95
- title = Regexp.last_match(2).strip
96
- item = Item.new(date, title, section)
97
- @content.push(item)
98
- elsif @content.count.zero?
99
- # if content[section].items.length - 1 == current
100
- @other_content_top.push(line)
101
- elsif line =~ /^\S/
102
- @other_content_bottom.push(line)
103
- else
104
- prev_item = @content.last
105
- prev_item.note = Note.new unless prev_item.note
106
-
107
- prev_item.note.add(line)
108
- # end
109
- end
110
- end
111
-
112
- Hooks.trigger :post_read, self
113
- end
114
-
115
- ##
116
- ## Create a new doing file
117
- ##
118
- def create(filename = nil)
119
- filename = @doing_file if filename.nil?
120
- return if File.exist?(filename) && File.stat(filename).size.positive?
121
-
122
- FileUtils.mkdir_p(File.dirname(filename)) unless File.directory?(File.dirname(filename))
123
-
124
- File.open(filename, 'w+') do |f|
125
- f.puts "#{Doing.setting('current_section')}:"
126
- end
127
- end
128
-
129
- ##
130
- ## Create a process for an editor and wait for the file handle to return
131
- ##
132
- ## @param input [String] Text input for editor
133
- ##
134
- def fork_editor(input = '', message: :default)
135
- # raise NonInteractive, 'Non-interactive terminal' unless $stdout.isatty || ENV['DOING_EDITOR_TEST']
136
-
137
- raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
138
-
139
- tmpfile = Tempfile.new(['doing', '.md'])
140
-
141
- File.open(tmpfile.path, 'w+') do |f|
142
- f.puts input
143
- unless message.nil?
144
- f.puts message == :default ? "# The first line is the entry title, any lines after that are added as a note" : message
145
- end
146
- end
147
-
148
- pid = Process.fork { system("#{Util.editor_with_args} #{tmpfile.path}") }
149
-
150
- trap('INT') do
151
- begin
152
- Process.kill(9, pid)
153
- rescue StandardError
154
- Errno::ESRCH
155
- end
156
- tmpfile.unlink
157
- tmpfile.close!
158
- exit 0
159
- end
160
-
161
- Process.wait(pid)
162
-
163
- begin
164
- if $?.exitstatus == 0
165
- input = IO.read(tmpfile.path)
166
- else
167
- exit_now! 'Cancelled'
168
- end
169
- ensure
170
- tmpfile.close
171
- tmpfile.unlink
172
- end
173
-
174
- input.split(/\n/).delete_if(&:ignore?).join("\n")
175
- end
176
-
177
- ##
178
- ## Takes a multi-line string and formats it as an entry
179
- ##
180
- ## @param input [String] The string to parse
181
- ##
182
- ## @return [Array] [[String]title, [Note]note]
183
- ##
184
- def format_input(input)
185
- raise EmptyInput, 'No content in entry' if input.nil? || input.strip.empty?
186
-
187
- input_lines = input.split(/[\n\r]+/).delete_if(&:ignore?)
188
- title = input_lines[0]&.strip
189
- raise EmptyInput, 'No content in first line' if title.nil? || title.strip.empty?
190
-
191
- date = nil
192
- iso_rx = /\d{4}-\d\d-\d\d \d\d:\d\d/
193
- date_rx = /^(?:\s*- )?(?<date>.*?) \| (?=\S)/
194
-
195
- raise EmptyInput, 'No content' if title.sub(/^.*?\| */, '').strip.empty?
196
-
197
- title.expand_date_tags(Doing.setting('date_tags'))
198
-
199
- if title =~ date_rx
200
- m = title.match(date_rx)
201
- d = m['date']
202
- date = if d =~ iso_rx
203
- Time.parse(d)
204
- else
205
- d.chronify(guess: :begin)
206
- end
207
- title.sub!(date_rx, '').strip!
208
- end
209
-
210
- note = Note.new
211
- note.add(input_lines[1..-1]) if input_lines.length > 1
212
- # If title line ends in a parenthetical, use that as the note
213
- if note.empty? && title =~ /\s+\(.*?\)$/
214
- title.sub!(/\s+\((?<note>.*?)\)$/) do
215
- m = Regexp.last_match
216
- note.add(m['note'])
217
- ''
218
- end
219
- end
220
-
221
- note.strip_lines!
222
- note.compress
223
-
224
- [date, title, note]
225
- end
226
-
227
- ##
228
- ## List sections
229
- ##
230
- ## @return [Array] section titles
231
- ##
232
- def sections
233
- @content.section_titles
234
- end
235
-
236
- ##
237
- ## Attempt to match a string with an existing section
238
- ##
239
- ## @param frag [String] The user-provided string
240
- ## @param guessed [Boolean] already guessed and failed
241
- ##
242
- def guess_section(frag, guessed: false, suggest: false)
243
- return 'All' if frag =~ /^all$/i
244
- frag ||= Doing.setting('current_section')
245
-
246
- return frag.cap_first if @content.section?(frag)
247
-
248
- section = nil
249
- re = frag.to_rx(distance: 2, case_type: :ignore)
250
- sections.each do |sect|
251
- next unless sect =~ /#{re}/i
252
-
253
- logger.debug('Match:', %(Assuming "#{sect}" from "#{frag}"))
254
- section = sect
255
- break
256
- end
257
-
258
- return section if suggest
259
-
260
- unless section || guessed
261
- alt = guess_view(frag, guessed: true, suggest: true)
262
- if alt
263
- meant_view = Prompt.yn("#{boldwhite("Did you mean")} `#{yellow("doing view #{alt}")}#{boldwhite}`?", default_response: 'n')
264
-
265
- raise WrongCommand.new("run again with #{"doing view #{alt}".boldwhite}", topic: 'Try again:') if meant_view
266
-
267
- end
268
-
269
- res = Prompt.yn("#{boldwhite}Section #{frag.yellow}#{boldwhite} not found, create it", default_response: 'n')
270
-
271
- if res
272
- @content.add_section(frag.cap_first, log: true)
273
- write(@doing_file)
274
- return frag.cap_first
275
- end
276
-
277
- raise InvalidSection.new("unknown section #{frag.bold.white}", topic: 'Missing:')
278
- end
279
- section ? section.cap_first : guessed
280
- end
281
-
282
- ##
283
- ## Attempt to match a string with an existing view
284
- ##
285
- ## @param frag [String] The user-provided string
286
- ## @param guessed [Boolean] already guessed
287
- ##
288
- def guess_view(frag, guessed: false, suggest: false)
289
- views.each { |view| return view if frag.downcase == view.downcase }
290
- view = false
291
- re = frag.to_rx(distance: 2, case_type: :ignore)
292
- views.each do |v|
293
- next unless v =~ /#{re}/i
294
-
295
- logger.debug('Match:', %(Assuming "#{v}" from "#{frag}"))
296
- view = v
297
- break
298
- end
299
- unless view || guessed
300
- alt = guess_section(frag, guessed: true, suggest: true)
301
-
302
- raise InvalidView.new(%(unknown view #{frag.bold.white}), topic: 'Missing:') unless alt
303
-
304
- meant_view = Prompt.yn("Did you mean `doing show #{alt}`?", default_response: 'n')
305
-
306
- raise WrongCommand.new("run again with #{"doing show #{alt}".yellow}", topic: 'Try again:') if meant_view
307
-
308
- raise InvalidView.new(%(unknown view #{alt.bold.white}), topic: 'Missing:')
309
- end
310
- view
311
- end
312
-
313
- def add_with_editor(**options)
314
- raise MissingEditor, 'No EDITOR variable defined in environment' if Util.default_editor.nil?
315
-
316
- input = options[:date].strftime('%F %R | ')
317
- input += options[:title]
318
- input += "\n#{options[:note]}" if options[:note]
319
- input = fork_editor(input).strip
320
-
321
- d, title, note = format_input(input)
322
- raise EmptyInput, 'No content' if title.empty?
323
-
324
- if options[:ask]
325
- ask_note = Doing::Prompt.read_lines(prompt: 'Add a note')
326
- note.add(ask_note) unless ask_note.empty?
327
- end
328
-
329
- date = d.nil? ? options[:date] : d
330
- finish = options[:finish_last] || false
331
- add_item(title.cap_first, options[:section], { note: note, back: date, timed: finish })
332
- write(@doing_file)
333
- end
334
-
335
- ##
336
- ## Adds an entry
337
- ##
338
- ## @param title [String] The entry title
339
- ## @param section [String] The section to add to
340
- ## @param opt [Hash] Additional Options
341
- ##
342
- ## @option opt :date [Date] item start date
343
- ## @option opt :note [Array] item note (will be converted if value is String)
344
- ## @option opt :back [Date] backdate
345
- ## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
346
- ## @option opt :done [Date] If set, adds a @done tag to new entry
347
- ##
348
- def add_item(title, section = nil, opt)
349
- opt ||= {}
350
- section ||= Doing.setting('current_section')
351
- @content.add_section(section, log: false)
352
- opt[:back] ||= opt[:date] ? opt[:date] : Time.now
353
- opt[:date] ||= Time.now
354
- note = Note.new
355
- opt[:timed] ||= false
356
-
357
- note.add(opt[:note]) if opt[:note]
358
-
359
- title = [title.strip.cap_first]
360
- title = title.join(' ')
361
-
362
- if Doing.auto_tag
363
- title = autotag(title)
364
- title.add_tags!(Doing.setting('default_tags')) if Doing.setting('default_tags').good?
365
- end
366
-
367
- title.compress!
368
- entry = Item.new(opt[:back], title.strip, section)
369
-
370
- if opt[:done] && entry.should_finish?
371
- if entry.should_time?
372
- entry.tag('done', value: opt[:done])
373
- else
374
- entry.tag('done')
375
- end
376
- end
377
-
378
- entry.note = note
379
-
380
- items = @content.clone
381
- if opt[:timed]
382
- items.reverse!
383
- items.each_with_index do |i, x|
384
- next if i.title =~ / @done/
385
-
386
- finish_date = verify_duration(i.date, opt[:back], title: i.title)
387
- items[x].tag('done', value: finish_date.strftime('%F %R'))
388
- break
389
- end
390
- end
391
-
392
- Hooks.trigger :pre_entry_add, self, entry
393
-
394
- @content.push(entry)
395
- # logger.count(:added, level: :debug)
396
- logger.info('New entry:', %(added "#{entry.date.relative_date}: #{entry.title}" to #{section}))
397
-
398
- Hooks.trigger :post_entry_added, self, entry
399
- entry
400
- end
401
-
402
- ##
403
- ## Remove items from an array that already exist in
404
- ## @content based on start and end times
405
- ##
406
- ## @param items [Array] The items to
407
- ## deduplicate
408
- ## @param no_overlap [Boolean] Remove items with
409
- ## overlapping time spans
410
- ##
411
- def dedup(items, no_overlap: false)
412
- items.delete_if do |item|
413
- duped = false
414
- @content.each do |comp|
415
- duped = no_overlap ? item.overlapping_time?(comp) : item.same_time?(comp)
416
- break if duped
417
- end
418
- logger.count(:skipped, level: :debug, message: '%count overlapping %items') if duped
419
- # logger.log_now(:debug, 'Skipped:', "overlapping entry: #{item.title}") if duped
420
- duped
421
- end
422
- end
423
-
424
- ##
425
- ## Imports external entries
426
- ##
427
- ## @param paths [String] Path to JSON report file
428
- ## @param opt [Hash] Additional Options
429
- ##
430
- def import(paths, opt)
431
- opt ||= {}
432
- Plugins.plugins[:import].each do |_, options|
433
- next unless opt[:type] =~ /^(#{options[:trigger].normalize_trigger})$/i
434
-
435
- if paths.count.positive?
436
- paths.each do |path|
437
- options[:class].import(self, path, options: opt)
438
- end
439
- else
440
- options[:class].import(self, nil, options: opt)
441
- end
442
- break
443
- end
444
- end
445
-
446
- ##
447
- ## Return the content of the last note for a given section
448
- ##
449
- ## @param section [String] The section to retrieve from, default
450
- ## All
451
- ##
452
- def last_note(section = 'All')
453
- section = guess_section(section)
454
-
455
- last_item = last_entry({ section: section })
456
-
457
- raise NoEntryError, 'No entry found' unless last_item
458
-
459
- logger.log_now(:info, 'Edit note:', last_item.title)
460
-
461
- note = last_item.note&.to_s || ''
462
- "#{last_item.title}\n# EDIT BELOW THIS LINE ------------\n#{note}"
463
- end
464
-
465
- # Reset start date to current time, optionally remove
466
- # done tag (resume)
467
- #
468
- # @param item [Item] the item to reset/resume
469
- # @param resume [Boolean] removing @done tag if true
470
- #
471
- def reset_item(item, date: nil, resume: false)
472
- date ||= Time.now
473
- item.date = date
474
- item.tag('done', remove: true) if resume
475
- logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
476
- item
477
- end
478
-
479
- # Duplicate an item and add it as a new item
480
- #
481
- # @param item [Item] the item to duplicate
482
- # @param opt [Hash] additional options
483
- #
484
- # @option opt :editor [Boolean] open new item in editor
485
- # @option opt :date [String] set start date
486
- # @option opt :in [String] add new item to section :in
487
- # @option opt :note [Note] add note to new item
488
- #
489
- # @return nothing
490
- #
491
- def repeat_item(item, opt)
492
- opt ||= {}
493
- old_item = item.clone
494
- if item.should_finish?
495
- if item.should_time?
496
- finish_date = verify_duration(item.date, Time.now, title: item.title)
497
- item.title.tag!('done', value: finish_date.strftime('%F %R'))
498
- else
499
- item.title.tag!('done')
500
- end
501
- Hooks.trigger :post_entry_updated, self, item, old_item
502
- end
503
-
504
- # Remove @done tag
505
- title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
506
- section = opt[:in].nil? ? item.section : guess_section(opt[:in])
507
- Doing.auto_tag = false
508
-
509
- note = opt[:note] || Note.new
510
-
511
- if opt[:editor]
512
- start = opt[:date] ? opt[:date] : Time.now
513
- to_edit = "#{start.strftime('%F %R')} | #{title}"
514
- to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
515
- new_item = fork_editor(to_edit)
516
- date, title, note = format_input(new_item)
517
-
518
- opt[:date] = date unless date.nil?
519
-
520
- if title.nil? || title.empty?
521
- logger.warn('Skipped:', 'No content provided')
522
- return
523
- end
524
- end
525
-
526
- # @content.update_item(original, item)
527
- add_item(title, section, { note: note, back: opt[:date], timed: false })
528
- end
529
-
530
- ##
531
- ## Restart the last entry
532
- ##
533
- ## @param opt [Hash] Additional Options
534
- ##
535
- def repeat_last(opt)
536
- opt ||= {}
537
- opt[:section] ||= 'all'
538
- opt[:section] = guess_section(opt[:section])
539
- opt[:note] ||= []
540
- opt[:tag] ||= []
541
- opt[:tag_bool] ||= :and
542
-
543
- last = last_entry(opt)
544
- if last.nil?
545
- logger.warn('Skipped:', 'No previous entry found')
546
- return
547
- end
548
-
549
- repeat_item(last, opt)
550
- write(@doing_file)
551
- end
552
-
553
- ##
554
- ## Get the last entry
555
- ##
556
- ## @param opt [Hash] Additional Options
557
- ##
558
- def last_entry(opt)
559
- opt ||= {}
560
- opt[:tag_bool] ||= :and
561
- opt[:section] ||= Doing.setting('current_section')
562
-
563
- items = filter_items(Items.new, opt: opt)
564
-
565
- logger.debug('Filtered:', "Parameters matched #{items.count} entries")
566
-
567
- if opt[:interactive]
568
- last_entry = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i,
569
- menu: true,
570
- header: '',
571
- prompt: 'Select an entry > ',
572
- multiple: false,
573
- sort: false,
574
- show_if_single: true
575
- )
576
- else
577
- last_entry = items.max_by { |item| item.date }
578
- end
579
-
580
- last_entry
581
- end
582
-
583
- def all_tags(items, opt: {}, counts: false)
584
- if counts
585
- all_tags = {}
586
- items.each do |item|
587
- item.tags.each do |tag|
588
- if all_tags.key?(tag.downcase)
589
- all_tags[tag.downcase] += 1
590
- else
591
- all_tags[tag.downcase] = 1
592
- end
593
- end
594
- end
595
-
596
- all_tags.sort_by { |tag, count| count }
597
- else
598
- all_tags = []
599
- items.each { |item| all_tags.concat(item.tags.map(&:downcase)).uniq! }
600
- all_tags.sort
601
- end
602
- end
603
-
604
- def tag_groups(items, opt: {})
605
- all_items = filter_items(items, opt: opt)
606
- tags = all_tags(all_items, opt: {})
607
- tag_groups = {}
608
- tags.each do |tag|
609
- tag_groups[tag] ||= []
610
- tag_groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
611
- end
612
-
613
- tag_groups
614
- end
615
-
616
- def fuzzy_filter_items(items, opt: {})
617
- scannable = items.map.with_index { |item, idx| "#{item.title} #{item.note.join(' ')}".gsub(/[|*?!]/, '') + "|#{idx}" }.join("\n")
618
-
619
- fzf_args = [
620
- '--multi',
621
- %(--filter="#{opt[:search].sub(/^'?/, "'")}"),
622
- '--no-sort',
623
- '-d "\|"',
624
- '--nth=1'
625
- ]
626
- if opt[:case]
627
- fzf_args << case opt[:case].normalize_case
628
- when :sensitive
629
- '+i'
630
- when :ignore
631
- '-i'
632
- end
633
- end
634
- # fzf_args << '-e' if opt[:exact]
635
- # puts fzf_args.join(' ')
636
- res = `echo #{Shellwords.escape(scannable)}|#{Prompt.fzf} #{fzf_args.join(' ')}`
637
- selected = Items.new
638
- res.split(/\n/).each do |item|
639
- idx = item.match(/\|(\d+)$/)[1].to_i
640
- selected.push(items[idx])
641
- end
642
- selected
643
- end
644
-
645
- ##
646
- ## Filter items based on search criteria
647
- ##
648
- ## @param items [Array] The items to filter (if empty, filters all items)
649
- ## @param opt [Hash] The filter parameters
650
- ##
651
- ## @option opt [String] :section ('all')
652
- ## @option opt [Boolean] :unfinished (false)
653
- ## @option opt [Array or String] :tag ([]) Array or comma-separated string
654
- ## @option opt [Symbol] :tag_bool (:and) :and, :or, :not
655
- ## @option opt [String] :search ('') string, optional regex with `/string/`
656
- ## @option opt [Array] :date_filter (nil) [[Time]start, [Time]end]
657
- ## @option opt [Boolean] :only_timed (false)
658
- ## @option opt [String] :before (nil) Date/Time string, unparsed
659
- ## @option opt [String] :after (nil) Date/Time string, unparsed
660
- ## @option opt [Boolean] :today (false) limit to entries from today
661
- ## @option opt [Boolean] :yesterday (false) limit to entries from yesterday
662
- ## @option opt [Number] :count (0) max entries to return
663
- ## @option opt [String] :age (new) 'old' or 'new'
664
- ## @option opt [Array] :val (nil) Array of tag value queries
665
- ##
666
- def filter_items(items = Items.new, opt: {})
667
- logger.benchmark(:filter_items, :start)
668
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i
669
-
670
- if items.nil? || items.empty?
671
- section = opt[:section] ? guess_section(opt[:section]) : 'All'
672
- items = section =~ /^all$/i ? @content.clone : @content.in_section(section)
673
- end
674
-
675
- if !opt[:time_filter]
676
- opt[:time_filter] = [nil, nil]
677
- if opt[:from] && !opt[:date_filter]
678
- if opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
679
- opt[:time_filter] = opt[:from]
680
- elsif opt[:from][0].is_a?(Time)
681
- opt[:date_filter] = opt[:from]
682
- end
683
- end
684
- end
685
-
686
- if opt[:before].is_a?(String) && opt[:before] =~ time_rx
687
- opt[:time_filter][1] = opt[:before]
688
- opt[:before] = nil
689
- end
690
-
691
- if opt[:after].is_a?(String) && opt[:after] =~ time_rx
692
- opt[:time_filter][0] = opt[:after]
693
- opt[:after] = nil
694
- end
695
-
696
- items.sort_by! { |item| [item.date, item.title.downcase] }.reverse
697
-
698
- filtered_items = items.select do |item|
699
- keep = true
700
- if opt[:unfinished]
701
- finished = item.tags?('done', :and)
702
- finished = opt[:not] ? !finished : finished
703
- keep = false if finished
704
- end
705
-
706
- if keep && opt[:val]&.count&.positive?
707
- bool = opt[:bool].normalize_bool if opt[:bool]
708
- bool ||= :and
709
- bool = :and if bool == :pattern
710
-
711
- val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
712
- keep = false unless val_match
713
- keep = opt[:not] ? !keep : keep
714
- end
715
-
716
- if keep && opt[:tag]
717
- opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
718
- opt[:tag_bool] ||= :and
719
- tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
720
- keep = false unless tag_match
721
- keep = opt[:not] ? !keep : keep
722
- end
723
-
724
- if keep && opt[:search]
725
- search_match = if opt[:search].nil? || opt[:search].empty?
726
- true
727
- else
728
- item.search(opt[:search], case_type: opt[:case].normalize_case)
729
- end
730
-
731
- keep = false unless search_match
732
- keep = opt[:not] ? !keep : keep
733
- end
734
-
735
- if keep && opt[:date_filter]&.length == 2
736
- start_date = opt[:date_filter][0]
737
- end_date = opt[:date_filter][1]
738
-
739
- in_date_range = if end_date
740
- item.date >= start_date && item.date <= end_date
741
- else
742
- item.date.strftime('%F') == start_date.strftime('%F')
743
- end
744
- keep = false unless in_date_range
745
- keep = opt[:not] ? !keep : keep
746
- end
747
-
748
- if keep && opt[:time_filter][0] || opt[:time_filter][1]
749
- start_string = if opt[:time_filter][0].nil?
750
- "#{item.date.strftime('%Y-%m-%d')} 12am"
751
- else
752
- "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
753
- end
754
- start_time = start_string.chronify(guess: :begin)
755
-
756
- end_string = if opt[:time_filter][1].nil?
757
- "#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 12am"
758
- else
759
- "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
760
- end
761
- end_time = end_string.chronify(guess: :end)
762
-
763
- in_time_range = item.date >= start_time && item.date <= end_time
764
- keep = false unless in_time_range
765
- keep = opt[:not] ? !keep : keep
766
- end
767
-
768
- keep = false if keep && opt[:only_timed] && !item.interval
769
-
770
- if keep && opt[:tag_filter]
771
- keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
772
- keep = opt[:not] ? !keep : keep
773
- end
774
-
775
- if keep && opt[:before]
776
- before = opt[:before]
777
- if before =~ time_rx
778
- cutoff = "#{item.date.strftime('%Y-%m-%d')} #{before}".chronify(guess: :begin)
779
- elsif before.is_a?(String)
780
- cutoff = before.chronify(guess: :begin)
781
- else
782
- cutoff = before
783
- end
784
- keep = cutoff && item.date <= cutoff
785
- keep = opt[:not] ? !keep : keep
786
- end
787
-
788
- if keep && opt[:after]
789
- after = opt[:after]
790
- if after =~ time_rx
791
- cutoff = "#{item.date.strftime('%Y-%m-%d')} #{after}".chronify(guess: :end)
792
- elsif after.is_a?(String)
793
- cutoff = after.chronify(guess: :end)
794
- else
795
- cutoff = after
796
- end
797
- keep = cutoff && item.date >= cutoff
798
- keep = opt[:not] ? !keep : keep
799
- end
800
-
801
- if keep && opt[:today]
802
- keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
803
- keep = opt[:not] ? !keep : keep
804
- elsif keep && opt[:yesterday]
805
- keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
806
- keep = opt[:not] ? !keep : keep
807
- end
808
-
809
- keep
810
- end
811
- count = opt[:count].to_i&.positive? ? opt[:count].to_i : filtered_items.count
812
-
813
- output = Items.new
814
-
815
- if opt[:age] && opt[:age].normalize_age == :oldest
816
- output.concat(filtered_items.slice(0, count).reverse)
817
- else
818
- output.concat(filtered_items.reverse.slice(0, count))
819
- end
820
-
821
- logger.benchmark(:filter_items, :finish)
822
-
823
- output
824
- end
825
-
826
- def delete_items(items, force: false)
827
- items.slice(0, 5).each { |i| puts i.to_pretty } unless force
828
- puts softpurple("+ #{items.size - 5} additional #{'item'.to_p(items.size - 5)}") if items.size > 5 && !force
829
-
830
- res = force ? true : Prompt.yn("Delete #{items.size} #{'item'.to_p(items.size)}?", default_response: 'y')
831
- return unless res
832
-
833
- items.each { |i| Hooks.trigger :post_entry_removed, self, @content.delete_item(i, single: items.count == 1) }
834
- write(@doing_file)
835
- end
836
-
837
- def edit_items(items)
838
- items.sort_by! { |i| i.date }
839
- editable_items = []
840
-
841
- items.each do |i|
842
- editable = "#{i.date.strftime('%F %R')} | #{i.title}"
843
- old_note = i.note ? i.note.strip_lines.join("\n") : nil
844
- editable += "\n#{old_note}" unless old_note.nil?
845
- editable_items << editable
846
- end
847
- divider = "-----------"
848
- notice =<<~EONOTICE
849
- # - You may delete entries, but leave all divider lines (---) in place.
850
- # - Start and @done dates replaced with a time string (yesterday 3pm) will
851
- # be parsed automatically. Do not delete the pipe (|) between start date
852
- # and entry title.
853
- EONOTICE
854
- input = "#{editable_items.map(&:strip).join("\n#{divider}\n")}\n\n#{notice}"
855
-
856
- new_items = fork_editor(input).split(/^#{divider}/).map(&:strip)
857
-
858
- new_items.each_with_index do |new_item, i|
859
- input_lines = new_item.split(/[\n\r]+/).delete_if(&:ignore?)
860
- first_line = input_lines[0]&.strip
861
-
862
- if first_line.nil? || first_line =~ /^#{divider.strip}$/ || first_line.strip.empty?
863
- deleted = @content.delete_item(items[i], single: new_items.count == 1)
864
- Hooks.trigger :post_entry_removed, self, deleted
865
- Doing.logger.info('Deleted:', deleted.title)
866
- else
867
- date, title, note = format_input(new_item)
868
-
869
- note.map!(&:strip)
870
- note.delete_if(&:ignore?)
871
- item = items[i]
872
- old_item = item.clone
873
- item.date = date || items[i].date
874
- item.title = title
875
- item.note = note
876
- if (item.equal?(old_item))
877
- Doing.logger.count(:skipped, level: :debug)
878
- else
879
- Doing.logger.count(:updated)
880
- Hooks.trigger :post_entry_updated, self, item, old_item
881
- end
882
- end
883
- end
884
- end
885
-
886
- ##
887
- ## Display an interactive menu of entries
888
- ##
889
- ## @param opt [Hash] Additional options
890
- ##
891
- ## Options hash is shared with #filter_items and #act_on
892
- ##
893
- def interactive(opt)
894
- opt ||= {}
895
- opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
896
-
897
- search = nil
898
-
899
- if opt[:search]
900
- search = opt[:search]
901
- search.sub!(/^'?/, "'") if opt[:exact]
902
- opt[:search] = search
903
- end
904
-
905
- # opt[:query] = opt[:search] if opt[:search] && !opt[:query]
906
- opt[:query] = "!#{opt[:query]}" if opt[:query] && opt[:not]
907
- opt[:multiple] = true
908
- opt[:show_if_single] = true
909
- filter_options = %i[after before case date_filter from fuzzy not search section val].each_with_object({}) {
910
- |k, hsh| hsh[k] = opt[k]
911
- }
912
- items = filter_items(Items.new, opt: filter_options)
913
-
914
- menu_options = %i[search query exact multiple show_if_single menu sort case].each_with_object({}) {
915
- |k, hsh| hsh[k] = opt[k]
916
- }
917
-
918
- selection = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, **menu_options)
919
-
920
- raise NoResults, 'no items selected' if selection.nil? || selection.empty?
921
-
922
- act_on(selection, opt)
923
- end
924
-
925
- ##
926
- ## Perform actions on a set of entries. If
927
- ## no valid action is included in the opt
928
- ## hash and the terminal is a TTY, a menu
929
- ## will be presented
930
- ##
931
- ## @param items [Array] Array of Items to affect
932
- ## @param opt [Hash] Options and actions to perform
933
- ##
934
- ## @option opt [Boolean] :editor
935
- ## @option opt [Boolean] :delete
936
- ## @option opt [String] :tag
937
- ## @option opt [Boolean] :flag
938
- ## @option opt [Boolean] :finish
939
- ## @option opt [Boolean] :cancel
940
- ## @option opt [Boolean] :archive
941
- ## @option opt [String] :output
942
- ## @option opt [String] :save_to
943
- ## @option opt [Boolean] :again
944
- ## @option opt [Boolean] :resume
945
- ##
946
- def act_on(items, opt)
947
- opt ||= {}
948
- actions = %i[editor delete tag flag finish cancel archive output save_to again resume]
949
- has_action = false
950
- single = items.count == 1
951
-
952
- actions.each do |a|
953
- if opt[a]
954
- has_action = true
955
- break
956
- end
957
- end
958
-
959
- unless has_action
960
- actions = [
961
- 'add tag',
962
- 'remove tag',
963
- 'autotag',
964
- 'cancel',
965
- 'delete',
966
- 'finish',
967
- 'flag',
968
- 'archive',
969
- 'move',
970
- 'edit',
971
- 'output formatted'
972
- ]
973
-
974
- actions.concat(['resume/repeat', 'begin/reset']) if items.count == 1
975
-
976
- choice = Prompt.choose_from(actions,
977
- prompt: 'What do you want to do with the selected items? > ',
978
- multiple: true,
979
- sorted: false,
980
- fzf_args: ["--height=#{actions.count + 3}", '--tac', '--no-sort', '--info=hidden'])
981
- return unless choice
982
-
983
- to_do = choice.strip.split(/\n/)
984
- to_do.each do |action|
985
- case action
986
- when /resume/
987
- opt[:resume] = true
988
- when /reset/
989
- opt[:reset] = true
990
- when /autotag/
991
- opt[:autotag] = true
992
- when /(add|remove) tag/
993
- type = action =~ /^add/ ? 'add' : 'remove'
994
- raise InvalidArgument, "'add tag' and 'remove tag' can not be used together" if opt[:tag]
995
-
996
- tags = type == 'add' ? all_tags(@content) : all_tags(items)
997
-
998
- puts "#{yellow}Separate multiple tags with spaces, hit tab to complete known tags#{type == 'add' ? ', include values with tag(value)' : ''}"
999
- puts "#{boldgreen}Available tags: #{boldwhite}#{tags.sort.map(&:add_at).join(', ')}" if type == 'remove'
1000
- tag = Prompt.read_line(prompt: "Tags to #{type}", completions: tags)
1001
-
1002
- # print "#{yellow("Tag to #{type}: ")}#{reset}"
1003
- # tag = $stdin.gets
1004
- next if tag =~ /^ *$/
1005
-
1006
- opt[:tag] = tag.strip.sub(/^@/, '')
1007
- opt[:remove] = true if type == 'remove'
1008
- when /output formatted/
1009
- plugins = Plugins.available_plugins(type: :export).sort
1010
- output_format = Prompt.choose_from(plugins,
1011
- prompt: 'Which output format? > ',
1012
- fzf_args: [
1013
- "--height=#{plugins.count + 3}",
1014
- '--tac',
1015
- '--no-sort',
1016
- '--info=hidden'
1017
- ])
1018
- next if output_format =~ /^ *$/
1019
-
1020
- raise UserCancelled unless output_format
1021
-
1022
- opt[:output] = output_format.strip
1023
- res = opt[:force] ? false : Prompt.yn('Save to file?', default_response: 'n')
1024
- if res
1025
- # print "#{yellow('File path/name: ')}#{reset}"
1026
- # filename = $stdin.gets.strip
1027
- filename = Prompt.read_line(prompt: 'File path/name')
1028
- next if filename.empty?
1029
-
1030
- opt[:save_to] = filename
1031
- end
1032
- when /archive/
1033
- opt[:archive] = true
1034
- when /delete/
1035
- opt[:delete] = true
1036
- when /edit/
1037
- opt[:editor] = true
1038
- when /finish/
1039
- opt[:finish] = true
1040
- when /cancel/
1041
- opt[:cancel] = true
1042
- when /move/
1043
- section = choose_section.strip
1044
- opt[:move] = section.strip unless section =~ /^ *$/
1045
- when /flag/
1046
- opt[:flag] = true
1047
- end
1048
- end
1049
- end
1050
-
1051
- if opt[:resume] || opt[:reset]
1052
- raise InvalidArgument, 'resume and restart can only be used on a single entry' if items.count > 1
1053
-
1054
- item = items[0]
1055
- if opt[:resume] && !opt[:reset]
1056
- repeat_item(item, { editor: opt[:editor] }) # hooked
1057
- elsif opt[:reset]
1058
- res = Prompt.enter_text('Start date (blank for current time)', default_response: '')
1059
- if res =~ /^ *$/
1060
- date = Time.now
1061
- else
1062
- date = res.chronify(guess: :begin)
1063
- end
1064
-
1065
- res = if item.tags?('done', :and) && !opt[:resume]
1066
- opt[:force] ? true : Prompt.yn('Remove @done tag?', default_response: 'y')
1067
- else
1068
- opt[:resume]
1069
- end
1070
- old_item = item.clone
1071
- new_entry = reset_item(item, date: date, resume: res)
1072
- @content.update_item(item, new_entry)
1073
- Hooks.trigger :post_entry_updated, self, new_entry, old_item
1074
- end
1075
- write(@doing_file)
1076
-
1077
- return
1078
- end
1079
-
1080
- if opt[:delete]
1081
- delete_items(items, force: opt[:force]) # hooked
1082
- return
1083
- end
1084
-
1085
- if opt[:flag]
1086
- tag = Doing.setting('marker_tag', 'flagged')
1087
- items.map! do |i|
1088
- old_item = i.clone
1089
- i.tag(tag, date: false, remove: opt[:remove], single: single)
1090
- Hooks.trigger :post_entry_updated, self, i, old_item
1091
- end
1092
- end
1093
-
1094
- if opt[:finish] || opt[:cancel]
1095
- tag = 'done'
1096
- items.map! do |i|
1097
- if i.should_finish?
1098
- old_item = i.clone
1099
- should_date = !opt[:cancel] && i.should_time?
1100
- i.tag(tag, date: should_date, remove: opt[:remove], single: single)
1101
- Hooks.trigger :post_entry_updated, self, i, old_item
1102
- end
1103
- end
1104
- end
1105
-
1106
- if opt[:autotag]
1107
- items.map! do |i|
1108
- new_title = autotag(i.title)
1109
- if new_title == i.title
1110
- logger.count(:skipped, level: :debug, message: '%count unchaged %items')
1111
- # logger.debug('Autotag:', 'No changes')
1112
- else
1113
- logger.count(:added_tags)
1114
- logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
1115
- old_item = i.clone
1116
- i.title = new_title
1117
- Hooks.trigger :post_entry_updated, self, i, old_item
1118
- end
1119
- end
1120
- end
1121
-
1122
- if opt[:tag]
1123
- tag = opt[:tag]
1124
- items.map! do |i|
1125
- old_item = i.clone
1126
- i.tag(tag, date: false, remove: opt[:remove], single: single)
1127
- i.expand_date_tags(Doing.setting('date_tags'))
1128
- Hooks.trigger :post_entry_updated, self, i, old_item
1129
- end
1130
- end
1131
-
1132
- if opt[:archive] || opt[:move]
1133
- section = opt[:archive] ? 'Archive' : guess_section(opt[:move])
1134
- items.map! do |i|
1135
- old_item = i.clone
1136
- i.move_to(section, label: true)
1137
- Hooks.trigger :post_entry_updated, self, i, old_item
1138
- end
1139
- end
1140
-
1141
- write(@doing_file)
1142
-
1143
- if opt[:editor]
1144
- edit_items(items) # hooked
1145
-
1146
- write(@doing_file)
1147
- end
1148
-
1149
- return unless opt[:output]
1150
-
1151
- items.each { |i| i.title = "#{i.title} @section(#{i.section})" }
1152
-
1153
- export_items = Items.new
1154
- export_items.concat(items)
1155
- export_items.add_section(Section.new('Export'), log: false)
1156
- options = { section: 'All' }
1157
-
1158
- if opt[:output] =~ /doing/
1159
- options[:output] = 'template'
1160
- options[:template] = '- %date | %title%note'
1161
- else
1162
- options[:output] = opt[:output]
1163
- options[:template] = opt[:template] || nil
1164
- end
1165
-
1166
- output = list_section(options, items: export_items) # hooked
1167
-
1168
- if opt[:save_to]
1169
- file = File.expand_path(opt[:save_to])
1170
- if File.exist?(file)
1171
- # Create a backup copy for the undo command
1172
- FileUtils.cp(file, "#{file}~")
1173
- end
1174
-
1175
- File.open(file, 'w+') do |f|
1176
- f.puts output
1177
- end
1178
-
1179
- logger.warn('File written:', file)
1180
- else
1181
- Doing::Pager.page output
1182
- end
1183
- end
1184
-
1185
- def verify_duration(date, finish_date, title: nil)
1186
- max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
1187
- max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
1188
- date = date.chronify(guess: :end, context: :today) if finish_date.is_a?(String)
1189
-
1190
- elapsed = finish_date - date
1191
-
1192
- if max_elapsed.positive? && (elapsed > max_elapsed)
1193
- puts boldwhite(title) if title
1194
- human = elapsed.time_string(format: :natural)
1195
- res = Prompt.yn(yellow("Did this entry actually take #{human}"), default_response: true)
1196
- unless res
1197
- new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
1198
- raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed.positive?
1199
-
1200
- finish_date = date + new_elapsed if new_elapsed
1201
- end
1202
- end
1203
-
1204
- finish_date
1205
- end
1206
-
1207
- ##
1208
- ## Tag the last entry or X entries
1209
- ##
1210
- ## @param opt [Hash] Additional Options (see
1211
- ## #filter_items for filtering
1212
- ## options)
1213
- ##
1214
- ## @see #filter_items
1215
- ##
1216
- def tag_last(opt) # hooked
1217
- opt ||= {}
1218
- opt[:count] ||= 1
1219
- opt[:archive] ||= false
1220
- opt[:tags] ||= ['done']
1221
- opt[:sequential] ||= false
1222
- opt[:date] ||= false
1223
- opt[:remove] ||= false
1224
- opt[:update] ||= false
1225
- opt[:autotag] ||= false
1226
- opt[:back] ||= false
1227
- opt[:unfinished] ||= false
1228
- opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
1229
-
1230
- items = filter_items(Items.new, opt: opt)
1231
-
1232
- if opt[:interactive]
1233
- items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
1234
- header: '',
1235
- prompt: 'Select entries to tag > ',
1236
- multiple: true,
1237
- sort: true,
1238
- show_if_single: true)
1239
-
1240
- raise NoResults, 'no items selected' if items.empty?
1241
-
1242
- end
1243
-
1244
- raise NoResults, 'no items matched your search' if items.empty?
1245
-
1246
- if opt[:tags].empty? && !opt[:autotag]
1247
- completions = opt[:remove] ? all_tags(items) : all_tags(@content)
1248
- if opt[:remove]
1249
- puts "#{yellow}Available tags: #{boldwhite}#{completions.map(&:add_at).join(', ')}"
1250
- else
1251
- puts "#{yellow}Use tab to complete known tags"
1252
- end
1253
- opt[:tags] = Doing::Prompt.read_line(prompt: "Enter tag(s) to #{opt[:remove] ? 'remove' : 'add'}",
1254
- completions: completions,
1255
- default_response: '').to_tags
1256
- raise UserCancelled, 'No tags provided' if opt[:tags].empty?
1257
- end
1258
-
1259
- items.each do |item|
1260
- old_item = item.clone
1261
- added = []
1262
- removed = []
1263
-
1264
- if opt[:autotag]
1265
- new_title = autotag(item.title) if Doing.auto_tag
1266
- if new_title == item.title
1267
- logger.count(:skipped, level: :debug, message: '%count unchaged %items')
1268
- # logger.debug('Autotag:', 'No changes')
1269
- else
1270
- logger.count(:added_tags)
1271
- logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
1272
- item.title = new_title
1273
- end
1274
- else
1275
- if opt[:sequential]
1276
- next_entry = next_item(item)
1277
-
1278
- done_date = if next_entry.nil?
1279
- Time.now
1280
- else
1281
- next_entry.date - 60
1282
- end
1283
- else
1284
- done_date = item.calculate_end_date(opt)
1285
- end
1286
-
1287
- opt[:tags].each do |tag|
1288
- if tag == 'done' && !item.should_finish?
1289
-
1290
- Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
1291
- logger.count(:skipped, level: :debug)
1292
- next
1293
- end
1294
-
1295
- tag = tag.strip
1296
-
1297
- if tag =~ /^(\S+)\((.*?)\)$/
1298
- m = Regexp.last_match
1299
- tag = m[1]
1300
- opt[:value] ||= m[2]
1301
- end
1302
-
1303
- if tag =~ /^done$/ && opt[:date] && item.should_time?
1304
- max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
1305
- max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
1306
- elapsed = done_date - item.date
1307
-
1308
- if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
1309
- puts boldwhite(item.title)
1310
- human = elapsed.time_string(format: :natural)
1311
- res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
1312
- unless res
1313
- new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
1314
- raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
1315
-
1316
- opt[:took] = new_elapsed
1317
- done_date = item.calculate_end_date(opt) if opt[:took]
1318
- end
1319
- end
1320
- end
1321
-
1322
- if opt[:remove] || opt[:rename] || opt[:value]
1323
- rename_to = nil
1324
- if opt[:value]
1325
- rename_to = tag
1326
- elsif opt[:rename]
1327
- rename_to = tag
1328
- tag = opt[:rename]
1329
- end
1330
- old_title = item.title.dup
1331
- force = opt[:value].nil? ? false : true
1332
- item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
1333
- if old_title != item.title
1334
- removed << tag
1335
- added << rename_to if rename_to
1336
- else
1337
- logger.count(:skipped, level: :debug)
1338
- end
1339
- else
1340
- old_title = item.title.dup
1341
- should_date = opt[:date] && item.should_time?
1342
- item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
1343
- item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
1344
- added << tag if old_title != item.title
1345
- end
1346
- end
1347
- end
1348
-
1349
- logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
1350
-
1351
- item.note.add(opt[:note]) if opt[:note]
1352
-
1353
- if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
1354
- item.move_to('Archive', label: true)
1355
- elsif opt[:archive] && opt[:count].zero?
1356
- logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
1357
- end
1358
-
1359
- item.expand_date_tags(Doing.setting('date_tags'))
1360
- Hooks.trigger :post_entry_updated, self, item, old_item
1361
- end
1362
-
1363
- write(@doing_file)
1364
- end
1365
-
1366
- ##
1367
- ## Get next item in the index
1368
- ##
1369
- ## @param item [Item] target item
1370
- ## @param options [Hash] additional options
1371
- ## @see #filter_items
1372
- ##
1373
- ## @return [Item] the next chronological item in the index
1374
- ##
1375
- def next_item(item, options = {})
1376
- options ||= {}
1377
- items = filter_items(Items.new, opt: options)
1378
-
1379
- idx = items.index(item)
1380
-
1381
- idx.positive? ? items[idx - 1] : nil
1382
- end
1383
-
1384
- ##
1385
- ## Edit the last entry
1386
- ##
1387
- ## @param section [String] The section, default "All"
1388
- ##
1389
- def edit_last(section: 'All', options: {})
1390
- options[:section] = guess_section(section)
1391
-
1392
- item = last_entry(options)
1393
-
1394
- if item.nil?
1395
- logger.debug('Skipped:', 'No entries found')
1396
- return
1397
- end
1398
-
1399
- old_item = item.clone
1400
- content = ["#{item.date.strftime('%F %R')} | #{item.title.dup}"]
1401
- content << item.note.strip_lines.join("\n") unless item.note.empty?
1402
- new_item = fork_editor(content.join("\n"))
1403
- date, title, note = format_input(new_item)
1404
- date ||= item.date
1405
-
1406
- if title.nil? || title.empty?
1407
- logger.debug('Skipped:', 'No content provided')
1408
- elsif title == item.title && note.equal?(item.note) && date.equal?(item.date)
1409
- logger.debug('Skipped:', 'No change in content')
1410
- else
1411
- item.date = date unless date.nil?
1412
- item.title = title
1413
- item.note.add(note, replace: true)
1414
- logger.info('Edited:', item.title)
1415
- Hooks.trigger :post_entry_updated, self, item, old_item
1416
-
1417
- write(@doing_file)
1418
- end
1419
- end
1420
-
1421
- ##
1422
- ## Accepts one tag and the raw text of a new item if the
1423
- ## passed tag is on any item, it's replaced with @done.
1424
- ## if new_item is not nil, it's tagged with the passed
1425
- ## tag and inserted. This is for use where only one
1426
- ## instance of a given tag should exist (@meanwhile)
1427
- ##
1428
- ## @param target_tag [String] Tag to replace
1429
- ## @param opt [Hash] Additional Options
1430
- ##
1431
- ## @option opt :section [String] target section
1432
- ## @option opt :archive [Boolean] archive old item
1433
- ## @option opt :back [Date] backdate new item
1434
- ## @option opt :new_item [String] content to use for new item
1435
- ## @option opt :note [Array] note content for new item
1436
- def stop_start(target_tag, opt)
1437
- opt ||= {}
1438
- tag = target_tag.dup
1439
- opt[:section] ||= Doing.setting('current_section')
1440
- opt[:archive] ||= false
1441
- opt[:back] ||= Time.now
1442
- opt[:new_item] ||= false
1443
- opt[:note] ||= false
1444
-
1445
- opt[:section] = guess_section(opt[:section])
1446
-
1447
- tag.sub!(/^@/, '')
1448
-
1449
- found_items = 0
1450
-
1451
- @content.each_with_index do |item, i|
1452
- old_item = i.clone
1453
- next unless item.section == opt[:section] || opt[:section] =~ /all/i
1454
-
1455
- next unless item.title =~ /@#{tag}/
1456
-
1457
- item.title.add_tags!([tag, 'done'], remove: true)
1458
- item.tag('done', value: opt[:back].strftime('%F %R'))
1459
-
1460
- found_items += 1
1461
-
1462
- if opt[:archive] && opt[:section] != 'Archive'
1463
- item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
1464
- item.move_to('Archive', label: false, log: false)
1465
- logger.count(:completed_archived)
1466
- logger.info('Completed/archived:', item.title)
1467
- else
1468
- logger.count(:completed)
1469
- logger.info('Completed:', item.title)
1470
- end
1471
- Hooks.trigger :post_entry_updated, self, item, old_item
1472
- end
1473
-
1474
-
1475
- logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
1476
-
1477
- if opt[:new_item]
1478
- date, title, note = format_input(opt[:new_item])
1479
- opt[:back] = date unless date.nil?
1480
- note.add(opt[:note]) if opt[:note]
1481
- title.tag!(tag)
1482
- add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
1483
- end
1484
-
1485
- write(@doing_file)
1486
- end
1487
-
1488
- ##
1489
- ## Write content to file or STDOUT
1490
- ##
1491
- ## @param file [String] The filepath to write to
1492
- ##
1493
- def write(file = nil, backup: true)
1494
- Hooks.trigger :pre_write, self, file
1495
- output = combined_content
1496
- if file.nil?
1497
- $stdout.puts output
1498
- else
1499
- Util.write_to_file(file, output, backup: backup)
1500
- run_after if Doing.setting('run_after')
1501
- end
1502
- end
1503
-
1504
- ##
1505
- ## Rename doing file with date and start fresh one
1506
- ##
1507
- def rotate(opt)
1508
- opt ||= {}
1509
- keep = opt[:keep] || 0
1510
- tags = []
1511
- tags.concat(opt[:tag].split(/ *, */).map { |t| t.sub(/^@/, '').strip }) if opt[:tag]
1512
- bool = opt[:bool] || :and
1513
- sect = opt[:section] !~ /^all$/i ? guess_section(opt[:section]) : 'all'
1514
-
1515
- section = guess_section(sect)
1516
-
1517
- section_items = @content.in_section(section)
1518
- max = section_items.count - keep.to_i
1519
-
1520
- counter = 0
1521
- new_content = Items.new
1522
-
1523
- section_items.each do |item|
1524
- break if counter >= max
1525
- if opt[:before]
1526
- time_string = opt[:before]
1527
- cutoff = time_string.chronify(guess: :begin)
1528
- end
1529
-
1530
- unless ((!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s)) || (opt[:before] && item.date >= cutoff))
1531
- new_item = @content.delete(item)
1532
- Hooks.trigger :post_entry_removed, self, item.clone
1533
- raise DoingRuntimeError, "Error deleting item: #{item}" if new_item.nil?
1534
-
1535
- new_content.add_section(new_item.section, log: false)
1536
- new_content.push(new_item)
1537
- counter += 1
1538
- end
1539
- end
1540
-
1541
- if counter.positive?
1542
- logger.count(:rotated,
1543
- level: :info,
1544
- count: counter,
1545
- message: "Rotated %count %items")
1546
- else
1547
- logger.info('Skipped:', 'No items were rotated')
1548
- end
1549
-
1550
- write(@doing_file)
1551
-
1552
- file = @doing_file.sub(/(\.\w+)$/, "_#{Time.now.strftime('%Y-%m-%d')}\\1")
1553
- if File.exist?(file)
1554
- init_doing_file(file)
1555
- @content.concat(new_content).uniq!
1556
- logger.warn('File update:', "added entries to existing file: #{file}")
1557
- else
1558
- @content = new_content
1559
- logger.warn('File update:', "created new file: #{file}")
1560
- end
1561
-
1562
- write(file, backup: false)
1563
- end
1564
-
1565
- ##
1566
- ## Generate a menu of sections and allow user selection
1567
- ##
1568
- ## @return [String] The selected section name
1569
- ##
1570
- def choose_section(include_all: false)
1571
- options = @content.section_titles.sort
1572
- options.unshift('All') if include_all
1573
- choice = Prompt.choose_from(options, prompt: 'Choose a section > ', fzf_args: ['--height=60%'])
1574
- choice ? choice.strip : choice
1575
- end
1576
-
1577
- ##
1578
- ## Generate a menu of tags and allow user selection
1579
- ##
1580
- ## @return [String] The selected tag name
1581
- ##
1582
- def choose_tag(section = 'All', items: nil, include_all: false)
1583
- items ||= @content.in_section(section)
1584
- tags = all_tags(items, counts: true).map { |t, c| "@#{t} (#{c})" }
1585
- tags.unshift('No tag filter') if include_all
1586
- choice = Prompt.choose_from(tags, sorted: false, multiple: true, prompt: 'Choose tag(s) > ', fzf_args: ['--height=60%'])
1587
- choice ? choice.split(/\n/).map { |t| t.strip.sub(/ \(.*?\)$/, '')}.join(' ') : choice
1588
- end
1589
-
1590
- ##
1591
- ## Generate a menu of sections and tags and allow user selection
1592
- ##
1593
- ## @return [String] The selected section or tag name
1594
- ##
1595
- def choose_section_tag
1596
- options = @content.section_titles.sort
1597
- options.concat(@content.all_tags.sort.map { |t| "@#{t}" })
1598
- choice = Prompt.choose_from(options, prompt: 'Choose a section or tag > ', fzf_args: ['--height=60%'])
1599
- choice ? choice.strip : choice
1600
- end
1601
-
1602
- ##
1603
- ## List available views
1604
- ##
1605
- ## @return [Array] View names
1606
- ##
1607
- def views
1608
- Doing.setting('views') ? Doing.setting('views').keys : []
1609
- end
1610
-
1611
- ##
1612
- ## Generate a menu of views and allow user selection
1613
- ##
1614
- ## @return [String] The selected view name
1615
- ##
1616
- def choose_view
1617
- choice = Prompt.choose_from(views.sort, prompt: 'Choose a view > ', fzf_args: ['--height=60%'])
1618
- choice ? choice.strip : choice
1619
- end
1620
-
1621
- ##
1622
- ## Gets a view from configuration
1623
- ##
1624
- ## @param title [String] The title of the view to retrieve
1625
- ##
1626
- def get_view(title)
1627
- return Doing.setting(['views', title], nil)
1628
-
1629
- false
1630
- end
1631
-
1632
- ##
1633
- ## Display contents of a section based on options
1634
- ##
1635
- ## @param opt [Hash] Additional Options
1636
- ##
1637
- def list_section(opt, items: Items.new)
1638
- logger.benchmark(:list_section, :start)
1639
- opt[:config_template] ||= 'default'
1640
-
1641
- tpl_cfg = Doing.setting(['templates', opt[:config_template]])
1642
-
1643
- cfg = if opt[:view_template]
1644
- Doing.setting(['views', opt[:view_template]]).deep_merge(tpl_cfg, { extend_existing_arrays: true, sort_merged_arrays: true })
1645
- else
1646
- tpl_cfg
1647
- end
1648
-
1649
- cfg.deep_merge({
1650
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1651
- 'date_format' => Doing.setting('default_date_format'),
1652
- 'order' => Doing.setting('order') || :asc,
1653
- 'tags_color' => Doing.setting('tags_color'),
1654
- 'duration' => Doing.setting('duration'),
1655
- 'interval_format' => Doing.setting('interval_format')
1656
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1657
-
1658
- opt[:duration] ||= cfg['duration'] || false
1659
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1660
- opt[:count] ||= 0
1661
- opt[:age] ||= :newest
1662
- opt[:age] = opt[:age].normalize_age
1663
- opt[:format] ||= cfg['date_format']
1664
- opt[:order] ||= cfg['order'] || :asc
1665
- opt[:tag_order] ||= :asc
1666
- opt[:tags_color] = cfg['tags_color'] || false if opt[:tags_color].nil?
1667
- opt[:template] ||= cfg['template']
1668
- opt[:sort_tags] ||= opt[:tag_sort]
1669
-
1670
- # opt[:highlight] ||= true
1671
- title = ''
1672
- is_single = true
1673
- if opt[:section].nil?
1674
- opt[:section] = choose_section
1675
- title = opt[:section]
1676
- elsif opt[:section].instance_of?(String)
1677
- title = if opt[:section] =~ /^all$/i
1678
- if opt[:page_title]
1679
- opt[:page_title]
1680
- elsif opt[:tag_filter] && opt[:tag_filter]['bool'].normalize_bool != :not
1681
- opt[:tag_filter]['tags'].map { |tag| "@#{tag}" }.join(' + ')
1682
- else
1683
- 'doing'
1684
- end
1685
- else
1686
- guess_section(opt[:section])
1687
- end
1688
- end
1689
-
1690
- items = filter_items(items, opt: opt)
1691
-
1692
- items.reverse! unless opt[:order].normalize_order == :desc
1693
-
1694
- if opt[:delete]
1695
- delete_items(items, force: opt[:force])
1696
-
1697
- write(@doing_file)
1698
- return
1699
- elsif opt[:editor]
1700
- edit_items(items)
1701
-
1702
- write(@doing_file)
1703
- return
1704
- elsif opt[:interactive]
1705
- opt[:menu] = !opt[:force]
1706
- opt[:query] = '' # opt[:search]
1707
- opt[:multiple] = true
1708
- selected = Prompt.choose_from_items(items.reverse, include_section: opt[:section] =~ /^all$/i, **opt)
1709
-
1710
- raise NoResults, 'no items selected' if selected.nil? || selected.empty?
1711
-
1712
- act_on(selected, opt)
1713
- return
1714
- end
1715
-
1716
- opt[:output] ||= 'template'
1717
- opt[:wrap_width] ||= Doing.setting('templates.default.wrap_width', 0)
1718
-
1719
- logger.benchmark(:list_section, :finish)
1720
- output(items, title, is_single, opt)
1721
- end
1722
-
1723
- ##
1724
- ## Move entries from a section to Archive or other specified
1725
- ## section
1726
- ##
1727
- ## @param section [String] The source section
1728
- ## @param options [Hash] Options
1729
- ##
1730
- def archive(section = Doing.setting('current_section'), options)
1731
- options ||= {}
1732
- count = options[:keep] || 0
1733
- destination = options[:destination] || 'Archive'
1734
- tags = options[:tags] || []
1735
- bool = options[:bool] || :and
1736
-
1737
- section = choose_section if section.nil? || section =~ /choose/i
1738
- archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
1739
- section = guess_section(section) unless archive_all
1740
-
1741
- @content.add_section(destination, log: true)
1742
- # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
1743
-
1744
- destination = guess_section(destination)
1745
-
1746
- if @content.section?(destination) && (@content.section?(section) || archive_all)
1747
- do_archive(section, destination, { count: count, tags: tags, bool: bool, search: options[:search], label: options[:label], before: options[:before], after: options[:after], from: options[:from] })
1748
- write(doing_file)
1749
- else
1750
- raise InvalidArgument, 'Either source or destination does not exist'
1751
- end
1752
- end
1753
-
1754
- ##
1755
- ## Show all entries from the current day
1756
- ##
1757
- ## @param times [Boolean] show times
1758
- ## @param output [String] output format
1759
- ## @param opt [Hash] Options
1760
- ##
1761
- def today(times = true, output = nil, opt)
1762
- opt ||= {}
1763
- opt[:totals] ||= false
1764
- opt[:sort_tags] ||= false
1765
-
1766
- cfg = Doing.setting('templates').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1767
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1768
- 'date_format' => Doing.setting('default_date_format'),
1769
- 'order' => Doing.setting('order') || :asc,
1770
- 'tags_color' => Doing.setting('tags_color'),
1771
- 'duration' => Doing.setting('duration'),
1772
- 'interval_format' => Doing.setting('interval_format')
1773
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1774
-
1775
- template = opt[:template] || cfg['template']
1776
-
1777
- opt[:duration] ||= cfg['duration'] || false
1778
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1779
-
1780
- options = {
1781
- after: opt[:after],
1782
- before: opt[:before],
1783
- count: 0,
1784
- duration: opt[:duration],
1785
- from: opt[:from],
1786
- format: cfg['date_format'],
1787
- interval_format: opt[:interval_format],
1788
- only_timed: opt[:only_timed],
1789
- order: cfg['order'] || :asc,
1790
- output: output,
1791
- section: opt[:section],
1792
- sort_tags: opt[:sort_tags],
1793
- template: template,
1794
- times: times,
1795
- today: true,
1796
- totals: opt[:totals],
1797
- wrap_width: cfg['wrap_width'],
1798
- tags_color: cfg['tags_color'],
1799
- config_template: opt[:config_template]
1800
- }
1801
- list_section(options)
1802
- end
1803
-
1804
- ##
1805
- ## Display entries within a date range
1806
- ##
1807
- ## @param dates [Array] [start, end]
1808
- ## @param section [String] The section
1809
- ## @param times (Bool) Show times
1810
- ## @param output [String] Output format
1811
- ## @param opt [Hash] Additional Options
1812
- ##
1813
- def list_date(dates, section, times = nil, output = nil, opt)
1814
- opt ||= {}
1815
- opt[:totals] ||= false
1816
- opt[:sort_tags] ||= false
1817
- section = guess_section(section)
1818
- # :date_filter expects an array with start and end date
1819
- dates = dates.split_date_range if dates.instance_of?(String)
1820
-
1821
- opt[:section] = section
1822
- opt[:count] = 0
1823
- opt[:order] = :asc
1824
- opt[:date_filter] = dates
1825
- opt[:times] = times
1826
- opt[:output] = output
1827
-
1828
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
1829
- if opt[:from] && opt[:from][0].is_a?(String) && opt[:from][0] =~ time_rx
1830
- opt[:time_filter] = opt[:from]
1831
- end
1832
-
1833
- list_section(opt)
1834
- end
1835
-
1836
- ##
1837
- ## Show entries from the previous day
1838
- ##
1839
- ## @param section [String] The section
1840
- ## @param times (Bool) Show times
1841
- ## @param output [String] Output format
1842
- ## @param opt [Hash] Additional Options
1843
- ##
1844
- def yesterday(section, times = nil, output = nil, opt)
1845
- opt ||= {}
1846
- opt[:totals] ||= false
1847
- opt[:sort_tags] ||= false
1848
- opt[:config_template] ||= 'today'
1849
- opt[:yesterday] = true
1850
-
1851
- section = guess_section(section)
1852
- y = (Time.now - (60 * 60 * 24)).strftime('%Y-%m-%d')
1853
- opt[:after] = "#{y} #{opt[:after]}" if opt[:after]
1854
- opt[:before] = "#{y} #{opt[:before]}" if opt[:before]
1855
-
1856
- opt[:output] = output
1857
- opt[:section] = section
1858
- opt[:times] = times
1859
- opt[:count] = 0
1860
-
1861
- list_section(opt)
1862
- end
1863
-
1864
- ##
1865
- ## Show recent entries
1866
- ##
1867
- ## @param count [Integer] The number to show
1868
- ## @param section [String] The section to show from, default Currently
1869
- ## @param opt [Hash] Additional Options
1870
- ##
1871
- def recent(count = 10, section = nil, opt)
1872
- opt ||= {}
1873
- times = opt[:t] || true
1874
- opt[:totals] ||= false
1875
- opt[:sort_tags] ||= false
1876
-
1877
- cfg = Doing.setting('templates.recent').deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1878
- 'wrap_width' => Doing.setting('wrap_width') || 0,
1879
- 'date_format' => Doing.setting('default_date_format'),
1880
- 'order' => Doing.setting('order') || :asc,
1881
- 'tags_color' => Doing.setting('tags_color'),
1882
- 'duration' => Doing.setting('duration'),
1883
- 'interval_format' => Doing.setting('interval_format')
1884
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1885
- opt[:duration] ||= cfg['duration'] || false
1886
- opt[:interval_format] ||= cfg['interval_format'] || 'text'
1887
-
1888
- section ||= Doing.setting('current_section')
1889
- section = guess_section(section)
1890
-
1891
- opt[:section] = section
1892
- opt[:wrap_width] = cfg['wrap_width']
1893
- opt[:count] = count
1894
- opt[:format] = cfg['date_format']
1895
- opt[:template] = opt[:template] || cfg['template']
1896
- opt[:order] = :asc
1897
- opt[:times] = times
1898
-
1899
- list_section(opt)
1900
- end
1901
-
1902
- ##
1903
- ## Show the last entry
1904
- ##
1905
- ## @param times (Bool) Show times
1906
- ## @param section [String] Section to pull from, default Currently
1907
- ##
1908
- def last(times: true, section: nil, options: {})
1909
- section = section.nil? || section =~ /all/i ? 'All' : guess_section(section)
1910
- cfg = Doing.setting(['templates', options[:config_template]]).deep_merge(Doing.setting('templates.default'), { extend_existing_arrays: true, sort_merged_arrays: true }).deep_merge({
1911
- 'wrap_width' => Doing.setting('wrap_width', 0),
1912
- 'date_format' => Doing.setting('default_date_format'),
1913
- 'order' => Doing.setting('order', :asc),
1914
- 'tags_color' => Doing.setting('tags_color'),
1915
- 'duration' => Doing.setting('duration'),
1916
- 'interval_format' => Doing.setting('interval_format')
1917
- }, { extend_existing_arrays: true, sort_merged_arrays: true })
1918
- options[:duration] ||= cfg['duration'] || false
1919
- options[:interval_format] ||= cfg['interval_format'] || 'text'
1920
-
1921
- opts = {
1922
- case: options[:case],
1923
- config_template: 'last',
1924
- count: 1,
1925
- delete: options[:delete],
1926
- duration: options[:duration],
1927
- format: cfg['date_format'],
1928
- interval_format: options[:interval_format],
1929
- not: options[:negate],
1930
- section: section,
1931
- template: options[:template] || cfg['template'],
1932
- times: times,
1933
- val: options[:val],
1934
- wrap_width: cfg['wrap_width']
1935
- }
1936
-
1937
- if options[:tag]
1938
- opts[:tag_filter] = {
1939
- 'tags' => options[:tag],
1940
- 'bool' => options[:tag_bool]
1941
- }
1942
- end
1943
-
1944
- opts[:search] = options[:search] if options[:search]
1945
-
1946
- list_section(opts)
1947
- end
1948
-
1949
- ##
1950
- ## Uses 'autotag' configuration to turn keywords into tags for time tracking.
1951
- ## Does not repeat tags in a title, and only converts the first instance of an
1952
- ## untagged keyword
1953
- ##
1954
- ## @param string [String] The text to tag
1955
- ##
1956
- def autotag(string)
1957
- return unless string
1958
- return string unless Doing.auto_tag
1959
-
1960
- original = string.dup
1961
- text = string.dup
1962
-
1963
- current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
1964
- tagged = {
1965
- whitelisted: [],
1966
- synonyms: [],
1967
- transformed: [],
1968
- replaced: []
1969
- }
1970
-
1971
- Doing.setting('autotag.whitelist').each do |tag|
1972
- next if text =~ /@#{tag}\b/i
1973
-
1974
- text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
1975
- m.downcase! unless tag =~ /[A-Z]/
1976
- tagged[:whitelisted].push(m)
1977
- "@#{m}"
1978
- end
1979
- end
1980
-
1981
- Doing.setting('autotag.synonyms').each do |tag, v|
1982
- v.each do |word|
1983
- word = word.wildcard_to_rx
1984
- next unless text =~ /\b#{word}\b/i
1985
-
1986
- unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
1987
- tagged[:synonyms].push(tag)
1988
- tagged[:synonyms] = tagged[:synonyms].uniq
1989
- end
1990
- end
1991
- end
1992
-
1993
- if Doing.setting('autotag.transform')
1994
- Doing.setting('autotag.transform').each do |tag|
1995
- next unless tag =~ /\S+:\S+/
1996
-
1997
- if tag =~ /::/
1998
- rx, r = tag.split(/::/)
1999
- else
2000
- rx, r = tag.split(/:/)
2001
- end
2002
-
2003
- flag_rx = %r{/([r]+)$}
2004
- if r =~ flag_rx
2005
- flags = r.match(flag_rx)[1].split(//)
2006
- r.sub!(flag_rx, '')
2007
- end
2008
- r.gsub!(/\$/, '\\')
2009
- rx.sub!(/^@?/, '@')
2010
- regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
2011
-
2012
- text.sub!(regex) do
2013
- m = Regexp.last_match
2014
- new_tag = r
2015
-
2016
- m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
2017
- new_tag.gsub!("\\#{idx + 1}", v)
2018
- end
2019
- # Replace original tag if /r
2020
- if flags&.include?('r')
2021
- tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
2022
- new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
2023
- else
2024
- tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
2025
- tagged[:transformed] = tagged[:transformed].uniq
2026
- m[0]
2027
- end
2028
- end
2029
- end
2030
- end
2031
-
2032
- logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
2033
- logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
2034
- logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
2035
- logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
2036
-
2037
- tail_tags = tagged[:synonyms].concat(tagged[:transformed])
2038
- tail_tags.sort!
2039
- tail_tags.uniq!
2040
-
2041
- text.add_tags!(tail_tags) unless tail_tags.empty?
2042
-
2043
- if text == original
2044
- logger.debug('Autotag:', "no change to \"#{text.strip}\"")
2045
- else
2046
- new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
2047
- logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
2048
- logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
2049
- end
2050
-
2051
- text.dedup_tags
2052
- end
2053
-
2054
- ##
2055
- ## Get total elapsed time for all tags in
2056
- ## selection
2057
- ##
2058
- ## @param format [String] return format (html,
2059
- ## json, or text)
2060
- ## @param sort_by [Symbol] Sort by :name or :time
2061
- ## @param sort_order [Symbol] The sort order (:asc or :desc)
2062
- ##
2063
- def tag_times(format: :text, sort_by: :time, sort_order: :asc)
2064
- return '' if @timers.empty?
2065
-
2066
- max = @timers.keys.sort_by(&:length).reverse[0].length + 1
2067
-
2068
- total = @timers.delete('All')
2069
-
2070
- tags_data = @timers.delete_if { |_k, v| v.zero? }
2071
- sorted_tags_data = if sort_by.normalize_tag_sort == :name
2072
- tags_data.sort_by { |k, _v| k }
2073
- else
2074
- tags_data.sort_by { |_k, v| v }
2075
- end
2076
-
2077
- sorted_tags_data.reverse! if sort_order.normalize_order == :asc
2078
- case format
2079
- when :html
2080
-
2081
- output = <<EOHEAD
2082
- <table>
2083
- <caption id="tagtotals">Tag Totals</caption>
2084
- <colgroup>
2085
- <col style="text-align:left;"/>
2086
- <col style="text-align:left;"/>
2087
- </colgroup>
2088
- <thead>
2089
- <tr>
2090
- <th style="text-align:left;">project</th>
2091
- <th style="text-align:left;">time</th>
2092
- </tr>
2093
- </thead>
2094
- <tbody>
2095
- EOHEAD
2096
- sorted_tags_data.reverse.each do |k, v|
2097
- if v.positive?
2098
- output += "<tr><td style='text-align:left;'>#{k}</td><td style='text-align:left;'>#{v.time_string(format: :clock)}</td></tr>\n"
2099
- end
2100
- end
2101
- tail = <<EOTAIL
2102
- <tr>
2103
- <td style="text-align:left;" colspan="2"></td>
2104
- </tr>
2105
- </tbody>
2106
- <tfoot>
2107
- <tr>
2108
- <td style="text-align:left;"><strong>Total</strong></td>
2109
- <td style="text-align:left;">#{total.time_string(format: :clock)}</td>
2110
- </tr>
2111
- </tfoot>
2112
- </table>
2113
- EOTAIL
2114
- output + tail
2115
- when :markdown
2116
- pad = sorted_tags_data.map { |k, _| k }.group_by(&:size).max.last[0].length
2117
- pad = 7 if pad < 7
2118
- output = <<~EOHEADER
2119
- | #{' ' * (pad - 7)}project | time |
2120
- | #{'-' * (pad - 1)}: | :------- |
2121
- EOHEADER
2122
- sorted_tags_data.reverse.each do |k, v|
2123
- if v.positive?
2124
- output += "| #{' ' * (pad - k.length)}#{k} | #{v.time_string(format: :clock)} |\n"
2125
- end
2126
- end
2127
- tail = '[Tag Totals]'
2128
- output + tail
2129
- when :json
2130
- output = []
2131
- sorted_tags_data.reverse.each do |k, v|
2132
- output << {
2133
- 'tag' => k,
2134
- 'seconds' => v,
2135
- 'formatted' => v.time_string(format: :clock)
2136
- }
2137
- end
2138
- output
2139
- when :human
2140
- output = []
2141
- sorted_tags_data.reverse.each do |k, v|
2142
- spacer = ''
2143
- (max - k.length).times do
2144
- spacer += ' '
2145
- end
2146
- output.push("┃ #{spacer}#{k}:#{v.time_string(format: :hm)} ┃")
2147
- end
2148
-
2149
- header = '┏━━ Tag Totals '
2150
- (max - 2).times { header += '━' }
2151
- header += '┓'
2152
- footer = '┗'
2153
- (max + 12).times { footer += '━' }
2154
- footer += '┛'
2155
- divider = '┣'
2156
- (max + 12).times { divider += '━' }
2157
- divider += '┫'
2158
- output = output.empty? ? '' : "\n#{header}\n#{output.join("\n")}"
2159
- output += "\n#{divider}"
2160
- spacer = ''
2161
- (max - 6).times do
2162
- spacer += ' '
2163
- end
2164
- total_time = total.time_string(format: :hm)
2165
- total = "┃ #{spacer}total: "
2166
- total += total_time
2167
- total += ' ┃'
2168
- output += "\n#{total}"
2169
- output += "\n#{footer}"
2170
- output
2171
- else
2172
- output = []
2173
- sorted_tags_data.reverse.each do |k, v|
2174
- spacer = ''
2175
- (max - k.length).times do
2176
- spacer += ' '
2177
- end
2178
- output.push("#{k}:#{spacer}#{v.time_string(format: :clock)}")
2179
- end
2180
-
2181
- output = output.empty? ? '' : "\n--- Tag Totals ---\n#{output.join("\n")}"
2182
- output += "\n\nTotal tracked: #{total.time_string(format: :clock)}\n"
2183
- output
2184
- end
2185
- end
2186
-
2187
- ##
2188
- ## Gets the interval between entry's start
2189
- ## date and @done date
2190
- ##
2191
- ## @param item [Item] The entry
2192
- ## @param formatted [Boolean] Return human readable
2193
- ## time (default seconds)
2194
- ## @param record [Boolean] Add the interval to the
2195
- ## total for each tag
2196
- ##
2197
- ## @return Interval in seconds, or [d, h, m] array if
2198
- ## formatted is true. False if no end date or
2199
- ## interval is 0
2200
- ##
2201
- def get_interval(item, formatted: true, record: true)
2202
- if item.interval
2203
- seconds = item.interval
2204
- record_tag_times(item, seconds) if record
2205
- return seconds.positive? ? seconds : false unless formatted
2206
-
2207
- return seconds.positive? ? seconds.time_string(format: :clock) : false
2208
- end
2209
-
2210
- false
2211
- end
2212
-
2213
- ##
2214
- ## Load configuration files and updated the @settings
2215
- ## attribute with a Doing::Configuration object
2216
- ##
2217
- ## @param filename [String] (optional) path to
2218
- ## alternative config file
2219
- ##
2220
- def configure(filename = nil)
2221
- logger.benchmark(:configure, :start)
2222
-
2223
- if filename
2224
- Doing.config_with(filename, { ignore_local: true })
2225
- elsif ENV['DOING_CONFIG']
2226
- Doing.config_with(ENV['DOING_CONFIG'], { ignore_local: true })
2227
- end
2228
-
2229
- logger.benchmark(:configure, :finish)
2230
-
2231
- Doing.set('backup_dir', ENV['DOING_BACKUP_DIR']) if ENV['DOING_BACKUP_DIR']
2232
- end
2233
-
2234
- def get_diff(filename = nil)
2235
- configure if Doing.settings.nil?
2236
-
2237
- filename ||= Doing.setting('doing_file')
2238
- init_doing_file(filename)
2239
- current_content = @content.clone
2240
- backup_file = Util::Backup.last_backup(filename, count: 1)
2241
- raise DoingRuntimeError, 'No undo history to diff' if backup_file.nil?
2242
-
2243
- backup = WWID.new
2244
- backup.config = Doing.settings
2245
- backup.init_doing_file(backup_file)
2246
- current_content.diff(backup.content)
2247
- end
2248
-
2249
- private
2250
-
2251
- ##
2252
- ## Wraps doing file content with additional
2253
- ## header/footer content
2254
- ##
2255
- ## @return [String] concatenated content
2256
- ##
2257
- def combined_content
2258
- output = @other_content_top ? "#{@other_content_top.join("\n")}\n" : ''
2259
- was_color = Color.coloring?
2260
- Color.coloring = false
2261
- @content.dedup!(match_section: true)
2262
- output += @content.to_s
2263
- output += @other_content_bottom.join("\n") unless @other_content_bottom.nil?
2264
- # Just strip all ANSI colors from the content before writing to doing file
2265
- Color.coloring = was_color
2266
-
2267
- output.uncolor
2268
- end
2269
-
2270
- ##
2271
- ## Generate output using available export plugins
2272
- ##
2273
- ## @param items [Array] The items
2274
- ## @param title [String] Page title
2275
- ## @param is_single [Boolean] Indicates if single
2276
- ## section
2277
- ## @param opt [Hash] Additional options
2278
- ##
2279
- ## @return [String] formatted output based on opt[:output]
2280
- ## template trigger
2281
- ##
2282
- def output(items, title, is_single, opt)
2283
- logger.benchmark(:output, :start)
2284
- opt ||= {}
2285
- out = nil
2286
-
2287
- raise InvalidArgument, 'Unknown output format' unless opt[:output] =~ Plugins.plugin_regex(type: :export)
2288
-
2289
- export_options = { page_title: title, is_single: is_single, options: opt }
2290
-
2291
- Hooks.trigger :pre_export, self, opt[:output], items
2292
-
2293
- Plugins.plugins[:export].each do |_, options|
2294
- next unless opt[:output] =~ /^(#{options[:trigger].normalize_trigger})$/i
2295
-
2296
- out = options[:class].render(self, items, variables: export_options)
2297
- break
2298
- end
2299
-
2300
- logger.debug('Output:', "#{items.count} #{items.count == 1 ? 'item' : 'items'} shown")
2301
- logger.benchmark(:output, :finish)
2302
- out
2303
- end
2304
-
2305
- ##
2306
- ## Record times for item tags
2307
- ##
2308
- ## @param item [Item] The item to record
2309
- ##
2310
- def record_tag_times(item, seconds)
2311
- item_hash = "#{item.date.strftime('%s')}#{item.title}#{item.section}"
2312
- return if @recorded_items.include?(item_hash)
2313
- item.title.scan(/(?mi)@(\S+?)(\(.*\))?(?=\s|$)/).each do |m|
2314
- k = m[0] == 'done' ? 'All' : m[0].downcase
2315
- if @timers.key?(k)
2316
- @timers[k] += seconds
2317
- else
2318
- @timers[k] = seconds
2319
- end
2320
- @recorded_items.push(item_hash)
2321
- end
2322
- end
2323
-
2324
- ##
2325
- ## Helper function, performs the actual archiving
2326
- ##
2327
- ## @param section [String] The source section
2328
- ## @param destination [String] The destination
2329
- ## section
2330
- ## @param opt [Hash] Additional Options
2331
- ##
2332
- def do_archive(section, destination, opt)
2333
- opt ||= {}
2334
- count = opt[:count] || 0
2335
- tags = opt[:tags] || []
2336
- bool = opt[:bool] || :and
2337
- label = opt[:label] || true
2338
-
2339
- section = guess_section(section)
2340
- destination = guess_section(destination)
2341
-
2342
- section_items = @content.in_section(section)
2343
- max = section_items.count - count.to_i
2344
-
2345
- opt[:after] = opt[:from][0] if opt[:from]
2346
- opt[:before] = opt[:from][1] if opt[:from]
2347
-
2348
- time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
2349
-
2350
- if opt[:before].is_a?(String) && opt[:before] =~ time_rx
2351
- opt[:before] = opt[:before].chronify(guess: :end, future: false)
2352
- end
2353
-
2354
- if opt[:after].is_a?(String) && opt[:after] =~ time_rx
2355
- opt[:after] = opt[:after].chronify(guess: :begin, future: false)
2356
- end
2357
-
2358
- counter = 0
2359
-
2360
- @content.map do |item|
2361
- break if counter >= max
2362
-
2363
- next if item.section.downcase == destination.downcase
2364
-
2365
- next if item.section.downcase != section.downcase && section != /^all$/i
2366
-
2367
- next if (opt[:before] && item.date > opt[:before]) || (opt[:after] && item.date < opt[:after])
2368
-
2369
- next if (!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s))
2370
-
2371
- counter += 1
2372
- old_item = item.clone
2373
- item.move_to(destination, label: label, log: false)
2374
- Hooks.trigger :post_entry_updated, self, item, old_item
2375
- item
2376
- end
2377
-
2378
- if counter.positive?
2379
- logger.count(destination == 'Archive' ? :archived : :moved,
2380
- level: :info,
2381
- count: counter,
2382
- message: "%count %items from #{section} to #{destination}")
2383
- else
2384
- logger.info('Skipped:', 'No items were moved')
2385
- end
2386
- end
2387
-
2388
- def run_after
2389
- return unless Doing.setting('run_after')
2390
-
2391
- _, stderr, status = Open3.capture3(Doing.setting('run_after'))
2392
- return unless status.exitstatus.positive?
2393
-
2394
- logger.log_now(:error, 'Script error:', "Error running #{Doing.setting('run_after')}")
2395
- logger.log_now(:error, 'STDERR output:', stderr)
2396
- end
2397
- end
2398
- end