friends 0.16 → 0.17

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