friends 0.16 → 0.17

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,7 @@
1
1
  # frozen_string_literal: true
2
2
  # Friend represents a friend. You know, a real-life friend!
3
3
 
4
+ require "friends/regex_builder"
4
5
  require "friends/serializable"
5
6
 
6
7
  module Friends
@@ -14,7 +15,7 @@ module Friends
14
15
  def self.deserialization_regex
15
16
  # Note: this regex must be on one line because whitespace is important
16
17
  # rubocop:disable Metrics/LineLength
17
- /(#{SERIALIZATION_PREFIX})?(?<name>[^\(]+)(\((?<nickname_str>#{NICKNAME_PREFIX}.+)\))?/
18
+ /(#{SERIALIZATION_PREFIX})?(?<name>[^\(\[]+)(\((?<nickname_str>#{NICKNAME_PREFIX}.+)\))?\s?(\[(?<location_name>[^\]]+)\])?/
18
19
  # rubocop:enable Metrics/LineLength
19
20
  end
20
21
 
@@ -24,14 +25,16 @@ module Friends
24
25
  end
25
26
 
26
27
  # @param name [String] the name of the friend
27
- def initialize(name:, nickname_str: nil)
28
+ def initialize(name:, nickname_str: nil, location_name: nil)
28
29
  @name = name.strip
29
30
  @nicknames = nickname_str &&
30
31
  nickname_str.split(NICKNAME_PREFIX)[1..-1].map(&:strip) ||
31
32
  []
33
+ @location_name = location_name
32
34
  end
33
35
 
34
36
  attr_accessor :name
37
+ attr_accessor :location_name
35
38
 
36
39
  # @return [String] the file serialization text for the friend
37
40
  def serialize
@@ -40,10 +43,16 @@ module Friends
40
43
 
41
44
  # @return [String] a string representing the friend's name and nicknames
42
45
  def to_s
43
- return name if @nicknames.empty?
46
+ unless @nicknames.empty?
47
+ nickname_str =
48
+ " (" +
49
+ @nicknames.map { |n| "#{NICKNAME_PREFIX}#{n}" }.join(" ") +
50
+ ")"
51
+ end
52
+
53
+ location_str = " [#{@location_name}]" unless @location_name.nil?
44
54
 
45
- nickname_str = @nicknames.map { |n| "#{NICKNAME_PREFIX}#{n}" }.join(" ")
46
- "#{name} (#{nickname_str})"
55
+ "#{@name}#{nickname_str}#{location_str}"
47
56
  end
48
57
 
49
58
  # Adds a nickname, avoiding duplicates and stripping surrounding whitespace.
@@ -53,13 +62,6 @@ module Friends
53
62
  @nicknames.uniq!
54
63
  end
55
64
 
56
- # Renames a friend, avoiding duplicates and stripping surrounding
57
- # whitespace.
58
- # @param new_name [String] the friend's new name
59
- def rename(new_name)
60
- @name = new_name
61
- end
62
-
63
65
  # @param nickname [String] the nickname to remove
64
66
  # @return [Boolean] true if the nickname was present, false otherwise
65
67
  def remove_nickname(nickname)
@@ -97,36 +99,10 @@ module Friends
97
99
  # We generously allow any amount of whitespace between parts of a name.
98
100
  splitter = "\\s+"
99
101
 
100
- # We don't want to match names that are "escaped" with a leading
101
- # backslash.
102
- no_leading_backslash = "(?<!\\\\)"
103
-
104
- # We don't want to match names that are directly touching double asterisks
105
- # as these are treated as sacred by our program.
106
- # NOTE: Technically we don't need this check here, since we perform a more
107
- # complex asterisk check in the Activity#description_matches method, but
108
- # this class shouldn't need to know about the internal implementation of
109
- # another class.
110
- no_leading_asterisks = "(?<!\\*\\*)"
111
- no_ending_asterisks = "(?!\\*\\*)"
112
-
113
- # We don't want to match names that are part of other words.
114
- no_leading_alphabeticals = "(?<![A-z])"
115
- no_ending_alphabeticals = "(?![A-z])"
116
-
117
102
  # Create the list of regexes and return it.
118
103
  chunks = name.split(Regexp.new(splitter))
119
-
120
104
  [chunks, [chunks.first], *@nicknames.map { |n| [n] }].map do |words|
121
- Regexp.new(
122
- no_leading_backslash +
123
- no_leading_asterisks +
124
- no_leading_alphabeticals +
125
- words.join(splitter) +
126
- no_ending_alphabeticals +
127
- no_ending_asterisks,
128
- Regexp::IGNORECASE
129
- )
105
+ Friends::RegexBuilder.regex(words.join(splitter))
130
106
  end
131
107
  end
132
108
 
@@ -5,6 +5,7 @@
5
5
 
6
6
  require "friends/activity"
7
7
  require "friends/friend"
8
+ require "friends/location"
8
9
  require "friends/friends_error"
9
10
 
10
11
  module Friends
@@ -12,6 +13,7 @@ module Friends
12
13
  DEFAULT_FILENAME = "./friends.md".freeze
13
14
  ACTIVITIES_HEADER = "### Activities:".freeze
14
15
  FRIENDS_HEADER = "### Friends:".freeze
16
+ LOCATIONS_HEADER = "### Locations:".freeze
15
17
  GRAPH_DATE_FORMAT = "%b %Y".freeze # Used as the param for date.strftime().
16
18
 
17
19
  # @param filename [String] the name of the friends Markdown file
@@ -25,22 +27,21 @@ module Friends
25
27
 
26
28
  # Write out the friends file with cleaned/sorted data.
27
29
  def clean
28
- descriptions = @activities.sort.map(&:serialize)
29
- names = @friends.sort.map(&:serialize)
30
-
31
- # Write out the cleaned file.
32
30
  File.open(@filename, "w") do |file|
33
31
  file.puts(ACTIVITIES_HEADER)
34
- descriptions.each { |desc| file.puts(desc) }
35
- file.puts # Blank line separating friends from activities.
32
+ @activities.sort.each { |act| file.puts(act.serialize) }
33
+ file.puts # Blank line separating activities from friends.
36
34
  file.puts(FRIENDS_HEADER)
37
- names.each { |name| file.puts(name) }
35
+ @friends.sort.each { |friend| file.puts(friend.serialize) }
36
+ file.puts # Blank line separating friends from locations.
37
+ file.puts(LOCATIONS_HEADER)
38
+ @locations.sort.each { |location| file.puts(location.serialize) }
38
39
  end
39
40
 
40
41
  @filename
41
42
  end
42
43
 
43
- # Add a friend and write out the new friends file.
44
+ # Add a friend.
44
45
  # @param name [String] the name of the friend to add
45
46
  # @raise [FriendsError] when a friend with that name is already in the file
46
47
  # @return [Friend] the added friend
@@ -60,7 +61,7 @@ module Friends
60
61
  friend # Return the added friend.
61
62
  end
62
63
 
63
- # Add an activity and write out the new friends file.
64
+ # Add an activity.
64
65
  # @param serialization [String] the serialized activity
65
66
  # @return [Activity] the added activity
66
67
  def add_activity(serialization:)
@@ -70,30 +71,91 @@ module Friends
70
71
  raise FriendsError, e
71
72
  end
72
73
 
73
- activity.highlight_friends(introvert: self) if activity.description
74
+ activity.highlight_description(introvert: self) if activity.description
74
75
  @activities.unshift(activity)
75
76
 
76
77
  activity # Return the added activity.
77
78
  end
78
79
 
79
- # Rename an existing added friend.
80
+ # Add a location.
81
+ # @param name [String] the serialized location
82
+ # @return [Location] the added location
83
+ # @raise [FriendsError] if a location with that name already exists
84
+ def add_location(name:)
85
+ if @locations.any? { |location| location.name == name }
86
+ raise FriendsError, "Location \"#{name}\" already exists"
87
+ end
88
+
89
+ begin
90
+ location = Location.deserialize(name)
91
+ rescue Serializable::SerializationError => e
92
+ raise FriendsError, e
93
+ end
94
+
95
+ @locations << location
96
+
97
+ location # Return the added location.
98
+ end
99
+
100
+ # Set a friend's location.
101
+ # @param name [String] the friend's name
102
+ # @param location_name [String] the name of an existing location
103
+ # @raise [FriendsError] if 0 or 2+ friends match the given name
104
+ # @raise [FriendsError] if 0 or 2+ locations match the given location name
105
+ # @return [Friend] the modified friend
106
+ def set_location(name:, location_name:)
107
+ friend = friend_with_name_in(name)
108
+ location = location_with_name_in(location_name)
109
+ friend.location_name = location.name
110
+ friend
111
+ end
112
+
113
+ # Rename an existing friend.
80
114
  # @param old_name [String] the name of the friend
81
115
  # @param new_name [String] the new name of the friend
82
- # @raise [FriendsError] if 0 of 2+ friends match the given name
116
+ # @raise [FriendsError] if 0 or 2+ friends match the given name
83
117
  # @return [Friend] the existing friend
84
118
  def rename_friend(old_name:, new_name:)
85
- friend = friend_with_name_in(old_name.strip)
119
+ old_name.strip!
120
+ new_name.strip!
121
+
122
+ friend = friend_with_name_in(old_name)
86
123
  @activities.each do |activity|
87
- activity.update_name(old_name: friend.name, new_name: new_name.strip)
124
+ activity.update_friend_name(old_name: friend.name, new_name: new_name)
88
125
  end
89
- friend.rename(new_name.strip)
126
+ friend.name = new_name
90
127
  friend
91
128
  end
92
129
 
93
- # Add a nickname to an existing friend and write out the new friends file.
130
+ # Rename an existing location.
131
+ # @param old_name [String] the name of the location
132
+ # @param new_name [String] the new name of the location
133
+ # @raise [FriendsError] if 0 or 2+ friends match the given name
134
+ # @return [Location] the existing location
135
+ def rename_location(old_name:, new_name:)
136
+ old_name.strip!
137
+ new_name.strip!
138
+
139
+ loc = location_with_name_in(old_name)
140
+
141
+ # Update locations in activities.
142
+ @activities.each do |activity|
143
+ activity.update_location_name(old_name: loc.name, new_name: new_name)
144
+ end
145
+
146
+ # Update locations of friends.
147
+ @friends.select { |f| f.location_name == loc.name }.each do |friend|
148
+ friend.location_name = new_name
149
+ end
150
+
151
+ loc.name = new_name # Update location itself.
152
+ loc
153
+ end
154
+
155
+ # Add a nickname to an existing friend.
94
156
  # @param name [String] the name of the friend
95
157
  # @param nickname [String] the nickname to add to the friend
96
- # @raise [FriendsError] if 0 of 2+ friends match the given name
158
+ # @raise [FriendsError] if 0 or 2+ friends match the given name
97
159
  # @return [Friend] the existing friend
98
160
  def add_nickname(name:, nickname:)
99
161
  friend = friend_with_name_in(name)
@@ -105,7 +167,7 @@ module Friends
105
167
  # file.
106
168
  # @param name [String] the name of the friend
107
169
  # @param nickname [String] the nickname to remove from the friend
108
- # @raise [FriendsError] if 0 of 2+ friends match the given name
170
+ # @raise [FriendsError] if 0 or 2+ friends match the given name
109
171
  # @return [Friend] the existing friend
110
172
  def remove_nickname(name:, nickname:)
111
173
  friend = friend_with_name_in(name)
@@ -114,9 +176,19 @@ module Friends
114
176
  end
115
177
 
116
178
  # List all friend names in the friends file.
179
+ # @param location_name [String] the name of a location to filter by, or nil
180
+ # for unfiltered
117
181
  # @return [Array] a list of all friend names
118
- def list_friends
119
- @friends.map(&:name)
182
+ def list_friends(location_name:)
183
+ fs = @friends
184
+
185
+ # Filter by location if a name is passed.
186
+ if location_name
187
+ location = location_with_name_in(location_name)
188
+ fs = fs.select { |friend| friend.location_name == location.name }
189
+ end
190
+
191
+ fs.map(&:name)
120
192
  end
121
193
 
122
194
  # List your favorite friends.
@@ -150,9 +222,11 @@ module Friends
150
222
  # limit
151
223
  # @param with [String] the name of a friend to filter by, or nil for
152
224
  # unfiltered
225
+ # @param location_name [String] the name of a location to filter by, or nil
226
+ # for unfiltered
153
227
  # @return [Array] a list of all activity text values
154
- # @raise [FriendsError] if 0 of 2+ friends match the given `with` text
155
- def list_activities(limit:, with:)
228
+ # @raise [FriendsError] if 0 or 2+ friends match the given `with` text
229
+ def list_activities(limit:, with:, location_name:)
156
230
  acts = @activities
157
231
 
158
232
  # Filter by friend name if argument is passed.
@@ -161,24 +235,39 @@ module Friends
161
235
  acts = acts.select { |act| act.includes_friend?(friend: friend) }
162
236
  end
163
237
 
238
+ # Filter by location name if argument is passed.
239
+ unless location_name.nil?
240
+ location = location_with_name_in(location_name)
241
+ acts = acts.select { |act| act.includes_location?(location: location) }
242
+ end
243
+
164
244
  # If we need to, trim the list.
165
245
  acts = acts.take(limit) unless limit.nil?
166
246
 
167
247
  acts.map(&:display_text)
168
248
  end
169
249
 
170
- # Find data points for graphing a given friend's relationship over time.
171
- # @param name [String] the name of the friend to use
172
- # @return [Hash] with the following format:
250
+ # List all location names in the friends file.
251
+ # @return [Array] a list of all location names
252
+ def list_locations
253
+ @locations.map(&:name)
254
+ end
255
+
256
+ # Find data points for graphing activities over time.
257
+ # Optionally filter by a friend to see a given relationship over time.
258
+ #
259
+ # The returned hash uses the following format:
173
260
  # {
174
- # "Jan 2015" => 3, # The month and number of activities with that
175
- # "Feb 2015" => 0, # friend during that month.
261
+ # "Jan 2015" => 3, # The number of activities during each month.
262
+ # "Feb 2015" => 0,
176
263
  # "Mar 2015" => 9
177
264
  # }
178
- # The keys of the hash are all of the months (inclusive) between the first
179
- # and last month in which activities for the given friend have been
180
- # recorded.
181
- # @raise [FriendsError] if 0 of 2+ friends match the given name
265
+ # The keys of the hash are all of the months (inclusive) between the first
266
+ # and last month in which activities have been recorded.
267
+ #
268
+ # @param name [String] the name of the friend to use
269
+ # @return [Hash{String => Fixnum}]
270
+ # @raise [FriendsError] if 0 or 2+ friends match the given name
182
271
  def graph(name: nil)
183
272
  if name
184
273
  friend = friend_with_name_in(name) # Find the friend by name.
@@ -204,17 +293,27 @@ module Friends
204
293
  act_table
205
294
  end
206
295
 
207
- # @return [Hash] of the format:
296
+ # Suggest friends to do something with.
297
+ #
298
+ # The returned hash uses the following format:
208
299
  # {
209
300
  # distant: ["Distant Friend 1 Name", "Distant Friend 2 Name", ...],
210
301
  # moderate: ["Moderate Friend 1 Name", "Moderate Friend 2 Name", ...],
211
302
  # close: ["Close Friend 1 Name", "Close Friend 2 Name", ...]
212
303
  # }
213
- def suggest
304
+ #
305
+ # @param location_name [String] the name of a location to filter by, or nil
306
+ # for unfiltered
307
+ # @return [Hash{String => Array<String>}]
308
+ def suggest(location_name:)
214
309
  set_n_activities! # Set n_activities for all friends.
215
310
 
311
+ # Filter our friends by location if necessary.
312
+ fs = @friends
313
+ fs = fs.select { |f| f.location_name == location_name } if location_name
314
+
216
315
  # Sort our friends, with the least favorite friend first.
217
- sorted_friends = @friends.sort_by(&:n_activities)
316
+ sorted_friends = fs.sort_by(&:n_activities)
218
317
 
219
318
  output = Hash.new { |h, k| h[k] = [] }
220
319
 
@@ -255,9 +354,17 @@ module Friends
255
354
  end
256
355
  end
257
356
 
258
- # @return [Hash] of the form { /regex/ => [list of friends matching regex] }
259
- # This hash is sorted (because Ruby's hashes are ordered) by decreasing
260
- # regex key length, so the key /Jacob Evelyn/ appears before /Jacob/.
357
+ # Get a regex friend map.
358
+ #
359
+ # The returned hash uses the following format:
360
+ # {
361
+ # /regex/ => [list of friends matching regex]
362
+ # }
363
+ #
364
+ # This hash is sorted (because Ruby's hashes are ordered) by decreasing
365
+ # regex key length, so the key /Jacob Evelyn/ appears before /Jacob/.
366
+ #
367
+ # @return [Hash{Regexp => Array<Friends::Friend>}]
261
368
  def regex_friend_map
262
369
  @friends.each_with_object(Hash.new { |h, k| h[k] = [] }) do |friend, hash|
263
370
  friend.regexes_for_name.each do |regex|
@@ -266,6 +373,23 @@ module Friends
266
373
  end.sort_by { |k, _| -k.to_s.size }.to_h
267
374
  end
268
375
 
376
+ # Get a regex location map.
377
+ #
378
+ # The returned hash uses the following format:
379
+ # {
380
+ # /regex/ => [list of friends matching regex]
381
+ # }
382
+ #
383
+ # This hash is sorted (because Ruby's hashes are ordered) by decreasing
384
+ # regex key length, so the key /Paris, France/ appears before /Paris/.
385
+ #
386
+ # @return [Hash{Regexp => Array<Friends::Location>}]
387
+ def regex_location_map
388
+ @locations.each_with_object({}) do |location, hash|
389
+ hash[location.regex_for_name] = location
390
+ end.sort_by { |k, _| -k.to_s.size }.to_h
391
+ end
392
+
269
393
  # Sets the likelihood_score field on each friend in `possible_matches`. This
270
394
  # score represents how likely it is that an activity containing the friends
271
395
  # in `matches` and containing a friend from each group in `possible_matches`
@@ -285,9 +409,9 @@ module Friends
285
409
  combination(2).
286
410
  reject do |friend1, friend2|
287
411
  (matches & [friend1, friend2]).size == 2 ||
288
- possible_matches.any? do |group|
289
- (group & [friend1, friend2]).size == 2
290
- end
412
+ possible_matches.any? do |group|
413
+ (group & [friend1, friend2]).size == 2
414
+ end
291
415
  end
292
416
 
293
417
  @activities.each do |activity|
@@ -326,44 +450,65 @@ module Friends
326
450
  def read_file
327
451
  @friends = []
328
452
  @activities = []
453
+ @locations = []
329
454
 
330
455
  return unless File.exist?(@filename)
331
456
 
332
- state = :initial
333
- line_num = 0
457
+ state = :unknown
334
458
 
335
459
  # Loop through all lines in the file and process them.
336
- File.foreach(@filename) do |line|
337
- line_num += 1
460
+ File.foreach(@filename).with_index(1) do |line, line_num|
338
461
  line.chomp! # Remove trailing newline from each line.
339
462
 
340
- case state
341
- when :initial
342
- bad_line(ACTIVITIES_HEADER, line_num) unless line == ACTIVITIES_HEADER
463
+ # Parse the line and update the parsing state.
464
+ state = parse_line!(line, line_num: line_num, state: state)
465
+ end
466
+ end
343
467
 
344
- state = :reading_activities
345
- when :reading_friends
346
- begin
347
- @friends << Friend.deserialize(line)
348
- rescue FriendsError => e
349
- bad_line(e, line_num)
350
- end
351
- when :done_reading_activities
352
- state = :reading_friends if line == FRIENDS_HEADER
353
- when :reading_activities
354
- if line == ""
355
- state = :done_reading_activities
356
- else
357
- begin
358
- @activities << Activity.deserialize(line)
359
- rescue FriendsError => e
360
- bad_line(e, line_num)
361
- end
468
+ # Parse the given line, adding to the various internal data structures as
469
+ # necessary.
470
+ # @param line [String]
471
+ # @param line_num [Integer] the 1-indexed file line number we're parsing
472
+ # @param state [Symbol] the state of the parsing, one of:
473
+ # [:unknown, :reading_activities, :reading_friends, :reading_locations]
474
+ # @return [Symbol] the updated state after parsing the given line
475
+ def parse_line!(line, line_num:, state:)
476
+ return :unknown if line == ""
477
+
478
+ # If we're in an unknown state, look for a header to tell us what we're
479
+ # parsing next.
480
+ if state == :unknown
481
+ PARSING_STAGES.each do |stage|
482
+ if line == self.class.const_get("#{stage.id.to_s.upcase}_HEADER")
483
+ return "reading_#{stage.id}".to_sym
362
484
  end
363
485
  end
486
+
487
+ # If we made it here, we couldn't recognize a header.
488
+ bad_line("Couldn't parse line.", line_num)
364
489
  end
490
+
491
+ # If we made it this far, we're parsing objects in a class.
492
+ stage = PARSING_STAGES.find { |s| state == "reading_#{s.id}".to_sym }
493
+
494
+ begin
495
+ instance_variable_get("@#{stage.id}") << stage.klass.deserialize(line)
496
+ rescue FriendsError => e
497
+ bad_line(e, line_num)
498
+ end
499
+
500
+ state
365
501
  end
366
502
 
503
+ # Used internally by the parse_line! method above to associate stages with
504
+ # the class to create.
505
+ ParsingStage = Struct.new(:id, :klass)
506
+ PARSING_STAGES = [
507
+ ParsingStage.new(:activities, Activity),
508
+ ParsingStage.new(:friends, Friend),
509
+ ParsingStage.new(:locations, Location)
510
+ ].freeze
511
+
367
512
  # @param name [String] the name of the friend to search for
368
513
  # @return [Friend] the friend whose name exactly matches the argument
369
514
  # @raise [FriendsError] if more than one friend has the given name
@@ -379,9 +524,10 @@ module Friends
379
524
 
380
525
  # @param text [String] the name (or substring) of the friend to search for
381
526
  # @return [Friend] the friend that matches
382
- # @raise [FriendsError] if 0 of 2+ friends match the given text
527
+ # @raise [FriendsError] if 0 or 2+ friends match the given text
383
528
  def friend_with_name_in(text)
384
- friends = friends_with_name_in(text)
529
+ regex = Regexp.new(text, Regexp::IGNORECASE)
530
+ friends = @friends.select { |friend| friend.name.match(regex) }
385
531
 
386
532
  case friends.size
387
533
  when 1
@@ -395,11 +541,23 @@ module Friends
395
541
  end
396
542
  end
397
543
 
398
- # @param text [String] the name (or substring) of the friends to search for
399
- # @return [Array] a list of all friends that match the given text
400
- def friends_with_name_in(text)
544
+ # @param text [String] the name (or substring) of the location to search for
545
+ # @return [Location] the location that matches
546
+ # @raise [FriendsError] if 0 or 2+ location match the given text
547
+ def location_with_name_in(text)
401
548
  regex = Regexp.new(text, Regexp::IGNORECASE)
402
- @friends.select { |friend| friend.name.match(regex) }
549
+ locations = @locations.select { |location| location.name.match(regex) }
550
+
551
+ case locations.size
552
+ when 1
553
+ # If exactly one location matches, use that location.
554
+ return locations.first
555
+ when 0 then raise FriendsError, "No location found for \"#{text}\""
556
+ else
557
+ raise FriendsError,
558
+ "More than one location found for \"#{text}\": "\
559
+ "#{locations.map(&:name).join(', ')}"
560
+ end
403
561
  end
404
562
 
405
563
  # Raise an error that a line in the friends file is malformed.