friends 0.34 → 0.35

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.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- desc "Adds a friend (or nickname), activity, or location"
3
+ desc "Adds a friend (or nickname), activity, note, or location"
4
4
  command :add do |add|
5
5
  add.desc "Adds a friend"
6
6
  add.arg_name "NAME"
@@ -12,20 +12,22 @@ command :add do |add|
12
12
  end
13
13
  end
14
14
 
15
- add.desc "Adds an activity"
16
- add.arg_name "DESCRIPTION"
17
- add.command :activity do |add_activity|
18
- add_activity.action do |_, _, args|
19
- activity = @introvert.add_activity(serialization: args.join(" "))
15
+ [:activity, :note].each do |event|
16
+ add.desc "Adds #{event == :note ? 'a' : 'an'} #{event}"
17
+ add.arg_name "DESCRIPTION"
18
+ add.command event do |add_event|
19
+ add_event.action do |_, _, args|
20
+ event_obj = @introvert.send("add_#{event}", serialization: args.join(" "))
20
21
 
21
- # If there's no description, prompt the user for one.
22
- if activity.description.nil? || activity.description.empty?
23
- activity.description = Readline.readline(activity.to_s)
24
- activity.highlight_description(introvert: @introvert)
25
- end
22
+ # If there's no description, prompt the user for one.
23
+ if event_obj.description.nil? || event_obj.description.empty?
24
+ event_obj.description = Readline.readline(event_obj.to_s)
25
+ event_obj.highlight_description(introvert: @introvert)
26
+ end
26
27
 
27
- @message = "Activity added: \"#{activity}\""
28
- @dirty = true # Mark the file for cleaning.
28
+ @message = "#{event.to_s.capitalize} added: \"#{event_obj}\""
29
+ @dirty = true # Mark the file for cleaning.
30
+ end
29
31
  end
30
32
  end
31
33
 
@@ -28,50 +28,53 @@ command :list do |list|
28
28
  end
29
29
  end
30
30
 
31
- list.desc "Lists all activities"
32
- list.command :activities do |list_activities|
33
- list_activities.flag [:limit],
34
- arg_name: "NUMBER",
35
- desc: "The number of activities to return",
36
- default_value: 10,
37
- type: Integer
38
-
39
- list_activities.flag [:with],
40
- arg_name: "NAME",
41
- desc: "List only activities with the given friend",
42
- type: Stripped,
43
- multiple: true
44
-
45
- list_activities.flag [:in],
46
- arg_name: "LOCATION",
47
- desc: "List only activities in the given location",
48
- type: Stripped
49
-
50
- list_activities.flag [:tagged],
51
- arg_name: "@TAG",
52
- desc: "List only activities with the given tag",
53
- type: Tag,
54
- multiple: true
55
-
56
- list_activities.flag [:since],
57
- arg_name: "DATE",
58
- desc: "List only activities on or after the given date",
59
- type: InputDate
60
-
61
- list_activities.flag [:until],
62
- arg_name: "DATE",
63
- desc: "List only activities before or on the given date",
64
- type: InputDate
65
-
66
- list_activities.action do |_, options|
67
- puts @introvert.list_activities(
68
- limit: options[:limit],
69
- with: options[:with],
70
- location_name: options[:in],
71
- tagged: options[:tagged],
72
- since_date: options[:since],
73
- until_date: options[:until]
74
- )
31
+ [:activities, :notes].each do |events|
32
+ list.desc "Lists all #{events}"
33
+ list.command events do |list_events|
34
+ list_events.flag [:limit],
35
+ arg_name: "NUMBER",
36
+ desc: "The number of #{events} to return",
37
+ default_value: 10,
38
+ type: Integer
39
+
40
+ list_events.flag [:with],
41
+ arg_name: "NAME",
42
+ desc: "List only #{events} with the given friend",
43
+ type: Stripped,
44
+ multiple: true
45
+
46
+ list_events.flag [:in],
47
+ arg_name: "LOCATION",
48
+ desc: "List only #{events} in the given location",
49
+ type: Stripped
50
+
51
+ list_events.flag [:tagged],
52
+ arg_name: "@TAG",
53
+ desc: "List only #{events} with the given tag",
54
+ type: Tag,
55
+ multiple: true
56
+
57
+ list_events.flag [:since],
58
+ arg_name: "DATE",
59
+ desc: "List only #{events} on or after the given date",
60
+ type: InputDate
61
+
62
+ list_events.flag [:until],
63
+ arg_name: "DATE",
64
+ desc: "List only #{events} before or on the given date",
65
+ type: InputDate
66
+
67
+ list_events.action do |_, options|
68
+ puts @introvert.send(
69
+ "list_#{events}",
70
+ limit: options[:limit],
71
+ with: options[:with],
72
+ location_name: options[:in],
73
+ tagged: options[:tagged],
74
+ since_date: options[:since],
75
+ until_date: options[:until]
76
+ )
77
+ end
75
78
  end
