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,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