doing 2.1.37 → 2.1.40

Sign up to get free protection for your applications and to get access to all the features.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +61 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +1 -1
  5. data/Rakefile +7 -1
  6. data/bin/commands/config.rb +43 -34
  7. data/bin/commands/done.rb +1 -18
  8. data/bin/commands/finish.rb +30 -25
  9. data/bin/commands/grep.rb +3 -14
  10. data/bin/commands/last.rb +2 -8
  11. data/bin/commands/meanwhile.rb +13 -6
  12. data/bin/commands/now.rb +2 -4
  13. data/bin/commands/on.rb +4 -15
  14. data/bin/commands/recent.rb +2 -8
  15. data/bin/commands/reset.rb +24 -1
  16. data/bin/commands/select.rb +1 -1
  17. data/bin/commands/show.rb +8 -16
  18. data/bin/commands/since.rb +1 -12
  19. data/bin/commands/today.rb +2 -13
  20. data/bin/commands/view.rb +1 -1
  21. data/bin/commands/yesterday.rb +2 -13
  22. data/bin/doing +41 -36
  23. data/docs/doc/Array.html +1 -1
  24. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  25. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  26. data/docs/doc/BooleanTermParser/Query.html +1 -1
  27. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  28. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  29. data/docs/doc/BooleanTermParser.html +1 -1
  30. data/docs/doc/Doing/Color.html +166 -20
  31. data/docs/doc/Doing/Completion.html +1 -1
  32. data/docs/doc/Doing/Configuration.html +1 -1
  33. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  34. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  35. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  36. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  37. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  38. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  39. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  40. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  41. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  42. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  43. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  44. data/docs/doc/Doing/Errors.html +9 -9
  45. data/docs/doc/Doing/Hooks.html +1 -1
  46. data/docs/doc/Doing/Item.html +114 -1576
  47. data/docs/doc/Doing/Items.html +121 -5
  48. data/docs/doc/Doing/Logger.html +1 -1
  49. data/docs/doc/Doing/Note.html +1 -1
  50. data/docs/doc/Doing/Pager.html +1 -1
  51. data/docs/doc/Doing/Plugins.html +1 -1
  52. data/docs/doc/Doing/Prompt.html +2 -2
  53. data/docs/doc/Doing/Section.html +1 -1
  54. data/docs/doc/Doing/TemplateString.html +2 -2
  55. data/docs/doc/Doing/Types.html +1 -1
  56. data/docs/doc/Doing/Util/Backup.html +5 -5
  57. data/docs/doc/Doing/Util.html +1 -1
  58. data/docs/doc/Doing/WWID.html +197 -4033
  59. data/docs/doc/Doing.html +2 -2
  60. data/docs/doc/FalseClass.html +1 -1
  61. data/docs/doc/GLI/Commands/Help.html +1 -1
  62. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  63. data/docs/doc/GLI/Commands.html +1 -1
  64. data/docs/doc/GLI.html +1 -1
  65. data/docs/doc/Hash.html +1 -1
  66. data/docs/doc/Object.html +1 -1
  67. data/docs/doc/PhraseParser/Operator.html +1 -1
  68. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  69. data/docs/doc/PhraseParser/Query.html +1 -1
  70. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  71. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  72. data/docs/doc/PhraseParser/TermClause.html +1 -1
  73. data/docs/doc/PhraseParser.html +1 -1
  74. data/docs/doc/Status.html +1 -1
  75. data/docs/doc/String.html +1 -1
  76. data/docs/doc/Symbol.html +1 -1
  77. data/docs/doc/Time.html +1 -1
  78. data/docs/doc/TrueClass.html +1 -1
  79. data/docs/doc/_index.html +26 -5
  80. data/docs/doc/class_list.html +1 -1
  81. data/docs/doc/file.README.html +2 -2
  82. data/docs/doc/index.html +2 -2
  83. data/docs/doc/method_list.html +237 -709
  84. data/docs/doc/top-level-namespace.html +3 -3
  85. data/docs/index.md +1 -1
  86. data/doing.rdoc +54 -7
  87. data/lib/completion/_doing.zsh +6 -6
  88. data/lib/completion/doing.bash +10 -10
  89. data/lib/completion/doing.fish +8 -2
  90. data/lib/doing/add_options.rb +31 -1
  91. data/lib/doing/chronify/array.rb +68 -18
  92. data/lib/doing/chronify/string.rb +3 -1
  93. data/lib/doing/colors.rb +77 -30
  94. data/lib/doing/completion.rb +4 -5
  95. data/lib/doing/errors.rb +51 -35
  96. data/lib/doing/hooks.rb +3 -3
  97. data/lib/doing/item/dates.rb +112 -0
  98. data/lib/doing/item/query.rb +433 -0
  99. data/lib/doing/item/state.rb +59 -0
  100. data/lib/doing/item/tags.rb +87 -0
  101. data/lib/doing/item.rb +6 -537
  102. data/lib/doing/items.rb +39 -14
  103. data/lib/doing/plugin_manager.rb +3 -3
  104. data/lib/doing/plugins/export/template_export.rb +4 -4
  105. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  106. data/lib/doing/prompt.rb +6 -8
  107. data/lib/doing/string/tags.rb +8 -2
  108. data/lib/doing/util_backup.rb +6 -8
  109. data/lib/doing/version.rb +1 -1
  110. data/lib/doing/wwid/display.rb +399 -0
  111. data/lib/doing/wwid/editor.rb +214 -0
  112. data/lib/doing/wwid/filetools.rb +186 -0
  113. data/lib/doing/wwid/filter.rb +218 -0
  114. data/lib/doing/wwid/guess.rb +87 -0
  115. data/lib/doing/wwid/interactive.rb +385 -0
  116. data/lib/doing/wwid/modify.rb +618 -0
  117. data/lib/doing/wwid/tags.rb +54 -0
  118. data/lib/doing/wwid/timers.rb +345 -0
  119. data/lib/doing/wwid/wwidutil.rb +104 -0
  120. data/lib/doing/wwid.rb +31 -2308
  121. metadata +19 -2
