# frozen_string_literal: true # Event represents an event with a date and a description. # It's basically a superclass for Activity and Note. require "chronic" require "paint" require "set" require "friends/tag_regex" require "friends/serializable" module Friends class Event extend Serializable SERIALIZATION_PREFIX = "- " DATE_PARTITION = ": " # @return [Regexp] the regex for capturing groups in deserialization def self.deserialization_regex /(#{SERIALIZATION_PREFIX})?(?.+)?/ end # @param str [String] the text of the activity, of one of the formats: # ": " # "" (Program will prompt for description.) # "" (The current date will be used by default.) # @return [Activity] the new activity 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 attr_reader :date attr_accessor :description attr_writer :implicit_location # @return [String] the command-line display text for the activity 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 # @return [String] the file serialization text for the activity def serialize "#{SERIALIZATION_PREFIX}#{date}: #{description}" end # 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 def highlight_description(introvert:) highlight_locations(introvert: introvert) highlight_friends(introvert: introvert) end # 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 def update_friend_name(old_name:, new_name:) @description = @description.gsub( Regexp.new("(?<=\\*\\*)#{old_name}(?=\\*\\*)"), new_name ) end # 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 def update_location_name(old_name:, new_name:) @description = @description.gsub( Regexp.new("(?<=_)#{old_name}(?=_)"), new_name ) end # @param location [Location] the location to test # @return [Boolean] true if activity has location in description or it equals implicit location def includes_location?(location) location_in_description?(location) || location_is_implicit?(location) end def default_location @default_location ||= @description[/(?<=to _)\w[^_]*(?=_)/] end # @param friend [Friend] the friend to test # @return [Boolean] true iff this activity includes the given friend def includes_friend?(friend) friend_names.include? friend.name end # @param tag [String] the tag to test, of the form "@tag" # @return [Boolean] true iff this activity includes the given tag def includes_tag?(tag) tags.include? tag.downcase end # @return [Set] all tags in this activity (including the "@") def tags Set.new(@description.scan(TAG_REGEX).map(&:downcase)) end # Find the names of all friends in this description. # @return [Array] list of all friend names in the description def friend_names @description.scan(/(?<=\*\*)\w[^\*]*(?=\*\*)/).uniq end # Find the names of all locations in this description. # @return [Array] list of all location names in the description def description_location_names @description.scan(/(?<=_)\w[^_]*(?=_)/).uniq end # @return [Array] list of all location names in either description or implicit_location def location_names @implicit_location ? [@implicit_location] : description_location_names end private # 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 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 # 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. 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 # 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 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 # Default sorting for an array of activities is reverse-date. def <=>(other) other.date <=> date end def location_in_description?(location) description_location_names.include? location.name end def location_is_implicit?(location) @implicit_location == location.name end end end