doing 2.1.39 → 2.1.42

Sign up to get free protection for your applications and to get access to all the features.
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