76
79
  end
77
80
 
@@ -85,9 +88,10 @@ command :list do |list|
85
88
  list.desc "List all tags used"
86
89
  list.command :tags do |list_tags|
87
90
  list_tags.flag [:from],
88
- arg_name: '"activities" or "friends" (default: both)',
89
- desc: "List only tags from activities or friends instead of"\
90
- "both"
91
+ arg_name: '"activities" or "friends" or "notes" (default: all)',
92
+ desc: "List only tags from activities, friends, or notes instead of"\
93
+ "all three",
94
+ multiple: true
91
95
  list_tags.action do |_, options|
92
96
  puts @introvert.list_tags(from: options[:from])
93
97
  end
@@ -5,6 +5,9 @@ command :stats do |stats|
5
5
  stats.action do
6
6
  puts "Total activities: #{@introvert.total_activities}"
7
7
  puts "Total friends: #{@introvert.total_friends}"
8
+ puts "Total locations: #{@introvert.total_locations}"
9
+ puts "Total notes: #{@introvert.total_notes}"
10
+ puts "Total tags: #{@introvert.total_tags}"
8
11
  days = @introvert.elapsed_days
9
12
  puts "Total time elapsed: #{days} day#{'s' if days != 1}"
10
13
  end
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Event represents an event with a date and a description.
4
+ # It's basically a superclass for Activity and Note.
5
+
6
+ require "chronic"
7
+ require "paint"
8
+ require "set"
9
+
10
+ require "friends/serializable"
11
+
12
+ module Friends
13
+ class Event
14
+ extend Serializable
15
+
16
+ SERIALIZATION_PREFIX = "- ".freeze
17
+ DATE_PARTITION = ": ".freeze
18
+
19
+ # @return [Regexp] the regex for capturing groups in deserialization
20
+ def self.deserialization_regex
21
+ /(#{SERIALIZATION_PREFIX})?(?<str>.+)?/
22
+ end
23
+
24
+ # @param str [String] the text of the activity, of one of the formats:
25
+ # "<date>: <description>"
26
+ # "<date>" (Program will prompt for description.)
27
+ # "<description>" (The current date will be used by default.)
28
+ # @return [Activity] the new activity
29
+ def initialize(str: "")
30
+ # Partition lets us parse "Today" and "Today: I awoke." identically.
31
+ date_s, _, description = str.partition(DATE_PARTITION)
32
+
33
+ time = if date_s =~ /^\d{4}-\d{2}-\d{2}$/
34
+ Time.parse(date_s)
35
+ else
36
+ # If the user inputed a non YYYY-MM-DD format, asssume
37
+ # it is in the past.
38
+ past_time = Chronic.parse(date_s, context: :past)
39
+
40
+ # If there's no year, Chronic will sometimes parse the date
41
+ # as being the next occurrence of that date in the future.
42
+ # Instead, we want to subtract one year to make it the last
43
+ # occurrence of the date in the past.
44
+ # NOTE: This is a hacky workaround for the fact that
45
+ # Chronic's `context: :past` doesn't actually work. We should
46
+ # remove this when that behavior is fixed.
47
+ if past_time && past_time > Time.now
48
+ Time.local(past_time.year - 1, past_time.month, past_time.day)
49
+ else
50
+ past_time
51
+ end
52
+ end
53
+
54
+ if time
55
+ @date = time.to_date
56
+ @description = description
57
+ else
58
+ # If the user didn't input a date, we fall back to the current date.
59
+ @date = Date.today
60
+ @description = str # Use str in case DATE_PARTITION occurred naturally.
61
+ end
62
+ end
63
+
64
+ attr_reader :date
65
+ attr_accessor :description
66
+
67
+ # @return [String] the command-line display text for the activity
68
+ def to_s
69
+ date_s = Paint[date, :bold]
70
+ description_s = description.to_s
71
+ # rubocop:disable Lint/AssignmentInCondition
72
+ while match = description_s.match(/\*\*([^\*]+)\*\*/)
73
+ # rubocop:enable Lint/AssignmentInCondition
74
+ description_s = "#{match.pre_match}"\
75
+ "#{Paint[match[1], :bold, :magenta]}"\
76
+ "#{match.post_match}"
77
+ end
78
+
79
+ # rubocop:disable Lint/AssignmentInCondition
80
+ while match = description_s.match(/_([^_]+)_/)
81
+ # rubocop:enable Lint/AssignmentInCondition
82
+ description_s = "#{match.pre_match}"\
83
+ "#{Paint[match[1], :bold, :yellow]}"\
84
+ "#{match.post_match}"
85
+ end
86
+
87
+ description_s = description_s.
88
+ gsub(TAG_REGEX, Paint['\0', :bold, :cyan])
89
+
90
+ "#{date_s}: #{description_s}"
91
+ end
92
+
93
+ # @return [String] the file serialization text for the activity
94
+ def serialize
95
+ "#{SERIALIZATION_PREFIX}#{date}: #{description}"
96
+ end
97
+
98
+ # Modify the description to turn inputted friend names
99
+ # (e.g. "Jacob" or "Jacob Evelyn") into full asterisk'd names
100
+ # (e.g. "**Jacob Evelyn**") and inputted location names (e.g. "Atlantis")
101
+ # into full underscore'd names (e.g. "_Atlantis_").
102
+ # @param introvert [Introvert] used to access internal data structures to
103
+ # perform object matching
104
+ def highlight_description(introvert:)
105
+ highlight_locations(introvert: introvert)
106
+ highlight_friends(introvert: introvert)
107
+ end
108
+
109
+ # Updates a friend's old_name to their new_name
110
+ # @param [String] old_name
111
+ # @param [String] new_name
112
+ # @return [String] if name found in description
113
+ # @return [nil] if no change was made
114
+ def update_friend_name(old_name:, new_name:)
115
+ @description = @description.gsub(
116
+ Regexp.new("(?<=\\*\\*)#{old_name}(?=\\*\\*)"),
117
+ new_name
118
+ )
119
+ end
120
+
121
+ # Updates a location's old_name to their new_name
122
+ # @param [String] old_name
123
+ # @param [String] new_name
124
+ # @return [String] if name found in description
125
+ # @return [nil] if no change was made
126
+ def update_location_name(old_name:, new_name:)
127
+ @description = @description.gsub(
128
+ Regexp.new("(?<=_)#{old_name}(?=_)"),
129
+ new_name
130
+ )
131
+ end
132
+
133
+ # @param location [Location] the location to test
134
+ # @return [Boolean] true iff this activity includes the given location
135
+ def includes_location?(location)
136
+ @description.scan(/(?<=_)[^_]+(?=_)/).include? location.name
137
+ end
138
+
139
+ # @param friend [Friend] the friend to test
140
+ # @return [Boolean] true iff this activity includes the given friend
141
+ def includes_friend?(friend)
142
+ friend_names.include? friend.name
143
+ end
144
+
145
+ # @param tag [String] the tag to test, of the form "@tag"
146
+ # @return [Boolean] true iff this activity includes the given tag
147
+ def includes_tag?(tag)
148
+ tags.include? tag
149
+ end
150
+
151
+ # @return [Set] all tags in this activity (including the "@")
152
+ def tags
153
+ Set.new(@description.scan(TAG_REGEX))
154
+ end
155
+
156
+ # Find the names of all friends in this description.
157
+ # @return [Array] list of all friend names in the description
158
+ def friend_names
159
+ @_friend_names ||= @description.scan(/(?<=\*\*)\w[^\*]*(?=\*\*)/).uniq
160
+ end
161
+
162
+ # Find the names of all locations in this description.
163
+ # @return [Array] list of all location names in the description
164
+ def location_names
165
+ @_location_names ||= @description.scan(/(?<=_)\w[^_]*(?=_)/).uniq
166
+ end
167
+
168
+ private
169
+
170
+ # Modify the description to turn inputted location names (e.g. "Atlantis")
171
+ # into full underscore'd names (e.g. "_Atlantis_").
172
+ # @param introvert [Introvert] used to access internal data structures to
173
+ # perform location matching
174
+ def highlight_locations(introvert:)
175
+ introvert.regex_location_map.each do |regex, location|
176
+ # If we find a match, replace all instances of the matching text with
177
+ # the location's name. We use single-underscores to indicate locations.
178
+ description_matches(regex: regex, replace: true, indicator: "_") do
179
+ location.name
180
+ end
181
+ end
182
+ end
183
+
184
+ # Modify the description to turn inputted friend names
185
+ # (e.g. "Jacob" or "Jacob Evelyn") into full asterisk'd names
186
+ # (e.g. "**Jacob Evelyn**")
187
+ # @param introvert [Introvert] used to access internal data structures to
188
+ # perform friend matching
189
+ # NOTE: When a friend name matches more than one friend, this method chooses
190
+ # a friend based on a best-guess algorithm that looks at which friends do
191
+ # activities together and which friends are stronger than others. For
192
+ # more information see the comments below and the
193
+ # introvert#set_likelihood_score! method.
194
+ def highlight_friends(introvert:)
195
+ # Split the regex friend map into two maps: one for names with only one
196
+ # friend match and another for ambiguous names
197
+ definite_map, ambiguous_map =
198
+ introvert.regex_friend_map.partition { |_, arr| arr.size == 1 }
199
+
200
+ matched_friends = []
201
+
202
+ # First, we find all of the unambiguous matches, and make those
203
+ # substitutions.
204
+ definite_map.each do |regex, friend_list|
205
+ # If we find a match, add the friend to the matched list and replace all
206
+ # instances of the matching text with the friend's name.
207
+ description_matches(regex: regex, replace: true, indicator: "**") do
208
+ friend = friend_list.first # There's only one friend in the list.
209
+ matched_friends << friend
210
+ friend.name
211
+ end
212
+ end
213
+
214
+ possible_matched_friends = []
215
+
216
+ # Now, we look at regex matches that are ambiguous.
217
+ ambiguous_map.each do |regex, friend_list|
218
+ # If we find a match, add the friend to the possible-match list.
219
+ description_matches(regex: regex, replace: false, indicator: "**") do
220
+ possible_matched_friends << friend_list
221
+ end
222
+ end
223
+
224
+ # Now, we compute the likelihood of each friend in the possible-match set.
225
+ introvert.set_likelihood_score!(
226
+ matches: matched_friends,
227
+ possible_matches: possible_matched_friends
228
+ )
229
+
230
+ # Now we replace all of the ambiguous matches with our best guesses.
231
+ ambiguous_map.each do |regex, friend_list|
232
+ # If we find a match, take the most likely and replace all instances of
233
+ # the matching text with that friend's name.
234
+ description_matches(regex: regex, replace: true, indicator: "**") do
235
+ friend_list.sort_by do |friend|
236
+ [-friend.likelihood_score, -friend.n_activities]
237
+ end.first.name
238
+ end
239
+ end
240
+
241
+ # Lastly, we remove any backslashes, as these are used to escape friends'
242
+ # names that we don't want to match.
243
+ @description = @description.delete("\\")
244
+ end
245
+
246
+ # This method accepts a block, and tests a regex on the @description
247
+ # instance variable.
248
+ # - If the regex does not match, the block is not executed.
249
+ # - If the regex matches, the block is executed exactly once, and:
250
+ # - If `replace` is true, all of the regex's matches are replaced by the
251
+ # return value of the block, EXCEPT when the matched text is between a
252
+ # set of double-asterisks ("**") or single-underscores ("_") indicating
253
+ # it is already part of another location or friend's matched name.
254
+ # - If `replace` is not true, we do not modify @description.
255
+ # @param regex [Regexp] the regex to test against @description
256
+ # @param replace [Boolean] true iff we should replace regex matches with the
257
+ # yielded block's result in @description
258
+ def description_matches(regex:, replace:, indicator:)
259
+ # rubocop:disable Lint/AssignmentInCondition
260
+ return unless match = @description.match(regex) # Abort if no match.
261
+ # rubocop:enable Lint/AssignmentInCondition
262
+ str = yield # It's important to execute the block even if not replacing.
263
+ return unless replace # Only continue if we want to replace text.
264
+
265
+ position = 0 # Prevent infinite looping by tracking last match position.
266
+ loop do
267
+ # Only make a replacement if we're not between a set of "**"s or "_"s.
268
+ if (match.pre_match.scan("**").size % 2).zero? &&
269
+ (match.post_match.scan("**").size % 2).zero? &&
270
+ (match.pre_match.scan("_").size % 2).zero? &&
271
+ (match.post_match.scan("_").size % 2).zero?
272
+ @description = [
273
+ match.pre_match,
274
+ indicator,
275
+ str,
276
+ indicator,
277
+ match.post_match
278
+ ].join
279
+ else
280
+ # If we're between double-asterisks or single-underscores we're
281
+ # already part of a name, so we don't make a substitution. We update
282
+ # `position` to avoid infinite looping.
283
+ position = match.end(0)
284
+ end
285
+
286
+ # Exit when there are no more matches.
287
+ # rubocop:disable Lint/AssignmentInCondition
288
+ break unless match = @description.match(regex, position)
289
+ # rubocop:enable Lint/AssignmentInCondition
290
+ end
291
+ end
292
+
293
+ # Default sorting for an array of activities is reverse-date.
294
+ def <=>(other)
295
+ other.date <=> date
296
+ end
297
+ end
298
+ end