Class: Friends::Introvert

Inherits:
Object
  • Object
show all
Defined in:
lib/friends/introvert.rb

Defined Under Namespace

Classes: ParsingStage

Constant Summary collapse

ACTIVITIES_HEADER =
"### Activities:"
NOTES_HEADER =
"### Notes:"
FRIENDS_HEADER =
"### Friends:"
LOCATIONS_HEADER =
"### Locations:"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(filename:) ⇒ Introvert

Returns a new instance of Introvert.

Parameters:

  • filename (String)

    the name of the friends Markdown file



25
26
27
28
29
30
31
32
33
# File 'lib/friends/introvert.rb', line 25

def initialize(filename:)
  @user_facing_filename = filename
  @expanded_filename = File.expand_path(filename)
  @output = []

  # Read in the input file. It's easier to do this now and optimize later
  # than try to overly be clever about what we read and write.
  read_file
end

Instance Attribute Details

#outputObject (readonly)

Returns the value of attribute output.



35
36
37
# File 'lib/friends/introvert.rb', line 35

def output
  @output
end

Instance Method Details

#add_activity(serialization:) ⇒ Object

Add an activity.

Parameters:

  • serialization (String)

    the serialized activity



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/friends/introvert.rb', line 99

def add_activity(serialization:)
  Activity.deserialize(serialization).tap do |activity|
    # If there's no description, prompt the user for one.
    if activity.description.nil? || activity.description.empty?
      activity.description = Readline.readline(activity.to_s).to_s.strip

      raise FriendsError, "Blank activity not added" if activity.description.empty?
    end

    activity.highlight_description(introvert: self)

    @output << "Activity added: \"#{activity}\""

    @output << default_location_output(activity) if activity.default_location

    @activities.unshift(activity)
  end
end

#add_alias(name:, nickname:) ⇒ Object

Add an alias to an existing location.

Parameters:

  • name (String)

    the name of the location

  • nickname (String)

    the alias to add to the location

Raises:



220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/friends/introvert.rb', line 220

def add_alias(name:, nickname:)
  raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty?
  raise FriendsError, "Alias cannot be blank" if nickname.empty?

  collision = @locations.find do |loc|
    loc.name.casecmp(nickname).zero? || loc.aliases.any? { |a| a.casecmp(nickname).zero? }
  end

  if collision
    raise FriendsError,
          "The location alias \"#{nickname}\" is already taken by "\
          "\"#{collision}\""
  end

  location = thing_with_name_in(:location, name)
  location.add_alias(nickname)

  @output << "Alias added: \"#{location}\""
end

#add_friend(name:) ⇒ Object

Add a friend.

Parameters:

  • name (String)

    the name of the friend to add

Raises:

  • (FriendsError)

    when a friend with that name is already in the file



85
86
87
88
89
90
91
92
93
94
95
# File 'lib/friends/introvert.rb', line 85

def add_friend(name:)
  if @friends.any? { |friend| friend.name == name }
    raise FriendsError, "Friend named \"#{name}\" already exists"
  end

  friend = Friend.deserialize(name)

  @friends << friend

  @output << "Friend added: \"#{friend.name}\""
end

#add_location(name:) ⇒ Object

Add a location.

Parameters:

  • name (String)

    the serialized location

Raises:

  • (FriendsError)

    if a location with that name already exists



140
141
142
143
144
145
146
147
148
149
150
# File 'lib/friends/introvert.rb', line 140

def add_location(name:)
  if @locations.any? { |location| location.name == name }
    raise FriendsError, "Location \"#{name}\" already exists"
  end

  location = Location.deserialize(name)

  @locations << location

  @output << "Location added: \"#{location.name}\"" # Return the added location.
end

#add_nickname(name:, nickname:) ⇒ Object

Add a nickname to an existing friend.

Parameters:

  • name (String)

    the name of the friend

  • nickname (String)

    the nickname to add to the friend

Raises:

  • (FriendsError)

    if 0 or 2+ friends match the given name



205
206
207
208
209
210
211
212
213
# File 'lib/friends/introvert.rb', line 205

def add_nickname(name:, nickname:)
  raise FriendsError, "Expected \"[Friend Name]\" \"[Nickname]\"" if name.empty?
  raise FriendsError, "Nickname cannot be blank" if nickname.empty?

  friend = thing_with_name_in(:friend, name)
  friend.add_nickname(nickname)

  @output << "Nickname added: \"#{friend}\""
