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
@@ -0,0 +1,617 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ ##
6
+ ## Adds an entry
7
+ ##
8
+ ## @param title [String] The entry title
9
+ ## @param section [String] The section to add to
10
+ ## @param opt [Hash] Additional Options
11
+ ##
12
+ ## @option opt :date [Date] item start date
13
+ ## @option opt :note [Note] item note (will be converted if value is String)
14
+ ## @option opt :back [Date] backdate
15
+ ## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
16
+ ## @option opt :done [Date] If set, adds a @done tag to new entry
17
+ ##
18
+ def add_item(title, section = nil, opt)
19
+ opt ||= {}
20
+ section ||= Doing.setting('current_section')
21
+ @content.add_section(section, log: false)
22
+ opt[:back] ||= opt[:date] ? opt[:date] : Time.now
23
+ opt[:date] ||= Time.now
24
+ note = Note.new
25
+ opt[:timed] ||= false
26
+
27
+ note.add(opt[:note]) if opt[:note]
28
+
29
+ title = [title.strip.cap_first]
30
+ title = title.join(' ')
31
+
32
+ if Doing.auto_tag
33
+ title = autotag(title)
34
+ title.add_tags!(Doing.setting('default_tags')) if Doing.setting('default_tags').good?
35
+ end
36
+
37
+ title.compress!
38
+ entry = Item.new(opt[:back], title.strip, section)
39
+
40
+ if opt[:done] && entry.should_finish?
41
+ if entry.should_time?
42
+ entry.tag('done', value: opt[:done])
43
+ else
44
+ entry.tag('done')
45
+ end
46
+ end
47
+
48
+ entry.note = note
49
+
50
+ items = @content.clone
51
+ if opt[:timed]
52
+ items.reverse!
53
+ items.each_with_index do |i, x|
54
+ next if i.title =~ / @done/
55
+
56
+ finish_date = verify_duration(i.date, opt[:back], title: i.title)
57
+ items[x].tag('done', value: finish_date.strftime('%F %R'))
58
+ break
59
+ end
60
+ end
61
+
62
+ Hooks.trigger :pre_entry_add, self, entry
63
+
64
+ @content.push(entry)
65
+ # logger.count(:added, level: :debug)
66
+ logger.info('New entry:', %(added "#{entry.date.relative_date}: #{entry.title}" to #{section}))
67
+
68
+ Hooks.trigger :post_entry_added, self, entry
69
+ entry
70
+ end
71
+
72
+ # Reset start date to current time, optionally remove
73
+ # done tag (resume)
74
+ #
75
+ # @param item [Item] the item to reset/resume
76
+ # @param resume [Boolean] removing @done tag if true
77
+ #
78
+ def reset_item(item, date: nil, finish_date: nil, resume: false)
79
+ date ||= Time.now
80
+ item.date = date
81
+ if finish_date
82
+ item.tag('done', remove: true)
83
+ item.tag('done', value: finish_date.strftime('%F %R'))
84
+ else
85
+ item.tag('done', remove: true) if resume
86
+ end
87
+ logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
88
+ item
89
+ end
90
+
91
+ # Duplicate an item and add it as a new item
92
+ #
93
+ # @param item [Item] the item to duplicate
94
+ # @param opt [Hash] additional options
95
+ #
96
+ # @option opt :editor [Boolean] open new item in editor
97
+ # @option opt :date [String] set start date
98
+ # @option opt :in [String] add new item to section :in
99
+ # @option opt :note [Note] add note to new item
100
+ #
101
+ # @return nothing
102
+ #
103
+ def repeat_item(item, opt)
104
+ opt ||= {}
105
+ old_item = item.clone
106
+ if item.should_finish?
107
+ if item.should_time?
108
+ finish_date = verify_duration(item.date, Time.now, title: item.title)
109
+ item.title.tag!('done', value: finish_date.strftime('%F %R'))
110
+ else
111
+ item.title.tag!('done')
112
+ end
113
+ Hooks.trigger :post_entry_updated, self, item, old_item
114
+ end
115
+
116
+ # Remove @done tag
117
+ title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
118
+ section = opt[:in].nil? ? item.section : guess_section(opt[:in])
119
+ Doing.auto_tag = false
120
+
121
+ note = opt[:note] || Note.new
122
+
123
+ if opt[:editor]
124
+ start = opt[:date] ? opt[:date] : Time.now
125
+ to_edit = "#{start.strftime('%F %R')} | #{title}"
126
+ to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
127
+ new_item = fork_editor(to_edit)
128
+ date, title, note = format_input(new_item)
129
+
130
+ opt[:date] = date unless date.nil?
131
+
132
+ if title.nil? || title.empty?
133
+ logger.warn('Skipped:', 'No content provided')
134
+ return
135
+ end
136
+ end
137
+
138
+ # @content.update_item(original, item)
139
+ add_item(title, section, { note: note, back: opt[:date], timed: false })
140
+ end
141
+
142
+ ##
143
+ ## Restart the last entry
144
+ ##
145
+ ## @param opt [Hash] Additional Options
146
+ ##
147
+ def repeat_last(opt)
148
+ opt ||= {}
149
+ opt[:section] ||= 'all'
150
+ opt[:section] = guess_section(opt[:section])
151
+ opt[:note] ||= []
152
+ opt[:tag] ||= []
153
+ opt[:tag_bool] ||= :and
154
+
155
+ last = last_entry(opt)
156
+ if last.nil?
157
+ logger.warn('Skipped:', 'No previous entry found')
158
+ return
159
+ end
160
+
161
+ repeat_item(last, opt)
162
+ write(@doing_file)
163
+ end
164
+
165
+
166
+ ##
167
+ ## Tag the last entry or X entries
168
+ ##
169
+ ## @param opt [Hash] Additional Options (see
170
+ ## #filter_items for filtering
171
+ ## options)
172
+ ##
173
+ ## @see #filter_items
174
+ ##
175
+ def tag_last(opt) # hooked
176
+ opt ||= {}
177
+ opt[:count] ||= 1
178
+ opt[:archive] ||= false
179
+ opt[:tags] ||= ['done']
180
+ opt[:sequential] ||= false
181
+ opt[:date] ||= false
182
+ opt[:remove] ||= false
183
+ opt[:update] ||= false
184
+ opt[:autotag] ||= false
185
+ opt[:back] ||= false
186
+ opt[:unfinished] ||= false
187
+ opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
188
+
189
+ items = filter_items(Items.new, opt: opt)
190
+
191
+ if opt[:interactive]
192
+ items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
193
+ header: '',
194
+ prompt: 'Select entries to tag > ',
195
+ multiple: true,
196
+ sort: true,
197
+ show_if_single: true)
198
+
199
+ raise NoResults, 'no items selected' if items.empty?
200
+
201
+ end
202
+
203
+ raise NoResults, 'no items matched your search' if items.empty?
204
+
205
+ if opt[:tags].empty? && !opt[:autotag]
206
+ completions = opt[:remove] ? all_tags(items) : all_tags(@content)
207
+ if opt[:remove]
208
+ puts "#{yellow}Available tags: #{boldwhite}#{completions.map(&:add_at).join(', ')}"
209
+ else
210
+ puts "#{yellow}Use tab to complete known tags"
211
+ end
212
+ opt[:tags] = Doing::Prompt.read_line(prompt: "Enter tag(s) to #{opt[:remove] ? 'remove' : 'add'}",
213
+ completions: completions,
214
+ default_response: '').to_tags
215
+ raise UserCancelled, 'No tags provided' if opt[:tags].empty?
216
+ end
217
+
218
+ items.each do |item|
219
+ old_item = item.clone
220
+ added = []
221
+ removed = []
222
+
223
+ item.date = opt[:start_date] if opt[:start_date]
224
+
225
+ if opt[:autotag]
226
+ new_title = autotag(item.title) if Doing.auto_tag
227
+ if new_title == item.title
228
+ logger.count(:skipped, level: :debug, message: '%count unchaged %items')
229
+ # logger.debug('Autotag:', 'No changes')
230
+ else
231
+ logger.count(:added_tags)
232
+ logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
233
+ item.title = new_title
234
+ end
235
+ else
236
+ if opt[:done_date]
237
+ done_date = opt[:done_date]
238
+ elsif opt[:sequential]
239
+ next_entry = next_item(item)
240
+
241
+ done_date = if next_entry.nil?
242
+ Time.now
243
+ else
244
+ next_entry.date - 60
245
+ end
246
+ else
247
+ done_date = item.calculate_end_date(opt)
248
+ end
249
+
250
+ opt[:tags].each do |tag|
251
+ if tag == 'done' && !item.should_finish?
252
+
253
+ Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
254
+ logger.count(:skipped, level: :debug)
255
+ next
256
+ end
257
+
258
+ tag = tag.strip
259
+
260
+ if tag =~ /^(\S+)\((.*?)\)$/
261
+ m = Regexp.last_match
262
+ tag = m[1]
263
+ opt[:value] ||= m[2]
264
+ end
265
+
266
+ if tag =~ /^done$/ && opt[:date] && item.should_time?
267
+ max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
268
+ max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
269
+ elapsed = done_date - item.date
270
+
271
+ if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
272
+ puts boldwhite(item.title)
273
+ human = elapsed.time_string(format: :natural)
274
+ res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
275
+ unless res
276
+ new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
277
+ raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
278
+
279
+ opt[:took] = new_elapsed
280
+ done_date = item.calculate_end_date(opt) if opt[:took]
281
+ end
282
+ end
283
+ end
284
+
285
+ if opt[:remove] || opt[:rename] || opt[:value]
286
+ rename_to = nil
287
+ if opt[:value]
288
+ rename_to = tag
289
+ elsif opt[:rename]
290
+ rename_to = tag
291
+ tag = opt[:rename]
292
+ end
293
+ old_title = item.title.dup
294
+ force = opt[:value].nil? ? false : true
295
+ item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
296
+ if old_title != item.title
297
+ removed << tag
298
+ added << rename_to if rename_to
299
+ else
300
+ logger.count(:skipped, level: :debug)
301
+ end
302
+ else
303
+ old_title = item.title.dup
304
+ should_date = opt[:date] && item.should_time?
305
+ item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
306
+ item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
307
+ added << tag if old_title != item.title
308
+ end
309
+ end
310
+ end
311
+
312
+ logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
313
+
314
+ item.note.add(opt[:note]) if opt[:note]
315
+
316
+ if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
317
+ item.move_to('Archive', label: true)
318
+ elsif opt[:archive] && opt[:count].zero?
319
+ logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
320
+ end
321
+
322
+ item.expand_date_tags(Doing.setting('date_tags'))
323
+ Hooks.trigger :post_entry_updated, self, item, old_item
324
+ end
325
+
326
+ write(@doing_file)
327
+ end
328
+
329
+ ##
330
+ ## Accepts one tag and the raw text of a new item if the
331
+ ## passed tag is on any item, it's replaced with @done.
332
+ ## if new_item is not nil, it's tagged with the passed
333
+ ## tag and inserted. This is for use where only one
334
+ ## instance of a given tag should exist (@meanwhile)
335
+ ##
336
+ ## @param target_tag [String] Tag to replace
337
+ ## @param opt [Hash] Additional Options
338
+ ##
339
+ ## @option opt :section [String] target section
340
+ ## @option opt :archive [Boolean] archive old item
341
+ ## @option opt :back [Date] backdate new item
342
+ ## @option opt :new_item [String] content to use for new item
343
+ ## @option opt :note [Array] note content for new item
344
+ def stop_start(target_tag, opt)
345
+ opt ||= {}
346
+ tag = target_tag.dup
347
+ opt[:section] ||= Doing.setting('current_section')
348
+ opt[:archive] ||= false
349
+ opt[:back] ||= Time.now
350
+ opt[:new_item] ||= false
351
+ opt[:note] ||= false
352
+
353
+ opt[:section] = guess_section(opt[:section])
354
+
355
+ tag.sub!(/^@/, '')
356
+
357
+ found_items = 0
358
+
359
+ @content.each_with_index do |item, i|
360
+ old_item = i.clone
361
+ next unless item.section == opt[:section] || opt[:section] =~ /all/i
362
+
363
+ next unless item.title =~ /@#{tag}/
364
+
365
+ item.title.add_tags!([tag, 'done'], remove: true)
366
+ item.tag('done', value: opt[:back].strftime('%F %R'))
367
+
368
+ found_items += 1
369
+
370
+ if opt[:archive] && opt[:section] != 'Archive'
371
+ item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
372
+ item.move_to('Archive', label: false, log: false)
373
+ logger.count(:completed_archived)
374
+ logger.info('Completed/archived:', item.title)
375
+ else
376
+ logger.count(:completed)
377
+ logger.info('Completed:', item.title)
378
+ end
379
+ Hooks.trigger :post_entry_updated, self, item, old_item
380
+ end
381
+
382
+
383
+ logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
384
+
385
+ if opt[:new_item]
386
+ date, title, note = format_input(opt[:new_item])
387
+ opt[:back] = date unless date.nil?
388
+ note.add(opt[:note]) if opt[:note]
389
+ title.tag!(tag)
390
+ add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
391
+ end
392
+
393
+ write(@doing_file)
394
+ end
395
+
396
+ ##
397
+ ## Delete a set of items from the main index
398
+ ##
399
+ ## @param items [Array] The items to delete
400
+ ## @param force [Boolean] Force deletion without confirmation
401
+ ##
402
+ def delete_items(items, force: false)
403
+ items.slice(0, 5).each { |i| puts i.to_pretty } unless force
404
+ puts softpurple("+ #{items.size - 5} additional #{'item'.to_p(items.size - 5)}") if items.size > 5 && !force
405
+
406
+ res = force ? true : Prompt.yn("Delete #{items.size} #{'item'.to_p(items.size)}?", default_response: 'y')
407
+ return unless res
408
+
409
+ items.each { |i| Hooks.trigger :post_entry_removed, self, @content.delete_item(i, single: items.count == 1) }
410
+ # write(@doing_file)
411
+ end
412
+
413
+ ##
414
+ ## Move entries from a section to Archive or other specified
415
+ ## section
416
+ ##
417
+ ## @param section [String] The source section
418
+ ## @param options [Hash] Options
419
+ ##
420
+ def archive(section = Doing.setting('current_section'), options)
421
+ options ||= {}
422
+ count = options[:keep] || 0
423
+ destination = options[:destination] || 'Archive'
424
+ tags = options[:tags] || []
425
+ bool = options[:bool] || :and
426
+
427
+ section = choose_section if section.nil? || section =~ /choose/i
428
+ archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
429
+ section = guess_section(section) unless archive_all
430
+
431
+ @content.add_section(destination, log: true)
432
+ # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
433
+
434
+ destination = guess_section(destination)
435
+
436
+ if @content.section?(destination) && (@content.section?(section) || archive_all)
437
+ 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] })
438
+ write(doing_file)
439
+ else
440
+ raise InvalidArgument, 'Either source or destination does not exist'
441
+ end
442
+ end
443
+
444
+ ##
445
+ ## Uses 'autotag' configuration to turn keywords into tags for time tracking.
446
+ ## Does not repeat tags in a title, and only converts the first instance of an
447
+ ## untagged keyword
448
+ ##
449
+ ## @param string [String] The text to tag
450
+ ##
451
+ def autotag(string)
452
+ return unless string
453
+ return string unless Doing.auto_tag
454
+
455
+ original = string.dup
456
+ text = string.dup
457
+
458
+ current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
459
+ tagged = {
460
+ whitelisted: [],
461
+ synonyms: [],
462
+ transformed: [],
463
+ replaced: []
464
+ }
465
+
466
+ Doing.setting('autotag.whitelist').each do |tag|
467
+ next if text =~ /@#{tag}\b/i
468
+
469
+ text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
470
+ m.downcase! unless tag =~ /[A-Z]/
471
+ tagged[:whitelisted].push(m)
472
+ "@#{m}"
473
+ end
474
+ end
475
+
476
+ Doing.setting('autotag.synonyms').each do |tag, v|
477
+ v.each do |word|
478
+ word = word.wildcard_to_rx
479
+ next unless text =~ /\b#{word}\b/i
480
+
481
+ unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
482
+ tagged[:synonyms].push(tag)
483
+ tagged[:synonyms] = tagged[:synonyms].uniq
484
+ end
485
+ end
486
+ end
487
+
488
+ if Doing.setting('autotag.transform')
489
+ Doing.setting('autotag.transform').each do |tag|
490
+ next unless tag =~ /\S+:\S+/
491
+
492
+ if tag =~ /::/
493
+ rx, r = tag.split(/::/)
494
+ else
495
+ rx, r = tag.split(/:/)
496
+ end
497
+
498
+ flag_rx = %r{/([r]+)$}
499
+ if r =~ flag_rx
500
+ flags = r.match(flag_rx)[1].split(//)
501
+ r.sub!(flag_rx, '')
502
+ end
503
+ r.gsub!(/\$/, '\\')
504
+ rx.sub!(/^@?/, '@')
505
+ regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
506
+
507
+ text.sub!(regex) do
508
+ m = Regexp.last_match
509
+ new_tag = r
510
+
511
+ m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
512
+ next if v.nil?
513
+
514
+ new_tag.gsub!("\\#{idx + 1}", v)
515
+ end
516
+ # Replace original tag if /r
517
+ if flags&.include?('r')
518
+ tagged[:replaced].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
519
+ new_tag.split(/ /).map { |t| t.sub(/^@?/, '@') }.join(' ')
520
+ else
521
+ tagged[:transformed].concat(new_tag.split(/ /).map { |t| t.sub(/^@/, '') })
522
+ tagged[:transformed] = tagged[:transformed].uniq
523
+ m[0]
524
+ end
525
+ end
526
+ end
527
+ end
528
+
529
+ logger.debug('Autotag:', "whitelisted tags: #{tagged[:whitelisted].log_tags}") unless tagged[:whitelisted].empty?
530
+ logger.debug('Autotag:', "synonyms: #{tagged[:synonyms].log_tags}") unless tagged[:synonyms].empty?
531
+ logger.debug('Autotag:', "transforms: #{tagged[:transformed].log_tags}") unless tagged[:transformed].empty?
532
+ logger.debug('Autotag:', "transform replaced: #{tagged[:replaced].log_tags}") unless tagged[:replaced].empty?
533
+
534
+ tail_tags = tagged[:synonyms].concat(tagged[:transformed])
535
+ tail_tags.sort!
536
+ tail_tags.uniq!
537
+
538
+ text.add_tags!(tail_tags) unless tail_tags.empty?
539
+
540
+ if text == original
541
+ logger.debug('Autotag:', "no change to \"#{text.strip}\"")
542
+ else
543
+ new_tags = tagged[:whitelisted].concat(tail_tags).concat(tagged[:replaced])
544
+ logger.debug('Autotag:', "added #{new_tags.log_tags} to \"#{text.strip}\"")
545
+ logger.count(:autotag, level: :info, count: 1, message: 'autotag updated %count %items')
546
+ end
547
+
548
+ text.dedup_tags
549
+ end
550
+
551
+ private
552
+
553
+ ##
554
+ ## Helper function, performs the actual archiving
555
+ ##
556
+ ## @param section [String] The source section
557
+ ## @param destination [String] The destination
558
+ ## section
559
+ ## @param opt [Hash] Additional Options
560
+ ## @api private
561
+ def do_archive(section, destination, opt)
562
+ opt ||= {}
563
+ count = opt[:count] || 0
564
+ tags = opt[:tags] || []
565
+ bool = opt[:bool] || :and
566
+ label = opt[:label] || true
567
+
568
+ section = guess_section(section)
569
+ destination = guess_section(destination)
570
+
571
+ section_items = @content.in_section(section)
572
+ max = section_items.count - count.to_i
573
+
574
+ opt[:after] = opt[:from][0] if opt[:from]
575
+ opt[:before] = opt[:from][1] if opt[:from]
576
+
577
+ time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/
578
+
579
+ if opt[:before].is_a?(String) && opt[:before] =~ time_rx
580
+ opt[:before] = opt[:before].chronify(guess: :end, future: false)
581
+ end
582
+
583
+ if opt[:after].is_a?(String) && opt[:after] =~ time_rx
584
+ opt[:after] = opt[:after].chronify(guess: :begin, future: false)
585
+ end
586
+
587
+ counter = 0
588
+
589
+ @content.map do |item|
590
+ break if counter >= max
591
+
592
+ next if item.section.downcase == destination.downcase
593
+
594
+ next if item.section.downcase != section.downcase && section != /^all$/i
595
+
596
+ next if (opt[:before] && item.date > opt[:before]) || (opt[:after] && item.date < opt[:after])
597
+
598
+ next if (!tags.empty? && !item.tags?(tags, bool)) || (opt[:search] && !item.search(opt[:search].to_s))
599
+
600
+ counter += 1
601
+ old_item = item.clone
602
+ item.move_to(destination, label: label, log: false)
603
+ Hooks.trigger :post_entry_updated, self, item, old_item
604
+ item
605
+ end
606
+
607
+ if counter.positive?
608
+ logger.count(destination == 'Archive' ? :archived : :moved,
609
+ level: :info,
610
+ count: counter,
611
+ message: "%count %items from #{section} to #{destination}")
612
+ else
613
+ logger.info('Skipped:', 'No items were moved')
614
+ end
615
+ end
616
+ end
617
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ ##
6
+ ## List all tags that exist on given items
7
+ ##
8
+ ## @param items [Array] array of Item
9
+ ## @param opt [Hash] additional options
10
+ ## @param counts [Boolean] Include tag counts in
11
+ ## results
12
+ ##
13
+ ## @return [Hash] if counts is true, returns a hash with {
14
+ ## tag: count }.
15
+ ## @return [Array] If counts is false, returns a simple
16
+ ## array of tags.
17
+ ##
18
+ def all_tags(items, opt: {}, counts: false)
19
+ if counts
20
+ all_tags = {}
21
+ items.each do |item|
22
+ item.tags.each do |tag|
23
+ if all_tags.key?(tag.downcase)
24
+ all_tags[tag.downcase] += 1
25
+ else
26
+ all_tags[tag.downcase] = 1
27
+ end
28
+ end
29
+ end
30
+
31
+ all_tags.sort_by { |_, count| count }
32
+ else
33
+ all_tags = []
34
+ items.each { |item| all_tags.concat(item.tags.map(&:downcase)).uniq! }
35
+ all_tags.sort
36
+ end
37
+ end
38
+
39
+ def tag_groups(items, opt: {})
40
+ all_items = filter_items(items, opt: opt)
41
+ tags = all_tags(all_items, opt: {})
42
+ groups = {}
43
+ tags.each do |tag|
44
+ groups[tag] ||= []
45
+ groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
46
+ end
47
+
48
+ groups
49
+ end
50
+ end
51
+ end