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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ # Tag and search filtering for a Doing entry
5
+ class Item
6
+ ##
7
+ ## Test if item contains tag(s)
8
+ ##
9
+ ## @param tags (Array or String) The tags to test. Can be an array or a comma-separated string.
10
+ ## @param bool (Symbol) The boolean to use for multiple tags (:and, :or, :not)
11
+ ## @param negate [Boolean] negate the result?
12
+ ##
13
+ ## @return [Boolean] true if tag/bool combination passes
14
+ ##
15
+ def tags?(tags, bool = :and, negate: false)
16
+ if bool == :pattern
17
+ tags = tags.to_tags.tags_to_array.join(' ')
18
+ matches = tag_pattern?(tags)
19
+
20
+ return negate ? !matches : matches
21
+ end
22
+
23
+ tags = split_tags(tags)
24
+ bool = bool.normalize_bool
25
+
26
+ matches = case bool
27
+ when :and
28
+ all_tags?(tags)
29
+ when :not
30
+ no_tags?(tags)
31
+ else
32
+ any_tags?(tags)
33
+ end
34
+ negate ? !matches : matches
35
+ end
36
+
37
+ ##
38
+ ## Test if item matches tag values
39
+ ##
40
+ ## @param queries (Array) The tag value queries to test
41
+ ## @param bool (Symbol) The boolean to use for multiple tags (:and, :or, :not)
42
+ ## @param negate [Boolean] negate the result?
43
+ ##
44
+ ## @return [Boolean] true if tag/bool combination passes
45
+ ##
46
+ def tag_values?(queries, bool = :and, negate: false)
47
+ bool = bool.normalize_bool
48
+
49
+ matches = case bool
50
+ when :and
51
+ all_values?(queries)
52
+ when :not
53
+ no_values?(queries)
54
+ else
55
+ any_values?(queries)
56
+ end
57
+ negate ? !matches : matches
58
+ end
59
+
60
+ ##
61
+ ## Determine if case should be ignored for searches
62
+ ##
63
+ ## @param search [String] The search string
64
+ ## @param case_type [Symbol] The case type
65
+ ##
66
+ ## @return [Boolean] case should be ignored
67
+ ##
68
+ def ignore_case(search, case_type)
69
+ (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
70
+ end
71
+
72
+ def highlight_search(search, distance: nil, negate: false, case_type: nil)
73
+ prefs = Doing.setting('search', {})
74
+ matching = prefs.fetch('matching', 'pattern').normalize_matching
75
+ distance ||= prefs.fetch('distance', 3).to_i
76
+ case_type ||= prefs.fetch('case', 'smart').normalize_case
77
+ new_note = Note.new
78
+
79
+ if search.rx? || matching == :fuzzy
80
+ rx = search.to_rx(distance: distance, case_type: case_type)
81
+ new_title = @title.gsub(rx) { |m| yellow(m) }
82
+ new_note.add(@note.to_s.gsub(rx) { |m| yellow(m) })
83
+ else
84
+ query = search.strip.to_phrase_query
85
+
86
+ if query[:must].nil? && query[:must_not].nil?
87
+ query[:must] = query[:should]
88
+ query[:should] = []
89
+ end
90
+ query[:must].concat(query[:should]).each do |s|
91
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
92
+ new_title = @title.gsub(rx) { |m| yellow(m) }
93
+ new_note.add(@note.to_s.gsub(rx) { |m| yellow(m) })
94
+ end
95
+ end
96
+
97
+ Item.new(@date, new_title, @section, new_note)
98
+ end
99
+
100
+ ##
101
+ ## Test if item matches search string
102
+ ##
103
+ ## @param search [String] The search string
104
+ ## @param negate [Boolean] negate results
105
+ ## @param case_type (Symbol) The case-sensitivity
106
+ ## type (:sensitive,
107
+ ## :ignore, :smart)
108
+ ##
109
+ ## @return [Boolean] matches search criteria
110
+ ##
111
+ def search(search, distance: nil, negate: false, case_type: nil)
112
+ prefs = Doing.setting('search', {})
113
+ matching = prefs.fetch('matching', 'pattern').normalize_matching
114
+ distance ||= prefs.fetch('distance', 3).to_i
115
+ case_type ||= prefs.fetch('case', 'smart').normalize_case
116
+
117
+ if search.rx? || matching == :fuzzy
118
+ matches = @title + @note.to_s =~ search.to_rx(distance: distance, case_type: case_type)
119
+ else
120
+ query = search.strip.to_phrase_query
121
+
122
+ if query[:must].nil? && query[:must_not].nil?
123
+ query[:must] = query[:should]
124
+ query[:should] = []
125
+ end
126
+ matches = no_searches?(query[:must_not], case_type: case_type)
127
+ matches &&= all_searches?(query[:must], case_type: case_type)
128
+ matches &&= any_searches?(query[:should], case_type: case_type)
129
+ end
130
+ # if search =~ /(?<=\A| )[+-]\S/
131
+ # else
132
+ # text = @title + @note.to_s
133
+ # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
134
+ # end
135
+
136
+ # if search.rx? || !fuzzy
137
+ # matches = text =~ search.to_rx(distance: distance, case_type: case_type)
138
+ # else
139
+ # distance = 0.25 if distance > 1
140
+ # score = if (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
141
+ # text.downcase.pair_distance_similar(search.downcase)
142
+ # else
143
+ # score = text.pair_distance_similar(search)
144
+ # end
145
+
146
+ # if score >= distance
147
+ # matches = true
148
+ # Doing.logger.debug('Fuzzy Match:', %(#{@title}, "#{search}" #{score}))
149
+ # end
150
+ # end
151
+
152
+ negate ? !matches : matches
153
+ end
154
+
155
+ private
156
+
157
+ def all_searches?(searches, case_type: :smart)
158
+ return true unless searches.good?
159
+
160
+ text = @title + @note.to_s
161
+ searches.each do |s|
162
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
163
+ return false unless text =~ rx
164
+ end
165
+ true
166
+ end
167
+
168
+ def no_searches?(searches, case_type: :smart)
169
+ return true unless searches.good?
170
+
171
+ text = @title + @note.to_s
172
+ searches.each do |s|
173
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
174
+ return false if text =~ rx
175
+ end
176
+ true
177
+ end
178
+
179
+ def any_searches?(searches, case_type: :smart)
180
+ return true unless searches.good?
181
+
182
+ text = @title + @note.to_s
183
+ searches.each do |s|
184
+ rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
185
+ return true if text =~ rx
186
+ end
187
+ false
188
+ end
189
+
190
+ def all_tags?(tags)
191
+ return true unless tags.good?
192
+
193
+ tags.each do |tag|
194
+ if tag =~ /done/ && !should_finish?
195
+ next
196
+ else
197
+ return false unless @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
198
+ end
199
+ end
200
+ true
201
+ end
202
+
203
+ def no_tags?(tags)
204
+ return true unless tags.good?
205
+
206
+ tags.each do |tag|
207
+ if tag =~ /done/ && !should_finish?
208
+ return false
209
+ else
210
+ return false if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
211
+ end
212
+ end
213
+ true
214
+ end
215
+
216
+ def any_tags?(tags)
217
+ return true unless tags.good?
218
+
219
+ tags.each do |tag|
220
+ if tag =~ /done/ && !should_finish?
221
+ return true
222
+ else
223
+ return true if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
224
+ end
225
+ end
226
+ false
227
+ end
228
+
229
+ def tag_pattern?(tags)
230
+ query = tags.to_query
231
+
232
+ no_tags?(query[:must_not]) && all_tags?(query[:must]) && any_tags?(query[:should])
233
+ end
234
+
235
+ def tag_value(tag)
236
+ res = @title.match(/@#{tag.sub(/^@/, '').wildcard_to_rx}\((.*?)\)/)
237
+ res ? res[1] : nil
238
+ end
239
+
240
+ def number_or_date(value)
241
+ return nil unless value
242
+
243
+ if value.strip =~ /^[0-9.]+%?$/
244
+ value.strip.to_f
245
+ else
246
+ value.strip.chronify(guess: :end)
247
+ end
248
+ end
249
+
250
+ def split_value_query(query)
251
+ val_rx = /^(!)?@?(\S+) +(!?[<>=][=*]?|[$*^]=) +(.*?)$/
252
+ query.match(val_rx)
253
+ end
254
+
255
+ def any_values?(queries)
256
+ return true unless queries.good?
257
+
258
+ queries.each do |q|
259
+ parts = split_value_query(q)
260
+ return true if tag_value_matches?(parts[2], parts[3], parts[4], parts[1])
261
+ end
262
+ false
263
+ end
264
+
265
+ def all_values?(queries)
266
+ return true unless queries.good?
267
+
268
+ queries.each do |q|
269
+ parts = split_value_query(q)
270
+
271
+ return false unless tag_value_matches?(parts[2], parts[3], parts[4], parts[1])
272
+ end
273
+ true
274
+ end
275
+
276
+ def no_values?(queries)
277
+ return true unless queries.good?
278
+
279
+ queries.each do |q|
280
+ parts = split_value_query(q)
281
+ return false if tag_value_matches?(parts[2], parts[3], parts[4], parts[1])
282
+ end
283
+ true
284
+ end
285
+
286
+ def duration_matches?(value, comp)
287
+ return false if interval.nil?
288
+
289
+ val = value.chronify_qty
290
+ case comp
291
+ when /^<$/
292
+ interval < val
293
+ when /^<=$/
294
+ interval <= val
295
+ when /^>$/
296
+ interval > val
297
+ when /^>=$/
298
+ interval >= val
299
+ when /^!=/
300
+ interval != val
301
+ when /^=/
302
+ interval == val
303
+ end
304
+ end
305
+
306
+ def date_matches?(value, comp)
307
+ time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i
308
+ value = "#{@date.strftime('%Y-%m-%d')} #{value}" if value =~ time_rx
309
+
310
+ val = value.chronify(guess: :begin)
311
+ raise InvalidTimeExpression, "Unrecognized date/time expression (#{value})" if val.nil?
312
+
313
+ case comp
314
+ when /^<$/
315
+ @date < val
316
+ when /^<=$/
317
+ @date <= val
318
+ when /^>$/
319
+ @date > val
320
+ when /^>=$/
321
+ @date >= val
322
+ when /^!=/
323
+ @date != val
324
+ when /^=/
325
+ @date == val
326
+ end
327
+ end
328
+
329
+ def value_string_matches?(tag_val, comp, value)
330
+ case comp
331
+ when /\^=/
332
+ tag_val =~ /^#{value.wildcard_to_rx}/i
333
+ when /\$=/
334
+ tag_val =~ /#{value.wildcard_to_rx}$/i
335
+ when %r{==}
336
+ tag_val =~ /^#{value.wildcard_to_rx}$/i
337
+ else
338
+ tag_val =~ /#{value.wildcard_to_rx}/i
339
+ end
340
+ end
341
+
342
+ def value_number_matches?(tag_val, comp, value)
343
+ case comp
344
+ when /^<$/
345
+ tag_val < value
346
+ when /^<=$/
347
+ tag_val <= value
348
+ when /^>$/
349
+ tag_val > value
350
+ when /^>=$/
351
+ tag_val >= value
352
+ when /^!=/
353
+ tag_val != value
354
+ when /^=/
355
+ tag_val == value
356
+ end
357
+ end
358
+
359
+ ##
360
+ ## Test if a tag's value matches a given value. Value
361
+ ## can be a date string, a text string, or a
362
+ ## number/percentage. Type of comparison is determined
363
+ ## by the comparitor and the objects being compared.
364
+ ##
365
+ ## @param tag [String] The tag name from which
366
+ ## to get the value
367
+ ## @param comp [String] The comparator (e.g. >=
368
+ ## or *=)
369
+ ## @param value [String] The value to test
370
+ ## against
371
+ ## @param negate [Boolean] Negate the response
372
+ ##
373
+ ## @return True if tag value matches, False otherwise.
374
+ ##
375
+ def tag_value_matches?(tag, comp, value, negate)
376
+ # If tag matches existing tag
377
+ if tags?(tag, :and)
378
+ tag_val = tag_value(tag)
379
+
380
+ # If the tag value is not a date and contains alpha
381
+ # characters and comparison is ==, or comparison is
382
+ # a string comparitor (*= ^= $=)
383
+ if (value.chronify.nil? && value =~ /[a-z]/i && comp =~ /^!?==?$/) || comp =~ /[$*^]=/
384
+ is_match = value_string_matches?(tag_val, comp, value)
385
+
386
+ comp =~ /!/ || negate ? !is_match : is_match
387
+ else
388
+ # Convert values to either a number or a date
389
+ tag_val = number_or_date(tag_val)
390
+ val = number_or_date(value)
391
+
392
+ # Fail if either value is nil
393
+ return false if val.nil? || tag_val.nil?
394
+
395
+ # Fail unless both values are of the same class (float or date)
396
+ return false unless val.class == tag_val.class
397
+
398
+ is_match = value_number_matches?(tag_val, comp, val)
399
+
400
+ negate.nil? ? is_match : !is_match
401
+ end
402
+ # If tag name matches a trigger for elapsed time test
403
+ elsif tag =~ /^(elapsed|dur(ation)?|int(erval)?)$/i
404
+ is_match = duration_matches?(value, comp)
405
+
406
+ comp =~ /!/ || negate ? !is_match : is_match
407
+ # Else if tag name matches a trigger for start date
408
+ elsif tag =~ /^(d(ate)?|t(ime)?)$/i
409
+ is_match = date_matches?(value, comp)
410
+
411
+ comp =~ /!/ || negate ? !is_match : is_match
412
+ # Else if tag name matches a trigger for all text
413
+ elsif tag =~ /^text$/i
414
+ is_match = value_string_matches?([@title, @note.to_s(prefix: '')].join(' '), comp, value)
415
+
416
+ comp =~ /!/ || negate ? !is_match : is_match
417
+ # Else if tag name matches a trigger for title
418
+ elsif tag =~ /^title$/i
419
+ is_match = value_string_matches?(@title, comp, value)
420
+
421
+ comp =~ /!/ || negate ? !is_match : is_match
422
+ # Else if tag name matches a trigger for note
423
+ elsif tag =~ /^note$/i
424
+ is_match = value_string_matches?(@note.to_s(prefix: ''), comp, value)
425
+
426
+ comp =~ /!/ || negate ? !is_match : is_match
427
+ # Else if item contains tag being tested
428
+ else
429
+ false
430
+ end
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,59 @@
1
+ module Doing
2
+ # State queries for a Doing entry
3
+ class Item
4
+ ##
5
+ ## Test if item has a @done tag
6
+ ##
7
+ ## @return [Boolean] true item has @done tag
8
+ ##
9
+ def finished?
10
+ tags?('done')
11
+ end
12
+
13
+ ##
14
+ ## Test if item does not contain @done tag
15
+ ##
16
+ ## @return [Boolean] true if item is missing @done tag
17
+ ##
18
+ def unfinished?
19
+ tags?('done', negate: true)
20
+ end
21
+
22
+ ##
23
+ ## Test if item is included in never_finish config and
24
+ ## thus should not receive a @done tag
25
+ ##
26
+ ## @return [Boolean] item should receive @done tag
27
+ ##
28
+ def should_finish?
29
+ should?('never_finish')
30
+ end
31
+
32
+ ##
33
+ ## Test if item is included in never_time config and
34
+ ## thus should not receive a date on the @done tag
35
+ ##
36
+ ## @return [Boolean] item should receive @done date
37
+ ##
38
+ def should_time?
39
+ should?('never_time')
40
+ end
41
+
42
+ private
43
+
44
+ def should?(key)
45
+ config = Doing.settings
46
+ return true unless config[key].is_a?(Array)
47
+
48
+ config[key].each do |tag|
49
+ if tag =~ /^@/
50
+ return false if tags?(tag.sub(/^@/, '').downcase)
51
+ elsif section.downcase == tag.downcase
52
+ return false
53
+ end
54
+ end
55
+
56
+ true
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Doing
4
+ # A Doing entry
5
+ class Item
6
+ ##
7
+ ## Add (or remove) tags from the title of the item
8
+ ##
9
+ ## @param tags [Array] The tags to apply
10
+ ## @param options Additional options
11
+ ##
12
+ ## @option options :date [Boolean] Include timestamp?
13
+ ## @option options :single [Boolean] Log as a single change?
14
+ ## @option options :value [String] A value to include as @tag(value)
15
+ ## @option options :remove [Boolean] if true remove instead of adding
16
+ ## @option options :rename_to [String] if not nil, rename target tag to this tag name
17
+ ## @option options :regex [Boolean] treat target tag string as regex pattern
18
+ ## @option options :force [Boolean] with rename_to, add tag if it doesn't exist
19
+ ##
20
+ def tag(tags, **options)
21
+ added = []
22
+ removed = []
23
+
24
+ date = options.fetch(:date, false)
25
+ options[:value] ||= date ? Time.now.strftime('%F %R') : nil
26
+ options.delete(:date)
27
+
28
+ single = options.fetch(:single, false)
29
+ options.delete(:single)
30
+
31
+ tags = tags.to_tags if tags.is_a? ::String
32
+
33
+ remove = options.fetch(:remove, false)
34
+ tags.each do |tag|
35
+ if tag =~ /^(\S+)\((.*?)\)$/
36
+ m = Regexp.last_match
37
+ tag = m[1]
38
+ options[:value] ||= m[2]
39
+ end
40
+
41
+ bool = remove ? :and : :not
42
+ if tags?(tag, bool) || options[:value]
43
+ @title = @title.tag(tag, **options).strip
44
+ remove ? removed.push(tag) : added.push(tag)
45
+ end
46
+ end
47
+
48
+ Doing.logger.log_change(tags_added: added, tags_removed: removed, count: 1, item: self, single: single)
49
+
50
+ self
51
+ end
52
+
53
+ ##
54
+ ## Get a list of tags on the item
55
+ ##
56
+ ## @return [Array] array of tags (no values)
57
+ ##
58
+ def tags
59
+ @title.scan(/(?<= |\A)@([^\s(]+)/).map { |tag| tag[0] }.sort.uniq
60
+ end
61
+
62
+ ##
63
+ ## Return all tags including parenthetical values
64
+ ##
65
+ ## @return [Array<Array>] Array of array pairs,
66
+ ## [[tag1, value], [tag2, value]]
67
+ ##
68
+ def tags_with_values
69
+ @title.scan(/(?<= |\A)@([^\s(]+)(?:\((.*?)\))?/).map { |tag| [tag[0], tag[1]] }.sort.uniq
70
+ end
71
+
72
+ ##
73
+ ## convert tags on item to an array with @ symbols removed
74
+ ##
75
+ ## @return [Array] array of tags
76
+ ##
77
+ def tag_array
78
+ tags.tags_to_array
79
+ end
80
+
81
+ private
82
+
83
+ def split_tags(tags)
84
+ tags.to_tags.tags_to_array
85
+ end
86
+ end
87
+ end