class Friends::Event

Constants

DATE_PARTITION
SERIALIZATION_PREFIX

Attributes

date[R]
description[RW]
implicit_location[W]

Public Class Methods

deserialization_regex() click to toggle source

@return [Regexp] the regex for capturing groups in deserialization

# File lib/friends/event.rb, line 21
def self.deserialization_regex
  /(#{SERIALIZATION_PREFIX})?(?<str>.+)?/
end
new(str: "") click to toggle source

@param str [String] the text of the activity, of one of the formats:

"<date>: <description>"
"<date>" (Program will prompt for description.)
"<description>" (The current date will be used by default.)

@return [Activity] the new activity

# File lib/friends/event.rb, line 30
def initialize(str: "")
  # Partition lets us parse "Today" and "Today: I awoke." identically.
  date_s, _, description = str.partition(DATE_PARTITION)

  time = if date_s =~ /^\d{4}-\d{2}-\d{2}$/
           Time.parse(date_s)
         else
           # If the user inputed a non YYYY-MM-DD format, asssume
           # it is in the past.
           past_time = Chronic.parse(date_s, context: :past)

           # If there's no year, Chronic will sometimes parse the date
           # as being the next occurrence of that date in the future.
           # Instead, we want to subtract one year to make it the last
           # occurrence of the date in the past.
           # NOTE: This is a hacky workaround for the fact that
           # Chronic's `context: :past` doesn't actually work. We should
           # remove this when that behavior is fixed.
           if past_time && past_time > Time.now
             Time.local(past_time.year - 1, past_time.month, past_time.day)
           else
             past_time
           end
         end

  if time
    @date = time.to_date
    @description = description
  else
    # If the user didn't input a date, we fall back to the current date.
    @date = Date.today
    @description = str # Use str in case DATE_PARTITION occurred naturally.
  end
end

Public Instance Methods

default_location() click to toggle source
# File lib/friends/event.rb, line 141
def default_location
  @default_location ||= @description[/(?<=to _)\w[^_]*(?=_)/]
end
description_location_names() click to toggle source

Find the names of all locations in this description. @return [Array] list of all location names in the description

# File lib/friends/event.rb, line 170
def description_location_names
  @description.scan(/(?<=_)\w[^_]*(?=_)/).uniq
end
friend_names() click to toggle source

Find the names of all friends in this description. @return [Array] list of all friend names in the description

# File lib/friends/event.rb, line 164
def friend_names
  @description.scan(/(?<=\*\*)\w[^\*]*(?=\*\*)/).uniq
end
highlight_description(introvert:) click to toggle source

Modify the description to turn inputted friend names (e.g. “Jacob” or “Jacob Evelyn”) into full asterisk'd names (e.g. “**Jacob Evelyn**”) and inputted location names (e.g. “Atlantis”) into full underscore'd names (e.g. “Atlantis”). @param introvert [Introvert] used to access internal data structures to

perform object matching
# File lib/friends/event.rb, line 106
def highlight_description(introvert:)
  highlight_locations(introvert: introvert)
  highlight_friends(introvert: introvert)
end
includes_friend?(friend) click to toggle source

@param friend [Friend] the friend to test @return [Boolean] true iff this activity includes the given friend

# File lib/friends/event.rb, line 147
def includes_friend?(friend)
  friend_names.include? friend.name
end
includes_location?(location) click to toggle source

@param location [Location] the location to test @return [Boolean] true if activity has location in description or it equals implicit location

# File lib/friends/event.rb, line 137
def includes_location?(location)
  location_in_description?(location) || location_is_implicit?(location)
end
includes_tag?(tag) click to toggle source

@param tag [String] the tag to test, of the form “@tag” @return [Boolean] true iff this activity includes the given tag

# File lib/friends/event.rb, line 153
def includes_tag?(tag)
  tags.include? tag.downcase
end
location_names() click to toggle source

@return [Array] list of all location names in either description or implicit_location

# File lib/friends/event.rb, line 175
def location_names
  @implicit_location ? [@implicit_location] : description_location_names
end
serialize() click to toggle source

@return [String] the file serialization text for the activity

# File lib/friends/event.rb, line 96
def serialize
  "#{SERIALIZATION_PREFIX}#{date}: #{description}"
end
tags() click to toggle source

@return [Set] all tags in this activity (including the “@”)

# File lib/friends/event.rb, line 158
def tags
  Set.new(@description.scan(TAG_REGEX).map(&:downcase))
end
to_s() click to toggle source

@return [String] the command-line display text for the activity

# File lib/friends/event.rb, line 70
def to_s
  date_s = Paint[date, :bold]
  description_s = description.to_s
  # rubocop:disable Lint/AssignmentInCondition
  while match = description_s.match(/\*\*([^\*]+)\*\*/)
    # rubocop:enable Lint/AssignmentInCondition
    description_s = "#{match.pre_match}"\
                    "#{Paint[match[1], :bold, :magenta]}"\
                    "#{match.post_match}"
  end

  # rubocop:disable Lint/AssignmentInCondition
  while match = description_s.match(/_([^_]+)_/)
    # rubocop:enable Lint/AssignmentInCondition
    description_s = "#{match.pre_match}"\
                    "#{Paint[match[1], :bold, :yellow]}"\
                    "#{match.post_match}"
  end

  description_s = description_s.
                  gsub(TAG_REGEX, Paint['\0', :bold, :cyan])

  "#{date_s}: #{description_s}"
end
update_friend_name(old_name:, new_name:) click to toggle source

Updates a friend's old_name to their new_name @param [String] old_name @param [String] new_name @return [String] if name found in description @return [nil] if no change was made

# File lib/friends/event.rb, line 116
def update_friend_name(old_name:, new_name:)
  @description = @description.gsub(
    Regexp.new("(?<=\\*\\*)#{old_name}(?=\\*\\*)"),
    new_name
  )
end
update_location_name(old_name:, new_name:) click to toggle source

Updates a location's old_name to their new_name @param [String] old_name @param [String] new_name @return [String] if name found in description @return [nil] if no change was made

# File lib/friends/event.rb, line 128
def update_location_name(old_name:, new_name:)
  @description = @description.gsub(
    Regexp.new("(?<=_)#{old_name}(?=_)"),
    new_name
  )
end

Private Instance Methods

<=>(other) click to toggle source

Default sorting for an array of activities is reverse-date.

# File lib/friends/event.rb, line 340
def <=>(other)
  other.date <=> date
end
description_matches(regex:, replace:, indicator:) { || ... } click to toggle source

This method accepts a block, and tests a regex on the @description instance variable.

  • If the regex does not match, the block is not executed.

  • If the regex matches, the block is executed exactly once, and:

    • If `replace` is true, all of the regex's matches are replaced by the return value of the block, EXCEPT when the matched text is between a set of double-asterisks (“**”) or single-underscores (“_”) indicating it is already part of another location or friend's matched name.

    • If `replace` is not true, we do not modify @description.

@param regex [Regexp] the regex to test against @description @param replace [Boolean] true iff we should replace regex matches with the

yielded block's result in @description
# File lib/friends/event.rb, line 302
def description_matches(regex:, replace:, indicator:)
  # rubocop:disable Lint/AssignmentInCondition
  return unless match = @description.match(regex) # Abort if no match.

  # rubocop:enable Lint/AssignmentInCondition

  str = yield # It's important to execute the block even if not replacing.
  return unless replace # Only continue if we want to replace text.

  position = 0 # Prevent infinite looping by tracking last match position.
  loop do
    # Only make a replacement if we're not between a set of "**"s or "_"s.
    if (match.pre_match.scan("**").size % 2).zero? &&
       (match.post_match.scan("**").size % 2).zero? &&
       (match.pre_match.scan("_").size % 2).zero? &&
       (match.post_match.scan("_").size % 2).zero?
      @description = [
        match.pre_match,
        indicator,
        str,
        indicator,
        match.post_match
      ].join
    else
      # If we're between double-asterisks or single-underscores we're
      # already part of a name, so we don't make a substitution. We update
      # `position` to avoid infinite looping.
      position = match.end(0)
    end

    # Exit when there are no more matches.
    # rubocop:disable Lint/AssignmentInCondition
    break unless match = @description.match(regex, position)
    # rubocop:enable Lint/AssignmentInCondition
  end
end
highlight_friends(introvert:) click to toggle source

Modify the description to turn inputted friend names (e.g. “Jacob” or “Jacob Evelyn”) into full asterisk'd names (e.g. “**Jacob Evelyn**”) @param introvert [Introvert] used to access internal data structures to

perform friend matching

NOTE: When a friend name matches more than one friend, this method chooses a friend based on a best-guess algorithm that looks at which friends do activities together and which friends are stronger than others. For more information see the comments below and the introvert#set_likelihood_score! method.

# File lib/friends/event.rb, line 205
def highlight_friends(introvert:)
  ## STEP 1
  # Split the regex friend map into two maps: one for names with only one
  # friend match and another for ambiguous names
  definite_map, ambiguous_map =
    introvert.regex_friend_map.partition { |_, arr| arr.size == 1 }.map(&:to_h)

  ## STEP 2
  matched_friends = []

  # We find all of the unambiguous matches, and make those
  # substitutions.
  definite_map.each do |regex, friend_list|
    # If we find a match, add the friend to the matched list and replace all
    # instances of the matching text with the friend's name.
    description_matches(regex: regex, replace: true, indicator: "**") do
      friend = friend_list.first # There's only one friend in the list.
      matched_friends << friend
      friend.name
    end
  end

  ## STEP 3
  # Now, we look through the ambiguous matches to find any where
  # the matched text is the entire friend's name, and make those
  # replacements too. ("Elizabeth" as a whole name should take
  # precedence over "Elizabeth Cady Stanton" even if the latter
  # is a better friend, because otherwise it's hard to just get
  # your friend "Elizabeth" to match.)
  full_name_regexes = []

  ambiguous_map.each do |regex, friend_list|
    smallest_name_friend = friend_list.min_by(&:name)
    smallest_name = smallest_name_friend.name

    # If one friend's name is contained within all of the other friend
    # names within the regex group, we assume that that friend's name
    # is the entire matched text (like "Elizabeth" in the above example).
    next unless friend_list.all? { |friend| friend.name.include? smallest_name }

    description_matches(regex: regex, replace: true, indicator: "**") do
      matched_friends << smallest_name_friend
      smallest_name
    end

    full_name_regexes << regex
  end

  # Delete all of regexes from STEP 3 substitutions.
  full_name_regexes.each { |regex| ambiguous_map.delete(regex) }

  ## STEP 4
  possible_matched_friends = []

  # Now, we look at regex matches that are ambiguous.
  ambiguous_map.each do |regex, friend_list|
    # If we find a match, add the friend to the possible-match list.
    description_matches(regex: regex, replace: false, indicator: "**") do
      possible_matched_friends << friend_list
    end
  end

  # Now, we compute the likelihood of each friend in the possible-match set.
  introvert.set_likelihood_score!(
    matches: matched_friends,
    possible_matches: possible_matched_friends
  )

  # Now we replace all of the ambiguous matches with our best guesses.
  ambiguous_map.each do |regex, friend_list|
    # If we find a match, take the most likely and replace all instances of
    # the matching text with that friend's name.
    description_matches(regex: regex, replace: true, indicator: "**") do
      friend_list.min_by do |friend|
        [-friend.likelihood_score, -friend.n_activities]
      end.name
    end
  end

  ## STEP 5
  # Lastly, we remove any backslashes, as these are used to escape friends'
  # names that we don't want to match.
  @description = @description.delete("\\")
end
highlight_locations(introvert:) click to toggle source

Modify the description to turn inputted location names (e.g. “Atlantis”) into full underscore'd names (e.g. “Atlantis”). @param introvert [Introvert] used to access internal data structures to

perform location matching
# File lib/friends/event.rb, line 185
def highlight_locations(introvert:)
  introvert.regex_location_map.each do |regex, location|
    # If we find a match, replace all instances of the matching text with
    # the location's name. We use single-underscores to indicate locations.
    description_matches(regex: regex, replace: true, indicator: "_") do
      location.name
    end
  end
end
location_in_description?(location) click to toggle source
# File lib/friends/event.rb, line 344
def location_in_description?(location)
  description_location_names.include? location.name
end
location_is_implicit?(location) click to toggle source
# File lib/friends/event.rb, line 348
def location_is_implicit?(location)
  @implicit_location == location.name
end