end

#add_note(serialization:) ⇒ Object

Add a note.

Parameters:

  • serialization (String)

    the serialized note



120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/friends/introvert.rb', line 120

def add_note(serialization:)
  Note.deserialize(serialization).tap do |note|
    # If there's no description, prompt the user for one.
    if note.description.nil? || note.description.empty?
      note.description = Readline.readline(note.to_s).to_s.strip

      raise FriendsError, "Blank note not added" if note.description.empty?
    end

    note.highlight_description(introvert: self)

    @notes.unshift(note)

    @output << "Note added: \"#{note}\""
  end
end

#add_tag(name:, tag:) ⇒ Object

Add a tag to an existing friend.

Parameters:

  • name (String)

    the name of the friend

  • tag (String)

    the tag to add to the friend, of the form: “@tag”

Raises:

  • (FriendsError)

    if 0 or 2+ friends match the given name



244
245
246
247
248
249
250
251
252
# File 'lib/friends/introvert.rb', line 244

def add_tag(name:, tag:)
  raise FriendsError, "Expected \"[Friend Name]\" \"[Tag]\"" if name.empty?
  raise FriendsError, "Tag cannot be blank" if tag == "@"

  friend = thing_with_name_in(:friend, name)
  friend.add_tag(tag)

  @output << "Tag added to friend: \"#{friend}\""
end

#clean(clean_command:) ⇒ Object

Write out the friends file with cleaned/sorted data.

Parameters:

  • clean_command (Boolean)

    true iff the command the user executed is ‘friends clean`; false if this is called as the result of another command



41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/friends/introvert.rb', line 41

def clean(clean_command:)
  friend_names = Set.new(@friends.map(&:name))
  location_names = Set.new(@locations.map(&:name))

  # Iterate through all events and add missing friends and
  # locations.
  (@activities + @notes).each do |event|
    event.friend_names.each do |name|
      unless friend_names.include? name
        add_friend(name: name)
        friend_names << name
      end
    end

    event.description_location_names.each do |name|
      unless location_names.include? name
        add_location(name: name)
        location_names << name
      end
    end
  end

  File.open(@expanded_filename, "w") do |file|
    file.puts(ACTIVITIES_HEADER)
    stable_sort(@activities).each { |act| file.puts(act.serialize) }
    file.puts # Blank line separating activities from notes.
    file.puts(NOTES_HEADER)
    stable_sort(@notes).each { |note| file.puts(note.serialize) }
    file.puts # Blank line separating notes from friends.
    file.puts(FRIENDS_HEADER)
    @friends.sort.each { |friend| file.puts(friend.serialize) }
    file.puts # Blank line separating friends from locations.
    file.puts(LOCATIONS_HEADER)
    @locations.sort.each { |location| file.puts(location.serialize) }
  end

  # This is a special-case piece of code that lets us print a message that
  # includes the filename when `friends clean` is called.
  @output << "File cleaned: \"#{@user_facing_filename}\"" if clean_command
end

#graph(with:, location_name:, tagged:, since_date:, until_date:, unscaled:) ⇒ Object

Graph activities over time. Optionally filter by friend, location and tag

The graph displays all of the months (inclusive) between the first and last month in which activities have been recorded.

Parameters:

  • with (Array<String>)

    the names of friends to filter by, or empty for unfiltered

  • location_name (String)

    the name of a location to filter by, or nil for unfiltered

  • tagged (Array<String>)

    the names of tags to filter by, or empty for unfiltered

  • since_date (Date)

    a date on or after which to find activities, or nil for unfiltered

  • until_date (Date)

    a date before or on which to find activities, or nil for unfiltered

  • unscaled (Boolean)

    true iff we should show the absolute size of bars in the graph rather than a scaled version

Raises:

  • (FriendsError)

    if friend, location or tag cannot be found or is ambiguous



372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# File 'lib/friends/introvert.rb', line 372

def graph(with:, location_name:, tagged:, since_date:, until_date:, unscaled:)
  filtered_activities_to_graph = filtered_events(
    events: @activities,
    with: with,
    location_name: location_name,
    tagged: tagged,
    since_date: since_date,
    until_date: until_date
  )

  # If the user wants to graph in a specific date range, we explicitly
  # limit our output to that date range. We don't just use the date range
  # of the first and last `filtered_activities_to_graph` because those
  # activities might not include others in the full range (for instance,
  # if only one filtered activity matches a query, we don't want to only
  # show unfiltered activities that occurred on that specific day).
  all_activities_to_graph = filtered_events(
    events: @activities,
    with: [],
    location_name: nil,
    tagged: [],

    # By including all activities for the "fencepost" months in our totals,
    # we prevent those months from being always "full" in the graph
    # because all filtered events will match the criteria.
    since_date: (since_date.prev_day(since_date.day - 1) if since_date),
    until_date: (until_date.prev_day(until_date.day - 1).next_month.prev_day if until_date)
  )

  Graph.new(
    filtered_activities: filtered_activities_to_graph,
    all_activities: all_activities_to_graph,
    unscaled: unscaled
  ).output.each { |line| @output << line }
end

#list_activities(**args) ⇒ Object

See ‘list_events` for all of the parameters we can pass.



