friends 0.34 → 0.35

Sign up to get free protection for your applications and to get access to all the features.
@@ -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