doing 2.1.39 → 2.1.40

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +1 -1
  5. data/bin/commands/config.rb +43 -34
  6. data/bin/commands/done.rb +1 -18
  7. data/bin/commands/finish.rb +30 -25
  8. data/bin/commands/grep.rb +3 -14
  9. data/bin/commands/last.rb +2 -8
  10. data/bin/commands/meanwhile.rb +13 -6
  11. data/bin/commands/on.rb +3 -16
  12. data/bin/commands/recent.rb +2 -8
  13. data/bin/commands/reset.rb +24 -1
  14. data/bin/commands/select.rb +1 -1
  15. data/bin/commands/show.rb +6 -17
  16. data/bin/commands/since.rb +1 -12
  17. data/bin/commands/today.rb +2 -13
  18. data/bin/commands/view.rb +1 -1
  19. data/bin/commands/yesterday.rb +2 -13
  20. data/bin/doing +15 -8
  21. data/docs/doc/Array.html +1 -1
  22. data/docs/doc/BooleanTermParser/Clause.html +1 -1
  23. data/docs/doc/BooleanTermParser/Operator.html +1 -1
  24. data/docs/doc/BooleanTermParser/Query.html +1 -1
  25. data/docs/doc/BooleanTermParser/QueryParser.html +1 -1
  26. data/docs/doc/BooleanTermParser/QueryTransformer.html +1 -1
  27. data/docs/doc/BooleanTermParser.html +1 -1
  28. data/docs/doc/Doing/Color.html +166 -20
  29. data/docs/doc/Doing/Completion.html +1 -1
  30. data/docs/doc/Doing/Configuration.html +1 -1
  31. data/docs/doc/Doing/Errors/DoingNoTraceError.html +7 -3
  32. data/docs/doc/Doing/Errors/DoingRuntimeError.html +7 -3
  33. data/docs/doc/Doing/Errors/DoingStandardError.html +1 -1
  34. data/docs/doc/Doing/Errors/EmptyInput.html +10 -2
  35. data/docs/doc/Doing/Errors/HistoryLimitError.html +194 -0
  36. data/docs/doc/Doing/Errors/InvalidPlugin.html +194 -0
  37. data/docs/doc/Doing/Errors/MissingBackupFile.html +194 -0
  38. data/docs/doc/Doing/Errors/NoResults.html +10 -2
  39. data/docs/doc/Doing/Errors/PluginException.html +1 -1
  40. data/docs/doc/Doing/Errors/UserCancelled.html +10 -2
  41. data/docs/doc/Doing/Errors/WrongCommand.html +10 -2
  42. data/docs/doc/Doing/Errors.html +9 -9
  43. data/docs/doc/Doing/Hooks.html +1 -1
  44. data/docs/doc/Doing/Item.html +90 -1615
  45. data/docs/doc/Doing/Items.html +121 -5
  46. data/docs/doc/Doing/Logger.html +1 -1
  47. data/docs/doc/Doing/Note.html +1 -1
  48. data/docs/doc/Doing/Pager.html +1 -1
  49. data/docs/doc/Doing/Plugins.html +1 -1
  50. data/docs/doc/Doing/Prompt.html +2 -2
  51. data/docs/doc/Doing/Section.html +1 -1
  52. data/docs/doc/Doing/TemplateString.html +2 -2
  53. data/docs/doc/Doing/Types.html +1 -1
  54. data/docs/doc/Doing/Util/Backup.html +5 -5
  55. data/docs/doc/Doing/Util.html +1 -1
  56. data/docs/doc/Doing/WWID.html +197 -4033
  57. data/docs/doc/Doing.html +2 -2
  58. data/docs/doc/FalseClass.html +1 -1
  59. data/docs/doc/GLI/Commands/Help.html +1 -1
  60. data/docs/doc/GLI/Commands/MarkdownDocumentListener.html +1 -1
  61. data/docs/doc/GLI/Commands.html +1 -1
  62. data/docs/doc/GLI.html +1 -1
  63. data/docs/doc/Hash.html +1 -1
  64. data/docs/doc/Object.html +1 -1
  65. data/docs/doc/PhraseParser/Operator.html +1 -1
  66. data/docs/doc/PhraseParser/PhraseClause.html +1 -1
  67. data/docs/doc/PhraseParser/Query.html +1 -1
  68. data/docs/doc/PhraseParser/QueryParser.html +1 -1
  69. data/docs/doc/PhraseParser/QueryTransformer.html +1 -1
  70. data/docs/doc/PhraseParser/TermClause.html +1 -1
  71. data/docs/doc/PhraseParser.html +1 -1
  72. data/docs/doc/Status.html +1 -1
  73. data/docs/doc/String.html +1 -1
  74. data/docs/doc/Symbol.html +1 -1
  75. data/docs/doc/Time.html +1 -1
  76. data/docs/doc/TrueClass.html +1 -1
  77. data/docs/doc/_index.html +26 -5
  78. data/docs/doc/class_list.html +1 -1
  79. data/docs/doc/file.README.html +2 -2
  80. data/docs/doc/index.html +2 -2
  81. data/docs/doc/method_list.html +293 -773
  82. data/docs/doc/top-level-namespace.html +3 -3
  83. data/docs/index.md +1 -1
  84. data/doing.rdoc +49 -7
  85. data/lib/completion/_doing.zsh +5 -5
  86. data/lib/completion/doing.bash +8 -8
  87. data/lib/completion/doing.fish +7 -2
  88. data/lib/doing/add_options.rb +31 -1
  89. data/lib/doing/chronify/array.rb +64 -22
  90. data/lib/doing/colors.rb +77 -30
  91. data/lib/doing/completion.rb +4 -5
  92. data/lib/doing/errors.rb +51 -35
  93. data/lib/doing/hooks.rb +3 -3
  94. data/lib/doing/item/dates.rb +112 -0
  95. data/lib/doing/item/query.rb +433 -0
  96. data/lib/doing/item/state.rb +59 -0
  97. data/lib/doing/item/tags.rb +87 -0
  98. data/lib/doing/item.rb +6 -667
  99. data/lib/doing/items.rb +38 -13
  100. data/lib/doing/plugin_manager.rb +3 -3
  101. data/lib/doing/plugins/export/template_export.rb +4 -4
  102. data/lib/doing/plugins/import/cal_to_json.scpt +0 -0
  103. data/lib/doing/util_backup.rb +6 -8
  104. data/lib/doing/version.rb +1 -1
  105. data/lib/doing/wwid/display.rb +399 -0
  106. data/lib/doing/wwid/editor.rb +214 -0
  107. data/lib/doing/wwid/filetools.rb +186 -0
  108. data/lib/doing/wwid/filter.rb +218 -0
  109. data/lib/doing/wwid/guess.rb +87 -0
  110. data/lib/doing/wwid/interactive.rb +385 -0
  111. data/lib/doing/wwid/modify.rb +618 -0
  112. data/lib/doing/wwid/tags.rb +54 -0
  113. data/lib/doing/wwid/timers.rb +345 -0
  114. data/lib/doing/wwid/wwidutil.rb +104 -0
  115. data/lib/doing/wwid.rb +31 -2317
  116. 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