327
328
329
# File 'lib/friends/introvert.rb', line 327

def list_activities(**args)
  list_events(events: @activities, **args)
end

#list_friends(location_name:, tagged:, verbose:, sort:, reverse:) ⇒ Object

List all friend names in the friends file.

Parameters:

  • location_name (String)

    the name of a location to filter by, or nil for unfiltered

  • tagged (Array<String>)

    the names of tags to filter by, or empty for unfiltered

  • verbose (Boolean)

    true iff we should output friend names with nicknames, locations, and tags; false for names only

  • sort (String)

    one of:

    “alphabetical”, “n-activities”, “recency”
  • reverse (Boolean)

    true iff we should reverse the sorted order of our output



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/friends/introvert.rb', line 304

def list_friends(location_name:, tagged:, verbose:, sort:, reverse:)
  fs = @friends

  # Filter by location if a name is passed.
  if location_name
    location = thing_with_name_in(:location, location_name)
    fs = fs.select { |friend| friend.location_name == location.name }
  end

  # Filter by tag if param is passed.
  unless tagged.empty?
    fs = fs.select do |friend|
      tagged.all? { |tag| friend.tags.map(&:downcase).include? tag.downcase }
    end
  end

  list_things(type: :friend, arr: fs, verbose: verbose, sort: sort, reverse: reverse)
end

#list_locations(verbose:, sort:, reverse:) ⇒ Object

List all location names in the friends file.

Parameters:

  • verbose (Boolean)

    true iff we should output location names with aliases; false for names only

  • sort (String)

    one of:

    “alphabetical”, “n-activities”, “recency”
  • reverse (Boolean)

    true iff we should reverse the sorted order of our output



343
344
345
# File 'lib/friends/introvert.rb', line 343

def list_locations(verbose:, sort:, reverse:)
  list_things(type: :location, verbose: verbose, sort: sort, reverse: reverse)
end

#list_notes(**args) ⇒ Object

