class Friends::Introvert

Constants

ACTIVITIES_HEADER
FRIENDS_HEADER
LOCATIONS_HEADER
NA_STR
NOTES_HEADER
PARSING_STAGES
ParsingStage

Used internally by the parse_line! method above to associate stages with the class to create.

Attributes

output[R]

Public Class Methods

new(filename:) click to toggle source

@param filename [String] the name of the friends Markdown file

# 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

Public Instance Methods

add_activity(serialization:) click to toggle source

Add an activity. @param serialization [String] the serialized activity

# 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:) click to toggle source

Add an alias to an existing location. @param name [String] the name of the location @param nickname [String] the alias to add to the location @raise [FriendsError] if 0 or 2+ locations match the given name @raise [FriendsError] if the alias is already taken

# 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:) click to toggle source

Add a friend. @param name [String] the name of the friend to add @raise [FriendsError] when a friend with that name is already in the file

# 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:) click to toggle source

Add a location. @param name [String] the serialized location @raise [FriendsError] if a location with that name already exists

# 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:) click to toggle source

Add a nickname to an existing friend. @param name [String] the name of the friend @param nickname [String] the nickname to add to the friend @raise [FriendsError] if 0 or 2+ friends match the given name

# 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:) click to toggle source

Add a note. @param serialization [String] the serialized note

# 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:) click to toggle source

Add a tag to an existing friend. @param name [String] the name of the friend @param tag [String] the tag to add to the friend, of the form: “@tag” @raise [FriendsError] if 0 or 2+ friends match the given name

# 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:) click to toggle source

Write out the friends file with cleaned/sorted data. @param clean_command [Boolean] true iff the command the user

executed is `friends clean`; false if this is called as the
result of another command
# 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:) click to toggle source

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.

@param with [Array<String>] the names of friends to filter by, or empty for

unfiltered

@param location_name [String] the name of a location to filter by, or

nil for unfiltered

@param tagged [Array<String>] the names of tags to filter by, or empty for

unfiltered

@param since_date [Date] a date on or after which to find activities, or nil for unfiltered @param until_date [Date] a date before or on which to find activities, or nil for unfiltered @param unscaled [Boolean] true iff we should show the absolute size of bars in the graph

rather than a scaled version

@raise [FriendsError] if friend, location or tag cannot be found or

is ambiguous
# 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) click to toggle source

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

# 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:) click to toggle source

List all friend names in the friends file. @param location_name [String] the name of a location to filter by, or nil

for unfiltered

@param tagged [Array<String>] the names of tags to filter by, or empty for

unfiltered

@param verbose [Boolean] true iff we should output friend names with

nicknames, locations, and tags; false for names only

@param sort [String] one of:

["alphabetical", "n-activities", "recency"]

@param reverse [Boolean] true iff we should reverse the sorted order of

our output
# 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:) click to toggle source

List all location names in the friends file. @param verbose [Boolean] true iff we should output location names with

aliases; false for names only

@param sort [String] one of:

["alphabetical", "n-activities", "recency"]

@param reverse [Boolean] true iff we should reverse the sorted order of

our output
# 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) click to toggle source

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

# File lib/friends/introvert.rb, line 332
def list_notes(**args)
  list_events(events: @notes, **args)
end
list_tags(from:) click to toggle source

@param from [Array] containing any of: [“activities”, “friends”, “notes”]

If not empty, limits the tags returned to only those from either
activities, notes, or friends.
# File lib/friends/introvert.rb, line 350
def list_tags(from:)
  tags(from: from).sort_by(&:downcase).each { |tag| @output << tag }
end
regex_friend_map() click to toggle source

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/.

@return [Hash{Regexp => Array<Friends::Friend>}]

# 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_map() click to toggle source

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/.

@return [Hash{Regexp => location}]

# 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:) click to toggle source

Remove an alias from an existing location. @param name [String] the name of the location @param nickname [String] the alias to remove from the location @raise [FriendsError] if 0 or 2+ locations match the given name @raise [FriendsError] if the location does not have the given alias

# 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:) click to toggle source

Remove a nickname from an existing friend. @param name [String] the name of the friend @param nickname [String] the nickname to remove from the friend @raise [FriendsError] if 0 or 2+ friends match the given name @raise [FriendsError] if the friend does not have the given nickname