@@ -0,0 +1,618 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ # Item modification methods for WWID class
6
+ module Modify
7
+ ##
8
+ ## Adds an entry
9
+ ##
10
+ ## @param title [String] The entry title
11
+ ## @param section [String] The section to add to
12
+ ## @param opt [Hash] Additional Options
13
+ ##
14
+ ## @option opt :date [Date] item start date
15
+ ## @option opt :note [Array] item note (will be converted if value is String)
16
+ ## @option opt :back [Date] backdate
17
+ ## @option opt :timed [Boolean] new item is timed entry, marks previous entry as @done
18
+ ## @option opt :done [Date] If set, adds a @done tag to new entry
19
+ ##
20
+ def add_item(title, section = nil, opt)
21
+ opt ||= {}
22
+ section ||= Doing.setting('current_section')
23
+ @content.add_section(section, log: false)
24
+ opt[:back] ||= opt[:date] ? opt[:date] : Time.now
25
+ opt[:date] ||= Time.now
26
+ note = Note.new
27
+ opt[:timed] ||= false
28
+
29
+ note.add(opt[:note]) if opt[:note]
30
+
31
+ title = [title.strip.cap_first]
32
+ title = title.join(' ')
33
+
34
+ if Doing.auto_tag
35
+ title = autotag(title)
36
+ title.add_tags!(Doing.setting('default_tags')) if Doing.setting('default_tags').good?
37
+ end
38
+
39
+ title.compress!
40
+ entry = Item.new(opt[:back], title.strip, section)
41
+
42
+ if opt[:done] && entry.should_finish?
43
+ if entry.should_time?
44
+ entry.tag('done', value: opt[:done])
45
+ else
46
+ entry.tag('done')
47
+ end
48
+ end
49
+
50
+ entry.note = note
51
+
52
+ items = @content.clone
53
+ if opt[:timed]
54
+ items.reverse!
55
+ items.each_with_index do |i, x|
56
+ next if i.title =~ / @done/
57
+
58
+ finish_date = verify_duration(i.date, opt[:back], title: i.title)
59
+ items[x].tag('done', value: finish_date.strftime('%F %R'))
60
+ break
61
+ end
62
+ end
63
+
64
+ Hooks.trigger :pre_entry_add, self, entry
65
+
66
+ @content.push(entry)
67
+ # logger.count(:added, level: :debug)
68
+ logger.info('New entry:', %(added "#{entry.date.relative_date}: #{entry.title}" to #{section}))
69
+
70
+ Hooks.trigger :post_entry_added, self, entry
71
+ entry
72
+ end
73
+
74
+ # Reset start date to current time, optionally remove
75
+ # done tag (resume)
76
+ #
77
+ # @param item [Item] the item to reset/resume
78
+ # @param resume [Boolean] removing @done tag if true
79
+ #
80
+ def reset_item(item, date: nil, finish_date: nil, resume: false)
81
+ date ||= Time.now
82
+ item.date = date
83
+ if finish_date
84
+ item.tag('done', remove: true)
85
+ item.tag('done', value: finish_date.strftime('%F %R'))
86
+ else
87
+ item.tag('done', remove: true) if resume
88
+ end
89
+ logger.info('Reset:', %(Reset #{resume ? 'and resumed ' : ''} "#{item.title}" in #{item.section}))
90
+ item
91
+ end
92
+
93
+ # Duplicate an item and add it as a new item
94
+ #
95
+ # @param item [Item] the item to duplicate
96
+ # @param opt [Hash] additional options
97
+ #
98
+ # @option opt :editor [Boolean] open new item in editor
99
+ # @option opt :date [String] set start date
100
+ # @option opt :in [String] add new item to section :in
101
+ # @option opt :note [Note] add note to new item
102
+ #
103
+ # @return nothing
104
+ #
105
+ def repeat_item(item, opt)
106
+ opt ||= {}
107
+ old_item = item.clone
108
+ if item.should_finish?
109
+ if item.should_time?
110
+ finish_date = verify_duration(item.date, Time.now, title: item.title)
111
+ item.title.tag!('done', value: finish_date.strftime('%F %R'))
112
+ else
113
+ item.title.tag!('done')
114
+ end
115
+ Hooks.trigger :post_entry_updated, self, item, old_item
116
+ end
117
+
118
+ # Remove @done tag
119
+ title = item.title.sub(/\s*@done(\(.*?\))?/, '').chomp
120
+ section = opt[:in].nil? ? item.section : guess_section(opt[:in])
121
+ Doing.auto_tag = false
122
+
123
+ note = opt[:note] || Note.new
124
+
125
+ if opt[:editor]
126
+ start = opt[:date] ? opt[:date] : Time.now
127
+ to_edit = "#{start.strftime('%F %R')} | #{title}"
128
+ to_edit += "\n#{note.strip_lines.join("\n")}" unless note.empty?
129
+ new_item = fork_editor(to_edit)
130
+ date, title, note = format_input(new_item)
131
+
132
+ opt[:date] = date unless date.nil?
133
+
134
+ if title.nil? || title.empty?
135
+ logger.warn('Skipped:', 'No content provided')
136
+ return
137
+ end
138
+ end
139
+
140
+ # @content.update_item(original, item)
141
+ add_item(title, section, { note: note, back: opt[:date], timed: false })
142
+ end
143
+
144
+ ##
145
+ ## Restart the last entry
146
+ ##
147
+ ## @param opt [Hash] Additional Options
148
+ ##
149
+ def repeat_last(opt)
150
+ opt ||= {}
151
+ opt[:section] ||= 'all'
152
+ opt[:section] = guess_section(opt[:section])
153
+ opt[:note] ||= []
154
+ opt[:tag] ||= []
155
+ opt[:tag_bool] ||= :and
156
+
157
+ last = last_entry(opt)
158
+ if last.nil?
159
+ logger.warn('Skipped:', 'No previous entry found')
160
+ return
161
+ end
162
+
163
+ repeat_item(last, opt)
164
+ write(@doing_file)
165
+ end
166
+
167
+
168
+ ##
169
+ ## Tag the last entry or X entries
170
+ ##
171
+ ## @param opt [Hash] Additional Options (see
172
+ ## #filter_items for filtering
173
+ ## options)
174
+ ##
175
+ ## @see #filter_items
176
+ ##
177
+ def tag_last(opt) # hooked
178
+ opt ||= {}
179
+ opt[:count] ||= 1
180
+ opt[:archive] ||= false
181
+ opt[:tags] ||= ['done']
182
+ opt[:sequential] ||= false
183
+ opt[:date] ||= false
184
+ opt[:remove] ||= false
185
+ opt[:update] ||= false
186
+ opt[:autotag] ||= false
187
+ opt[:back] ||= false
188
+ opt[:unfinished] ||= false
189
+ opt[:section] = opt[:section] ? guess_section(opt[:section]) : 'All'
190
+
191
+ items = filter_items(Items.new, opt: opt)
192
+
193
+ if opt[:interactive]
194
+ items = Prompt.choose_from_items(items, include_section: opt[:section] =~ /^all$/i, menu: true,
195
+ header: '',
196
+ prompt: 'Select entries to tag > ',
197
+ multiple: true,
198
+ sort: true,
199
+ show_if_single: true)
200
+
201
+ raise NoResults, 'no items selected' if items.empty?
202
+
203
+ end
204
+
205
+ raise NoResults, 'no items matched your search' if items.empty?
206
+
207
+ if opt[:tags].empty? && !opt[:autotag]
208
+ completions = opt[:remove] ? all_tags(items) : all_tags(@content)
209
+ if opt[:remove]
210
+ puts "#{yellow}Available tags: #{boldwhite}#{completions.map(&:add_at).join(', ')}"
211
+ else
212
+ puts "#{yellow}Use tab to complete known tags"
213
+ end
214
+ opt[:tags] = Doing::Prompt.read_line(prompt: "Enter tag(s) to #{opt[:remove] ? 'remove' : 'add'}",
215
+ completions: completions,
216
+ default_response: '').to_tags
217
+ raise UserCancelled, 'No tags provided' if opt[:tags].empty?
218
+ end
219
+
220
+ items.each do |item|
221
+ old_item = item.clone
222
+ added = []
223
+ removed = []
224
+
225
+ item.date = opt[:start_date] if opt[:start_date]
226
+
227
+ if opt[:autotag]
228
+ new_title = autotag(item.title) if Doing.auto_tag
229
+ if new_title == item.title
230
+ logger.count(:skipped, level: :debug, message: '%count unchaged %items')
231
+ # logger.debug('Autotag:', 'No changes')
232
+ else
233
+ logger.count(:added_tags)
234
+ logger.write(items.count == 1 ? :info : :debug, 'Tagged:', new_title)
235
+ item.title = new_title
236
+ end
237
+ else
238
+ if opt[:done_date]
239
+ done_date = opt[:done_date]
240
+ elsif opt[:sequential]
241
+ next_entry = next_item(item)
242
+
243
+ done_date = if next_entry.nil?
244
+ Time.now
245
+ else
246
+ next_entry.date - 60
247
+ end
248
+ else
249
+ done_date = item.calculate_end_date(opt)
250
+ end
251
+
252
+ opt[:tags].each do |tag|
253
+ if tag == 'done' && !item.should_finish?
254
+
255
+ Doing.logger.debug('Skipped:', "Item in never_finish: #{item.title}")
256
+ logger.count(:skipped, level: :debug)
257
+ next
258
+ end
259
+
260
+ tag = tag.strip
261
+
262
+ if tag =~ /^(\S+)\((.*?)\)$/
263
+ m = Regexp.last_match
264
+ tag = m[1]
265
+ opt[:value] ||= m[2]
266
+ end
267
+
268
+ if tag =~ /^done$/ && opt[:date] && item.should_time?
269
+ max_elapsed = Doing.setting('interaction.confirm_longer_than', 0)
270
+ max_elapsed = max_elapsed.chronify_qty if max_elapsed.is_a?(String)
271
+ elapsed = done_date - item.date
272
+
273
+ if max_elapsed.positive? && (elapsed > max_elapsed) && !opt[:took]
274
+ puts boldwhite(item.title)
275
+ human = elapsed.time_string(format: :natural)
276
+ res = Prompt.yn(yellow("Did this actually take #{human}"), default_response: true)
277
+ unless res
278
+ new_elapsed = Prompt.enter_text('How long did it take?').chronify_qty
279
+ raise InvalidTimeExpression, 'Unrecognized time span entry' unless new_elapsed > 0
280
+
281
+ opt[:took] = new_elapsed
282
+ done_date = item.calculate_end_date(opt) if opt[:took]
283
+ end
284
+ end
285
+ end
286
+
287
+ if opt[:remove] || opt[:rename] || opt[:value]
288
+ rename_to = nil
289
+ if opt[:value]
290
+ rename_to = tag
291
+ elsif opt[:rename]
292
+ rename_to = tag
293
+ tag = opt[:rename]
294
+ end
295
+ old_title = item.title.dup
296
+ force = opt[:value].nil? ? false : true
297
+ item.title.tag!(tag, remove: opt[:remove], rename_to: rename_to, regex: opt[:regex], value: opt[:value], force: force)
298
+ if old_title != item.title
299
+ removed << tag
300
+ added << rename_to if rename_to
301
+ else
302
+ logger.count(:skipped, level: :debug)
303
+ end
304
+ else
305
+ old_title = item.title.dup
306
+ should_date = opt[:date] && item.should_time?
307
+ item.title.tag!('done', remove: true) if tag =~ /done/ && (!should_date || opt[:update])
308
+ item.title.tag!(tag, value: should_date ? done_date.strftime('%F %R') : nil)
309
+ added << tag if old_title != item.title
310
+ end
311
+ end
312
+ end
313
+
314
+ logger.log_change(tags_added: added, tags_removed: removed, item: item, single: items.count == 1)
315
+
316
+ item.note.add(opt[:note]) if opt[:note]
317
+
318
+ if opt[:archive] && opt[:section] != 'Archive' && (opt[:count]).positive?
319
+ item.move_to('Archive', label: true)
320
+ elsif opt[:archive] && opt[:count].zero?
321
+ logger.warn('Skipped:', 'Archiving is skipped when operating on all entries')
322
+ end
323
+
324
+ item.expand_date_tags(Doing.setting('date_tags'))
325
+ Hooks.trigger :post_entry_updated, self, item, old_item
326
+ end
327
+
328
+ write(@doing_file)
329
+ end
330
+
331
+ ##
332
+ ## Accepts one tag and the raw text of a new item if the
333
+ ## passed tag is on any item, it's replaced with @done.
334
+ ## if new_item is not nil, it's tagged with the passed
335
+ ## tag and inserted. This is for use where only one
336
+ ## instance of a given tag should exist (@meanwhile)
337
+ ##
338
+ ## @param target_tag [String] Tag to replace
339
+ ## @param opt [Hash] Additional Options
340
+ ##
341
+ ## @option opt :section [String] target section
342
+ ## @option opt :archive [Boolean] archive old item
343
+ ## @option opt :back [Date] backdate new item
344
+ ## @option opt :new_item [String] content to use for new item
345
+ ## @option opt :note [Array] note content for new item
346
+ def stop_start(target_tag, opt)
347
+ opt ||= {}
348
+ tag = target_tag.dup
349
+ opt[:section] ||= Doing.setting('current_section')
350
+ opt[:archive] ||= false
351
+ opt[:back] ||= Time.now
352
+ opt[:new_item] ||= false
353
+ opt[:note] ||= false
354
+
355
+ opt[:section] = guess_section(opt[:section])
356
+
357
+ tag.sub!(/^@/, '')
358
+
359
+ found_items = 0
360
+
361
+ @content.each_with_index do |item, i|
362
+ old_item = i.clone
363
+ next unless item.section == opt[:section] || opt[:section] =~ /all/i
364
+
365
+ next unless item.title =~ /@#{tag}/
366
+
367
+ item.title.add_tags!([tag, 'done'], remove: true)
368
+ item.tag('done', value: opt[:back].strftime('%F %R'))
369
+
370
+ found_items += 1
371
+
372
+ if opt[:archive] && opt[:section] != 'Archive'
373
+ item.title = item.title.sub(/(?:@from\(.*?\))?(.*)$/, "\\1 @from(#{item.section})")
374
+ item.move_to('Archive', label: false, log: false)
375
+ logger.count(:completed_archived)
376
+ logger.info('Completed/archived:', item.title)
377
+ else
378
+ logger.count(:completed)
379
+ logger.info('Completed:', item.title)
380
+ end
381
+ Hooks.trigger :post_entry_updated, self, item, old_item
382
+ end
383
+
384
+
385
+ logger.debug('Skipped:', "No active @#{tag} tasks found.") if found_items.zero?
386
+
387
+ if opt[:new_item]
388
+ date, title, note = format_input(opt[:new_item])
389
+ opt[:back] = date unless date.nil?
390
+ note.add(opt[:note]) if opt[:note]
391
+ title.tag!(tag)
392
+ add_item(title.cap_first, opt[:section], { note: note, back: opt[:back] })
393
+ end
394
+
395
+ write(@doing_file)
396
+ end
397
+
398
+ ##
399
+ ## Delete a set of items from the main index
400
+ ##
401
+ ## @param items [Array] The items to delete
402
+ ## @param force [Boolean] Force deletion without confirmation
403
+ ##
404
+ def delete_items(items, force: false)
405
+ items.slice(0, 5).each { |i| puts i.to_pretty } unless force
406
+ puts softpurple("+ #{items.size - 5} additional #{'item'.to_p(items.size - 5)}") if items.size > 5 && !force
407
+
408
+ res = force ? true : Prompt.yn("Delete #{items.size} #{'item'.to_p(items.size)}?", default_response: 'y')
409
+ return unless res
410
+
411
+ items.each { |i| Hooks.trigger :post_entry_removed, self, @content.delete_item(i, single: items.count == 1) }
412
+ # write(@doing_file)
413
+ end
414
+
415
+ ##
416
+ ## Move entries from a section to Archive or other specified
417
+ ## section
418
+ ##
419
+ ## @param section [String] The source section
420
+ ## @param options [Hash] Options
421
+ ##
422
+ def archive(section = Doing.setting('current_section'), options)
423
+ options ||= {}
424
+ count = options[:keep] || 0
425
+ destination = options[:destination] || 'Archive'
426
+ tags = options[:tags] || []
427
+ bool = options[:bool] || :and
428
+
429
+ section = choose_section if section.nil? || section =~ /choose/i
430
+ archive_all = section =~ /^all$/i # && !(tags.nil? || tags.empty?)
431
+ section = guess_section(section) unless archive_all
432
+
433
+ @content.add_section(destination, log: true)
434
+ # add_section(Section.new('Archive')) if destination =~ /^archive$/i && !@content.section?('Archive')
435
+
436
+ destination = guess_section(destination)
437
+
438
+ if @content.section?(destination) && (@content.section?(section) || archive_all)
439
+ 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] })
440
+ write(doing_file)
441
+ else
442
+ raise InvalidArgument, 'Either source or destination does not exist'
443
+ end
444
+ end
445
+
446
+ ##
447
+ ## Uses 'autotag' configuration to turn keywords into tags for time tracking.
448
+ ## Does not repeat tags in a title, and only converts the first instance of an
449
+ ## untagged keyword
450
+ ##
451
+ ## @param string [String] The text to tag
452
+ ##
453
+ def autotag(string)
454
+ return unless string
455
+ return string unless Doing.auto_tag
456
+
457
+ original = string.dup
458
+ text = string.dup
459
+
460
+ current_tags = text.scan(/@\w+/).map { |t| t.sub(/^@/, '') }
461
+ tagged = {
462
+ whitelisted: [],
463
+ synonyms: [],
464
+ transformed: [],
465
+ replaced: []
466
+ }
467
+
468
+ Doing.setting('autotag.whitelist').each do |tag|
469
+ next if text =~ /@#{tag}\b/i
470
+
471
+ text.sub!(/(?<= |\A)(#{tag.strip})(?= |\Z)/i) do |m|
472
+ m.downcase! unless tag =~ /[A-Z]/
473
+ tagged[:whitelisted].push(m)
474
+ "@#{m}"
475
+ end
476
+ end
477
+
478
+ Doing.setting('autotag.synonyms').each do |tag, v|
479
+ v.each do |word|
480
+ word = word.wildcard_to_rx
481
+ next unless text =~ /\b#{word}\b/i
482
+
483
+ unless current_tags.include?(tag) || tagged[:whitelisted].include?(tag)
484
+ tagged[:synonyms].push(tag)
485
+ tagged[:synonyms] = tagged[:synonyms].uniq
486
+ end
487
+ end
488
+ end
489
+
490
+ if Doing.setting('autotag.transform')
491
+ Doing.setting('autotag.transform').each do |tag|
492
+ next unless tag =~ /\S+:\S+/
493
+
494
+ if tag =~ /::/
495
+ rx, r = tag.split(/::/)
496
+ else
497
+ rx, r = tag.split(/:/)
498
+ end
499
+
500
+ flag_rx = %r{/([r]+)$}
501
+ if r =~ flag_rx
502
+ flags = r.match(flag_rx)[1].split(//)
503
+ r.sub!(flag_rx, '')
504
+ end
505
+ r.gsub!(/\$/, '\\')
506
+ rx.sub!(/^@?/, '@')
507
+ regex = Regexp.new("(?<= |\\A)#{rx}(?= |\\Z)")
508
+
509
+ text.sub!(regex) do
510
+ m = Regexp.last_match
511
+ new_tag = r
512
+
513
+ m.to_a.slice(1, m.length - 1).each_with_index do |v, idx|
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
618
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ class WWID
5
+ # Tag methods for WWID class
6
+ module Tags
7
+ ##
8
+ ## List all tags that exist on given items
9
+ ##
10
+ ## @param items [Array] array of Item
11
+ ## @param opt [Hash] additional options
12
+ ## @param counts [Boolean] Include tag counts in
13
+ ## results
14
+ ##
15
+ ## @return [Hash] if counts is true, returns a hash with {
16
+ ## tag: count }.
17
+ ## @return [Array] If counts is false, returns a simple
18
+ ## array of tags.
19
+ ##
20
+ def all_tags(items, opt: {}, counts: false)
21
+ if counts
22
+ all_tags = {}
23
+ items.each do |item|
24
+ item.tags.each do |tag|
25
+ if all_tags.key?(tag.downcase)
26
+ all_tags[tag.downcase] += 1
27
+ else
28
+ all_tags[tag.downcase] = 1
29
+ end
30
+ end
31
+ end
32
+
33
+ all_tags.sort_by { |_, count| count }
34
+ else
35
+ all_tags = []
36
+ items.each { |item| all_tags.concat(item.tags.map(&:downcase)).uniq! }
37
+ all_tags.sort
38
+ end
39
+ end
40
+
41
+ def tag_groups(items, opt: {})
42
+ all_items = filter_items(items, opt: opt)
43
+ tags = all_tags(all_items, opt: {})
44
+ groups = {}
45
+ tags.each do |tag|
46
+ groups[tag] ||= []
47
+ groups[tag] = filter_items(all_items, opt: { tag: tag, tag_bool: :or })
48
+ end
49
+
50
+ groups
51
+ end
52
+ end
53
+ end
54
+ end