See ‘list_events` for all of the parameters we can pass.



332
333
334
# File 'lib/friends/introvert.rb', line 332

def list_notes(**args)
  list_events(events: @notes, **args)
end

#list_tags(from:) ⇒ Object

Parameters:

  • from (Array)

    containing any of: [“activities”, “friends”, “notes”] If not empty, limits the tags returned to only those from either activities, notes, or friends.



350
351
352
# File 'lib/friends/introvert.rb', line 350

def list_tags(from:)
  tags(from: from).sort_by(&:downcase).each { |tag| @output << tag }
end

#regex_friend_mapHash{Regexp => Array<Friends::Friend>}

Get a regex friend map.

The returned hash uses the following format:

{
  /regex/ => [list of friends matching regex]
}

This hash is sorted (because Ruby’s hashes are ordered) by decreasing regex key length, so the key /Jacob Evelyn/ appears before /Jacob/.

Returns:



463
464
465
466
467
468
469
# File 'lib/friends/introvert.rb', line 463

def regex_friend_map
  @friends.each_with_object(Hash.new { |h, k| h[k] = [] }) do |friend, hash|
    friend.regexes_for_name.each do |regex|
      hash[regex] << friend
    end
  end.sort_by { |k, _| -k.to_s.size }.to_h
end

#regex_location_mapHash{Regexp => location}

Get a regex location map.

The returned hash uses the following format:

{
  /regex/ => location
}

This hash is sorted (because Ruby’s hashes are ordered) by decreasing regex key length, so the key /Paris, France/ appears before /Paris/.

Returns:

  • (Hash{Regexp => location})


482
483
484
485
486
# File 'lib/friends/introvert.rb', line 482

def regex_location_map
  @locations.each_with_object({}) do |location, hash|
    location.regexes_for_name.each { |regex| hash[regex] = location }
  end.sort_by { |k, _| -k.to_s.size }.to_h
end

#remove_alias(name:, nickname:) ⇒ Object

Remove an alias from an existing location.

Parameters:

  • name (String)

    the name of the location

  • nickname (String)

    the alias to remove from the location

Raises:

  • (FriendsError)

    if 0 or 2+ locations match the given name

  • (FriendsError)

    if the location does not have the given alias



283
284
285
286
287
288
289
290
291
# File 'lib/friends/introvert.rb', line 283

def remove_alias(name:, nickname:)
  raise FriendsError, "Expected \"[Location Name]\" \"[Alias]\"" if name.empty?
  raise FriendsError, "Alias cannot be blank" if nickname.empty?

  location = thing_with_name_in(:location, name)
  location.remove_alias(nickname)

  @output << "Alias removed: \"#{location}\""
end

#remove_nickname(name:, nickname:) ⇒ Object

Remove a nickname from an existing friend.

Parameters:

  • name (String)

    the name of the friend

  • nickname (String)

    the nickname to remove from the friend

Raises:

  • (FriendsError)

    if 0 or 2+ friends match the given name

  • (FriendsError)

    if the friend does not have the given nickname



271
272
273
274
275
276
# File 'lib/friends/introvert.rb', line 271

def remove_nickname(name:, nickname:)
  friend = thing_with_name_in(:friend, name)
  friend.remove_nickname(nickname)

  @output << "Nickname removed: \"#{friend}\""
end

#remove_tag(name:, tag:) ⇒ Object

Remove a tag from an existing friend.

Parameters:

  • name (String)

    the name of the friend

  • tag (String)

    the tag to remove from the friend, of the form: “@tag”

Raises:

  • (FriendsError)

    if 0 or 2+ friends match the given name

  • (FriendsError)

    if the friend does not have the given nickname



259
260
261
262
263
264
# File 'lib/friends/introvert.rb', line 259

def remove_tag(name:, tag:)
  friend = thing_with_name_in(:friend, name)
  friend.remove_tag(tag)

  @output << "Tag removed from friend: \"#{friend}\""
end

#rename_friend(old_name:, new_name:) ⇒ Object

Rename an existing friend.

Parameters:

  • old_name (String)

    the name of the friend

  • new_name (String)

    the new name of the friend

Raises:

  • (FriendsError)

    if 0 or 2+ friends match the given name



169
170
171
172
173
174
175
176
177
# File 'lib/friends/introvert.rb', line 169

def rename_friend(old_name:, new_name:)
  friend = thing_with_name_in(:friend, old_name)
  (@activities + @notes).each do |event|
    event.update_friend_name(old_name: friend.name, new_name: new_name)
  end
  friend.name = new_name

  @output << "Name changed: \"#{friend}\""
end

#rename_location(old_name:, new_name:) ⇒ Object

Rename an existing location.

Parameters:

  • old_name (String)

    the name of the location

  • new_name (String)

    the new name of the location

Raises:

  • (FriendsError)

    if 0 or 2+ friends match the given name



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/friends/introvert.rb', line 183

def rename_location(old_name:, new_name:)
  loc = thing_with_name_in(:location, old_name)

  # Update locations in activities and notes.
  (@activities + @notes).each do |event|
    event.update_location_name(old_name: loc.name, new_name: new_name)
  end

  # Update locations of friends.
  @friends.select { |f| f.location_name == loc.name }.each do |friend|
    friend.location_name = new_name
  end

  loc.name = new_name # Update location itself.

  @output << "Location renamed: \"#{loc.name}\""
end

#set_likelihood_score!(matches:, possible_matches:) ⇒ Object

Sets the likelihood_score field on each friend in ‘possible_matches`. This score represents how likely it is that an activity containing the friends in `matches` and containing a friend from each group in `possible_matches` contains that given friend.

