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:".freeze
NOTES_HEADER =
"### Notes:".freeze
FRIENDS_HEADER =
"### Friends:".freeze
LOCATIONS_HEADER =
"### Locations:".freeze

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
# 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)

    @activities.unshift(activity)

    @output << "Activity added: \"#{activity}\""
  end
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


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

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


203
204
205
206
207
208
209
210
# File 'lib/friends/introvert.rb', line 203

def add_nickname(name:, nickname:)
  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


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

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


216
217
218
219
220
221
222
223
# File 'lib/friends/introvert.rb', line 216

def add_tag(name:, tag:)
  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.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


325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
# File 'lib/friends/introvert.rb', line 325

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.


286
287
288
# File 'lib/friends/introvert.rb', line 286

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

#list_favorite_friendsObject

List your favorite friends.


276
277
278
# File 'lib/friends/introvert.rb', line 276

def list_favorite_friends
  list_favorite_things(:friend)
end

#list_favorite_locationsObject

List your favorite friends.


281
282
283
# File 'lib/friends/introvert.rb', line 281

def list_favorite_locations
  list_favorite_things(:location)
end

#list_friends(location_name:, tagged:, verbose:) ⇒ 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


256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/friends/introvert.rb', line 256

def list_friends(location_name:, tagged:, verbose:)
  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

  (verbose ? fs.map(&:to_s) : fs.map(&:name)).each { |line| @output << line }
end

#list_locationsObject

List all location names in the friends file.


296
297
298
# File 'lib/friends/introvert.rb', line 296

def list_locations
  @locations.each { |location| @output << location.name }
end

#list_notes(**args) ⇒ Object

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


291
292
293
# File 'lib/friends/introvert.rb', line 291

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.


303
304
305
# File 'lib/friends/introvert.rb', line 303

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:


416
417
418
419
420
421
422
# File 'lib/friends/introvert.rb', line 416

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 => Array<Friends::Location>}

Get a regex location 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 /Paris, France/ appears before /Paris/.

Returns:


435
436
437
438
439
# File 'lib/friends/introvert.rb', line 435

def regex_location_map
  @locations.each_with_object({}) do |location, hash|
    hash[location.regex_for_name] = location
  end.sort_by { |k, _| -k.to_s.size }.to_h
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


242
243
244
245
246
247
# File 'lib/friends/introvert.rb', line 242

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


230
231
232
233
234
235
# File 'lib/friends/introvert.rb', line 230

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


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

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


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

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”


455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
# File 'lib/friends/introvert.rb', line 455

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


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

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


476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
# File 'lib/friends/introvert.rb', line 476

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


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
# File 'lib/friends/introvert.rb', line 372

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