# 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:) click to toggle source

Remove a tag from an existing friend. @param name [String] the name of the friend @param tag [String] the tag to remove from the friend, of the form: “@tag” @raise [FriendsError] if 0 or 2+ friends match the given name @raise [FriendsError] if the friend does not have the given nickname

# 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:) click to toggle source

Rename an existing friend. @param old_name [String] the name of the friend @param new_name [String] the new name of the friend @raise [FriendsError] if 0 or 2+ friends match the given name

# 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:) click to toggle source

Rename an existing location. @param old_name [String] the name of the location @param new_name [String] the new name of the location @raise [FriendsError] if 0 or 2+ friends match the given name

# 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:) click to toggle source

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. @param matches [Array<Friend>] the friends in a specific activity @param 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"
# 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:) click to toggle source

Set a friend's location. @param name [String] the friend's name @param location_name [String] the name of an existing location @raise [FriendsError] if 0 or 2+ friends match the given name @raise [FriendsError] if 0 or 2+ locations match the given location name

# 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
stats() click to toggle source
# 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:) click to toggle source

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", ...]
}

@param location_name [String] the name of a location to filter by, or nil

for unfiltered
# 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

Private Instance Methods

bad_line(expected, line_num) click to toggle source

Raise an error that a line in the friends file is malformed. @param expected [String] the expected contents of the line @param line_num [Integer] the line number @raise [FriendsError] with a constructed message

# File lib/friends/introvert.rb, line 831
def bad_line(expected, line_num)
  raise FriendsError, "Expected \"#{expected}\" on line #{line_num}"
end
default_location_output(activity) click to toggle source

@param [Activity] the activity that was added by the user @return [String] specifying default location and its time range

# File lib/friends/introvert.rb, line 837
def default_location_output(activity)
  str = "Default location"

  earlier_activities, later_activities = @activities.partition { |a| a.date <= activity.date }

  earlier_activity_with_default_location = activity

  earlier_activities.each do |a|
    next unless a.default_location

    break unless a.default_location == activity.default_location

    earlier_activity_with_default_location = a
  end

  unless later_activities.empty?
    str += " from #{Paint[earlier_activity_with_default_location.date, :bold]}"

    later_activity = later_activities.find do |a|
      a.default_location && a.default_location != activity.default_location
    end

    str += " to #{Paint[later_activity&.date || 'present', :bold]}"
  end

  str += " already" if earlier_activity_with_default_location != activity

  "#{str} set to: \"#{activity.default_location}\""
end
filtered_events(events:, with:, location_name:, tagged:, since_date:, until_date:) click to toggle source

Filter activities by friend, location and tag @param events [Array<Event>] the base events to list, either @activities or @notes @param with [Array<String>] the names of friends to filter by, or empty for

unfiltered

@param location_name [String] the name of a location to filter by, or

nil for unfiltered

@param tagged [Array<String>] the names of tags to filter by, or empty for

unfiltered

@param since_date [Date] a date on or after which to find activities, or nil for unfiltered @param until_date [Date] a date before or on which to find activities, or nil for unfiltered @return [Array] an array of activities or notes @raise [FriendsError] if friend, location or tag cannot be found or

is ambiguous
# File lib/friends/introvert.rb, line 655
def filtered_events(events:, with:, location_name:, tagged:, since_date:, until_date:)
  # Filter by friend name if argument is passed.
  unless with.empty?
    friends = with.map { |name| thing_with_name_in(:friend, name) }
    events = events.select do |event|
      friends.all? { |friend| event.includes_friend?(friend) }
    end
  end

  # Filter by location name if argument is passed.
  unless location_name.nil?
    location = thing_with_name_in(:location, location_name)
    events = events.select { |event| event.includes_location?(location) }
  end

  # Filter by tag if argument is passed.
  unless tagged.empty?
    events = events.select do |event|
      tagged.all? { |tag| event.includes_tag?(tag) }
    end
  end

  # Filter by date if arguments are passed.
  events = events.select { |event| event.date >= since_date } unless since_date.nil?
  events = events.select { |event| event.date <= until_date } unless until_date.nil?

  events