Parameters:

  • matches (Array<Friend>)

    the friends in a specific activity

  • possible_matches (Array<Array<Friend>>)

    an array of groups of possible matches, for example: [

    [Friend.new(name: "John Doe"), Friend.new(name: "John Deere")],
    [Friend.new(name: "Aunt Mae"), Friend.new(name: "Aunt Sue")]
    

    ] These groups will all contain friends with similar names; the purpose of this method is to give us a likelihood that a “John” in an activity description, for instance, is “John Deere” vs. “John Doe”



502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
# File 'lib/friends/introvert.rb', line 502

def set_likelihood_score!(matches:, possible_matches:)
  combinations = (matches + possible_matches.flatten).
                 combination(2).
                 reject do |friend1, friend2|
                   (matches & [friend1, friend2]).size == 2 ||
                     possible_matches.any? do |group|
                       (group & [friend1, friend2]).size == 2
                     end
                 end

  @activities.each do |activity|
    names = activity.friend_names

    combinations.each do |group|
      if (names & group.map(&:name)).size == 2
        group.each { |friend| friend.likelihood_score += 1 }
      end
    end
  end
end

#set_location(name:, location_name:) ⇒ Object

Set a friend’s location.

Parameters:

  • name (String)

    the friend’s name

  • location_name (String)

    the name of an existing location

Raises:

  • (FriendsError)

    if 0 or 2+ friends match the given name

  • (FriendsError)

    if 0 or 2+ locations match the given location name



157
158
159
160
161
162
163
# File 'lib/friends/introvert.rb', line 157

def set_location(name:, location_name:)
  friend = thing_with_name_in(:friend, name)
  location = thing_with_name_in(:location, location_name)
  friend.location_name = location.name

  @output << "#{friend.name}'s location set to: \"#{location.name}\""
end

#statsObject



523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# File 'lib/friends/introvert.rb', line 523

def stats
  events = @activities + @notes

  elapsed_days = if events.size < 2
                   0
                 else
                   sorted_events = events.sort
                   (sorted_events.first.date - sorted_events.last.date).to_i
                 end

  @output << "Total activities: #{@activities.size}"
  @output << "Total friends: #{@friends.size}"
  @output << "Total locations: #{@locations.size}"
  @output << "Total notes: #{@notes.size}"
  @output << "Total tags: #{tags.size}"
  @output << "Total time elapsed: #{elapsed_days} day#{'s' if elapsed_days != 1}"
end

#suggest(location_name:) ⇒ Object

Suggest friends to do something with.

The returned hash uses the following format:

{
  distant: ["Distant Friend 1 Name", "Distant Friend 2 Name", ...],
  moderate: ["Moderate Friend 1 Name", "Moderate Friend 2 Name", ...],
  close: ["Close Friend 1 Name", "Close Friend 2 Name", ...]
}

Parameters:

  • location_name (String)

    the name of a location to filter by, or nil for unfiltered



419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
# File 'lib/friends/introvert.rb', line 419

def suggest(location_name:)
  # Filter our friends by location if necessary.
  fs = @friends
  fs = fs.select { |f| f.location_name == location_name } if location_name

  # Sort our friends, with the least favorite friend first.
  sorted_friends = fs.sort_by(&:n_activities)

  # Set initial value in case there are no friends and the while loop is
  # never entered.
  distant_friend_names = []

  # First, get not-so-good friends.
  while !sorted_friends.empty? && sorted_friends.first.n_activities < 2
    distant_friend_names << sorted_friends.shift.name
  end

  moderate_friend_names = sorted_friends.slice!(0, sorted_friends.size * 3 / 4).
                          map!(&:name)
  close_friend_names = sorted_friends.map!(&:name)

  @output << "Distant friend: "\
             "#{Paint[distant_friend_names.sample || 'None found', :bold, :magenta]}"
  @output << "Moderate friend: "\
             "#{Paint[moderate_friend_names.sample || 'None found', :bold, :magenta]}"
  @output << "Close friend: "\
             "#{Paint[close_friend_names.sample || 'None found', :bold, :magenta]}"
end