end
list_events(events:, with:, location_name:, tagged:, since_date:, until_date:) click to toggle source

List all event details. @param events [Array<Event>] the base events to list, either @activities or @notes @param with [Array<String>] the names of friends to filter by, or empty for

unfiltered

@param location_name [String] the name of a location to filter by, or

nil for unfiltered

@param tagged [Array<String>] the names of tags to filter by, or empty for

unfiltered

@param since_date [Date] a date on or after which to find events, or nil for unfiltered @param until_date [Date] a date before or on which to find events, or nil for unfiltered @raise [FriendsError] if friend, location or tag cannot be found or

is ambiguous
# File lib/friends/introvert.rb, line 616
def list_events(events:, with:, location_name:, tagged:, since_date:, until_date:)
  events = filtered_events(
    events: events,
    with: with,
    location_name: location_name,
    tagged: tagged,
    since_date: since_date,
    until_date: until_date
  )

  events.each { |event| @output << event.to_s }
end
list_things(type:, arr: instance_variable_get("@ click to toggle source

List either friends or activities @param arr [Array<Friend|Activity>] a filtered list to print @param verbose [Boolean] true iff we should output names with

aliases/nicknames/etc.; false for names only

@param sort [String] one of:

["alphabetical", "n-activities", "recency"]

@param reverse [Boolean] true iff we should reverse the sorted order of

our output
# File lib/friends/introvert.rb, line 551
def list_things(type:, arr: instance_variable_get("@#{type}s"), verbose:, sort:, reverse:)
  case sort
  when "alphabetical"
    arr = stable_sort(arr) # In case the input file was not already sorted.
  when "n-activities"
    arr = stable_sort_by(arr) { |thing| -thing.n_activities }
  when "recency"
    today = Date.today

    most_recent_activity_by_thing = @activities.each_with_object({}) do |activity, output|
      activity.send("#{type}_names").each do |thing_name|
        output[thing_name] = (today - activity.date).to_i unless output.key?(thing_name)
      end
    end

    arr = stable_sort_by(arr) do |thing|
      most_recent_activity_by_thing[thing.name] || -Float::INFINITY
    end
  end

  (reverse ? arr.reverse : arr).each do |thing|
    case sort
    when "n-activities"
      prefix = "#{Paint[thing.n_activities, :bold, :red]} "\
               "activit#{thing.n_activities == 1 ? 'y' : 'ies'}: "
    when "recency"
      n_days = most_recent_activity_by_thing[thing.name] || NA_STR
      prefix = "#{Paint[n_days, :bold, :red]} "\
               "day#{'s' unless n_days == 1} ago: "
    end

    @output << "#{prefix}#{verbose ? thing.to_s : thing.name}"
  end
end
parse_line!(line, line_num:, state:) click to toggle source

Parse the given line, adding to the various internal data structures as necessary. @param line [String] @param line_num [Integer] the 1-indexed file line number we're parsing @param state [Symbol] the state of the parsing, one of:

[:unknown, :reading_activities, :reading_friends, :reading_locations]

@return [Symbol] the updated state after parsing the given line

# File lib/friends/introvert.rb, line 759
def parse_line!(line, line_num:, state:)
  return :unknown if line == ""

  # If we're in an unknown state, look for a header to tell us what we're
  # parsing next.
  if state == :unknown
    PARSING_STAGES.each do |stage|
      if line == self.class.const_get("#{stage.id.to_s.upcase}_HEADER")
        return "reading_#{stage.id}".to_sym
      end
    end

    # If we made it here, we couldn't recognize a header.
    bad_line("a valid header", line_num)
  end

  # If we made it this far, we're parsing objects in a class.
  stage = PARSING_STAGES.find { |s| state == "reading_#{s.id}".to_sym }

  begin
    instance_variable_get("@#{stage.id}") << stage.klass.deserialize(line)
  rescue StandardError => e
    bad_line(e, line_num)
  end

  state
end
read_file() click to toggle source

Process the friends.md file and store its contents in internal data structures.

# File lib/friends/introvert.rb, line 726
def read_file
  @friends = []
  @activities = []
  @notes = []
  @locations = []

  return unless File.exist?(@expanded_filename)

  state = :unknown

  # Loop through all lines in the file and process them.
  File.foreach(@expanded_filename).with_index(1) do |line, line_num|
    line.chomp! # Remove trailing newline from each line.

    # Parse the line and update the parsing state.
    state = parse_line!(line, line_num: line_num, state: state)
  end
  # sort the activities from earliest to latest, in case friends.md has been corrupted
  @activities = stable_sort(@activities)

  set_implicit_locations!

  set_n_activities!(:friend)
  set_n_activities!(:location)
end
set_implicit_locations!() click to toggle source
# File lib/friends/introvert.rb, line 715
def set_implicit_locations!
  implicit_location = nil
  # reverse_each here moves through the activities in chronological order
  @activities.reverse_each do |activity|
    implicit_location = activity.default_location if activity.default_location
    activity.implicit_location = implicit_location if activity.description_location_names.empty?
  end
end
set_n_activities!(type) click to toggle source

Sets the n_activities field on each thing. @param type [Symbol] one of: [:friend, :location] @raise [ArgumentError] if `type` is not one of: [:friend, :location]

# File lib/friends/introvert.rb, line 687
def set_n_activities!(type)
  unless [:friend, :location].include? type
    raise ArgumentError, "Type must be either :friend or :location"
  end

  # Construct a hash of location name to frequency of appearance.
  freq_table = Hash.new { |h, k| h[k] = 0 }
  @activities.each do |activity|
    activity.send("#{type}_names").each do |thing_name|
      freq_table[thing_name] += 1
    end
  end

  # Remove names that are not in the locations list.
  freq_table.each do |name, count|
    things = instance_variable_get("@#{type}s").select do |thing|
      thing.name == name
    end

    # Do nothing if no matches found.
    if things.size == 1
      things.first.n_activities = count
    elsif things.size > 1
      raise FriendsError, "More than one #{type} named \"#{name}\""
    end
  end
end
stable_sort(arr) click to toggle source

@param arr [Array] an unsorted array @return [Array] a stably-sorted array

# File lib/friends/introvert.rb, line 631
def stable_sort(arr)
  arr.sort_by.with_index { |x, idx| [x, idx] }
end
stable_sort_by(arr) { |x| ... } click to toggle source

@param arr [Array] an unsorted array @param &block [block] used to return a value for each element's sort position @return [Array] a stably-sorted array

# File lib/friends/introvert.rb, line 638
def stable_sort_by(arr)
  arr.sort_by.with_index { |x, idx| [yield(x), idx] }
end
tags(from: []) click to toggle source

@param from [Array] containing any of: [“activities”, “friends”, “notes”]

If not empty, limits the tags returned to only those from either
activities, notes, or friends.

@return [Set] the set of tags present in the given things

# File lib/friends/introvert.rb, line 590
def tags(from: [])
  Set.new.tap do |output|
    if from.empty? || from.include?("activities")
      @activities.each { |activity| output.merge(activity.tags) }
    end

    @notes.each { |note| output.merge(note.tags) } if from.empty? || from.include?("notes")

    if from.empty? || from.include?("friends")
      @friends.each { |friend| output.merge(friend.tags) }
    end
  end
end
thing_with_name_in(type, text) click to toggle source

@param type [Symbol] one of: [:friend, :location] @param text [String] the name (or substring) of the friend or location to

search for

@return [Friend/Location] the friend or location that matches @raise [FriendsError] if 0 or 2+ friends match the given text

# File lib/friends/introvert.rb, line 802
def thing_with_name_in(type, text)
  things = instance_variable_get("@#{type}s").select do |thing|
    thing.regexes_for_name.any? { |regex| regex.match(text) }
  end

  # If there's more than one match with fuzzy regexes but exactly one thing
  # with that exact name, match it.
  if things.size > 1
    exact_things = things.select do |thing|
      thing.name.casecmp(text).zero? # We ignore case for an "exact" match.
    end

    things = exact_things if exact_things.size == 1
  end

  case things.size
  when 1 then things.first # If exactly one thing matches, use that thing.
  when 0 then raise FriendsError, "No #{type} found for \"#{text}\""
  else
    raise FriendsError,
          "More than one #{type} found for \"#{text}\": "\
            "#{things.map(&:name).join(', ')}"
  end
end