module Doing::ItemQuery

Tag and search filtering for a Doing entry

Public Instance Methods

ignore_case(search, case_type) click to toggle source

Determine if case should be ignored for searches

@param search [String] The search string @param case_type [Symbol] The case type

@return [Boolean] case should be ignored

# File lib/doing/item/query.rb, line 68
def ignore_case(search, case_type)
  (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
end
keep_item?(opt) click to toggle source

Used by filter_items determine whether an item matches a set of criteria

@param opt [Hash] filter parameters

@return [Boolean] whether the item matches all filter criteria

# File lib/doing/item/query.rb, line 162
def keep_item?(opt)
  item = dup
  time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i

  keep = true
  if opt[:unfinished]
    finished = item.tags?('done', :and)
    finished = opt[:not] ? !finished : finished
    keep = false if finished
  end

  if keep && opt[:val]&.count&.positive?
    bool = opt[:bool].normalize_bool if opt[:bool]
    bool ||= :and
    bool = :and if bool == :pattern

    val_match = opt[:val].nil? || opt[:val].empty? ? true : item.tag_values?(opt[:val], bool)
    keep = false unless val_match
    keep = opt[:not] ? !keep : keep
  end

  if keep && opt[:tag]
    opt[:tag_bool] = opt[:bool].normalize_bool if opt[:bool]
    opt[:tag_bool] ||= :and
    tag_match = opt[:tag].nil? || opt[:tag].empty? ? true : item.tags?(opt[:tag], opt[:tag_bool])
    keep = false unless tag_match
    keep = opt[:not] ? !keep : keep
  end

  if keep && opt[:search]
    search_match = if opt[:search].nil? || opt[:search].empty?
                     true
                   else
                     item.search(opt[:search], case_type: opt[:case].normalize_case)
                   end

    keep = false unless search_match
    keep = opt[:not] ? !keep : keep
  end

  if keep && opt[:date_filter]&.length == 2
    start_date = opt[:date_filter][0]
    end_date = opt[:date_filter][1]

    in_date_range = if end_date
                      item.date >= start_date && item.date <= end_date
                    else
                      item.date.strftime('%F') == start_date.strftime('%F')
                    end
    keep = false unless in_date_range
    keep = opt[:not] ? !keep : keep
  end

  if keep && opt[:time_filter][0] || opt[:time_filter][1]
    opt[:time_filter].map! { |v| v =~ /(12 *am|midnight)/i ? '00:00' : v }

    start_string = if opt[:time_filter][0].nil?
                     "#{item.date.strftime('%Y-%m-%d')} 00:00"
                   else
                     "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][0]}"
                   end
    start_time = start_string.chronify(guess: :begin)

    end_string = if opt[:time_filter][1].nil?
                   "#{item.date.to_datetime.next_day.strftime('%Y-%m-%d')} 00:00"
                 else
                   "#{item.date.strftime('%Y-%m-%d')} #{opt[:time_filter][1]}"
                 end
    end_time = end_string.chronify(guess: :end) || Time.now

    in_time_range = item.date >= start_time && item.date <= end_time

    keep = false unless in_time_range
    keep = opt[:not] ? !keep : keep
  end

  keep = false if keep && opt[:only_timed] && !item.interval

  if keep && opt[:tag_filter]
    keep = item.tags?(opt[:tag_filter]['tags'], opt[:tag_filter]['bool'])
    keep = opt[:not] ? !keep : keep
  end

  if keep && opt[:before]
    before = opt[:before]
    cutoff = if before.is_a?(String) && before =~ time_rx
               "#{item.date.strftime('%Y-%m-%d')} #{before}".chronify(guess: :begin)
             elsif before.is_a?(String)
               before.chronify(guess: :begin)
             else
               before
             end
    keep = cutoff && item.date <= cutoff
    keep = opt[:not] ? !keep : keep
  end

  if keep && opt[:after]
    after = opt[:after]
    cutoff = if after.is_a?(String) && after =~ time_rx
               "#{item.date.strftime('%Y-%m-%d')} #{after}".chronify(guess: :end)
             elsif after.is_a?(String)
               after.chronify(guess: :end)
             else
               after
             end
    keep = cutoff && item.date >= cutoff
    keep = opt[:not] ? !keep : keep
  end

  if keep && opt[:today]
    keep = item.date >= Date.today.to_time && item.date < Date.today.next_day.to_time
    keep = opt[:not] ? !keep : keep
  elsif keep && opt[:yesterday]
    keep = item.date >= Date.today.prev_day.to_time && item.date < Date.today.to_time
    keep = opt[:not] ? !keep : keep
  end

  keep
end
tag_values?(queries, bool = :and, negate: false) click to toggle source

Test if item matches tag values

@param queries (Array) The tag value queries to test @param bool (Symbol) The boolean to use for multiple tags (:and, :or, :not) @param negate [Boolean] negate the result?

@return [Boolean] true if tag/bool combination passes

# File lib/doing/item/query.rb, line 46
def tag_values?(queries, bool = :and, negate: false)
  bool = bool.normalize_bool

  matches = case bool
            when :and
              all_values?(queries)
            when :not
              no_values?(queries)
            else
              any_values?(queries)
            end
  negate ? !matches : matches
end
tags?(tags, bool = :and, negate: false) click to toggle source

Test if item contains tag(s)

@param tags (Array or String) The tags to test. Can be an array or a comma-separated string. @param bool (Symbol) The boolean to use for multiple tags (:and, :or, :not) @param negate [Boolean] negate the result?

@return [Boolean] true if tag/bool combination passes

# File lib/doing/item/query.rb, line 15
def tags?(tags, bool = :and, negate: false)
  if bool == :pattern
    tags = tags.to_tags.tags_to_array.join(' ')
    matches = tag_pattern?(tags)

    return negate ? !matches : matches
  end

  tags = split_tags(tags)
  bool = bool.normalize_bool

  matches = case bool
            when :and
              all_tags?(tags)
            when :not
              no_tags?(tags)
            else
              any_tags?(tags)
            end
  negate ? !matches : matches
end

Private Instance Methods

all_searches?(searches, case_type: :smart) click to toggle source
# File lib/doing/item/query.rb, line 284
def all_searches?(searches, case_type: :smart)
  return true unless searches.good?

  text = @title + @note.to_s
  searches.each do |s|
    rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
    return false unless text =~ rx
  end
  true
end
all_tags?(tags) click to toggle source
# File lib/doing/item/query.rb, line 317
def all_tags?(tags)
  return true unless tags.good?

  tags.each do |tag|
    next if tag =~ /done/ && !should_finish?

    return false unless @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
  end
  true
end
all_values?(queries) click to toggle source
# File lib/doing/item/query.rb, line 386
def all_values?(queries)
  return true unless queries.good?

  queries.each do |q|
    parts = split_value_query(q)

    return false unless tag_value_matches?(parts[2], parts[3], parts[4], parts[1])
  end
  true
end
any_searches?(searches, case_type: :smart) click to toggle source
# File lib/doing/item/query.rb, line 306
def any_searches?(searches, case_type: :smart)
  return true unless searches.good?

  text = @title + @note.to_s
  searches.each do |s|
    rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
    return true if text =~ rx
  end
  false
end
any_tags?(tags) click to toggle source
# File lib/doing/item/query.rb, line 339
def any_tags?(tags)
  return true unless tags.good?

  tags.each do |tag|
    return true if tag =~ /done/ && !should_finish?

    return true if @title =~ /@#{Regexp.escape(tag.wildcard_to_rx)}(?= |\(|\Z)/i
  end
  false
end
any_values?(queries) click to toggle source
# File lib/doing/item/query.rb, line 376
def any_values?(queries)
  return true unless queries.good?

  queries.each do |q|
    parts = split_value_query(q)
    return true if tag_value_matches?(parts[2], parts[3], parts[4], parts[1])
  end
  false
end
date_matches?(value, comp) click to toggle source
# File lib/doing/item/query.rb, line 427
def date_matches?(value, comp)
  time_rx = /^(\d{1,2}+(:\d{1,2}+)?( *(am|pm))?|midnight|noon)$/i
  value = "#{@date.strftime('%Y-%m-%d')} #{value}" if value =~ time_rx

  val = value.chronify(guess: :begin)
  raise InvalidTimeExpression, "Unrecognized date/time expression (#{value})" if val.nil?

  case comp
  when /^<$/
    @date < val
  when /^<=$/
    @date <= val
  when /^>$/
    @date > val
  when /^>=$/
    @date >= val
  when /^!=/
    @date != val
  when /^=/
    @date == val
  end
end
duration_matches?(value, comp) click to toggle source
# File lib/doing/item/query.rb, line 407
def duration_matches?(value, comp)
  return false if interval.nil?

  val = value.chronify_qty
  case comp
  when /^<$/
    interval < val
  when /^<=$/
    interval <= val
  when /^>$/
    interval > val
  when /^>=$/
    interval >= val
  when /^!=/
    interval != val
  when /^=/
    interval == val
  end
end
no_searches?(searches, case_type: :smart) click to toggle source
# File lib/doing/item/query.rb, line 295
def no_searches?(searches, case_type: :smart)
  return true unless searches.good?

  text = @title + @note.to_s
  searches.each do |s|
    rx = Regexp.new(s.wildcard_to_rx, ignore_case(s, case_type))
    return false if text =~ rx
  end
  true
end
no_tags?(tags) click to toggle source
# File lib/doing/item/query.rb, line 328
def no_tags?(tags)
  return true unless tags.good?

  tags.each do |tag|
    return false if tag =~ /done/ && !should_finish?

    return false if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
  end
  true
end
no_values?(queries) click to toggle source
# File lib/doing/item/query.rb, line 397
def no_values?(queries)
  return true unless queries.good?

  queries.each do |q|
    parts = split_value_query(q)
    return false if tag_value_matches?(parts[2], parts[3], parts[4], parts[1])
  end
  true
end
number_or_date(value) click to toggle source
# File lib/doing/item/query.rb, line 361
def number_or_date(value)
  return nil unless value

  if value.strip =~ /^[0-9.]+%?$/
    value.strip.to_f
  else
    value.strip.chronify(guess: :end)
  end
end
split_value_query(query) click to toggle source
# File lib/doing/item/query.rb, line 371
def split_value_query(query)
  val_rx = /^(!)?@?(\S+) +(!?[<>=][=*]?|[$*^]=) +(.*?)$/
  query.match(val_rx)
end
tag_pattern?(tags) click to toggle source
# File lib/doing/item/query.rb, line 350
def tag_pattern?(tags)
  query = tags.to_query

  no_tags?(query[:must_not]) && all_tags?(query[:must]) && any_tags?(query[:should])
end
tag_value(tag) click to toggle source
# File lib/doing/item/query.rb, line 356
def tag_value(tag)
  res = @title.match(/@#{tag.sub(/^@/, '').wildcard_to_rx}\((.*?)\)/)
  res ? res[1] : nil
end
tag_value_matches?(tag, comp, value, negate) click to toggle source

Test if a tag’s value matches a given value. Value can be a date string, a text string, or a number/percentage. Type of comparison is determined by the comparitor and the objects being compared.

@param tag [String] The tag name from which to get the value @param comp [String] The comparator (e.g. >= or *=) @param value [String] The value to test against @param negate [Boolean] Negate the response

@return True if tag value matches, False otherwise.

# File lib/doing/item/query.rb, line 496
def tag_value_matches?(tag, comp, value, negate)
  # If tag matches existing tag
  if tags?(tag, :and)
    tag_val = tag_value(tag)

    # If the tag value is not a date and contains alpha
    # characters and comparison is ==, or comparison is
    # a string comparitor (*= ^= $=)
    if (value.chronify.nil? && value =~ /[a-z]/i && comp =~ /^!?==?$/) || comp =~ /[$*^]=/
      is_match = value_string_matches?(tag_val, comp, value)

      comp =~ /!/ || negate ? !is_match : is_match
    else
      # Convert values to either a number or a date
      tag_val = number_or_date(tag_val)
      val = number_or_date(value)

      # Fail if either value is nil
      return false if val.nil? || tag_val.nil?

      # Fail unless both values are of the same class (float or date)
      return false unless val.instance_of?(tag_val.class)

      is_match = value_number_matches?(tag_val, comp, val)

      negate.nil? ? is_match : !is_match
    end
  # If tag name matches a trigger for elapsed time test
  elsif tag =~ /^(elapsed|dur(ation)?|int(erval)?)$/i
    is_match = duration_matches?(value, comp)

    comp =~ /!/ || negate ? !is_match : is_match
  # Else if tag name matches a trigger for start date
  elsif tag =~ /^(d(ate)?|t(ime)?)$/i
    is_match = date_matches?(value, comp)

    comp =~ /!/ || negate ? !is_match : is_match
  # Else if tag name matches a trigger for all text
  elsif tag =~ /^text$/i
    is_match = value_string_matches?([@title, @note.to_s(prefix: '')].join(' '), comp, value)

    comp =~ /!/ || negate ? !is_match : is_match
  # Else if tag name matches a trigger for title
  elsif tag =~ /^title$/i
    is_match = value_string_matches?(@title, comp, value)

    comp =~ /!/ || negate ? !is_match : is_match
  # Else if tag name matches a trigger for note
  elsif tag =~ /^note$/i
    is_match = value_string_matches?(@note.to_s(prefix: ''), comp, value)

    comp =~ /!/ || negate ? !is_match : is_match
  # Else if item contains tag being tested
  else
    false
  end
end
value_number_matches?(tag_val, comp, value) click to toggle source
# File lib/doing/item/query.rb, line 463
def value_number_matches?(tag_val, comp, value)
  case comp
  when /^<$/
    tag_val < value
  when /^<=$/
    tag_val <= value
  when /^>$/
    tag_val > value
  when /^>=$/
    tag_val >= value
  when /^!=/
    tag_val != value
  when /^=/
    tag_val == value
  end
end
value_string_matches?(tag_val, comp, value) click to toggle source
# File lib/doing/item/query.rb, line 450
def value_string_matches?(tag_val, comp, value)
  case comp
  when /\^=/
    tag_val =~ /^#{value.wildcard_to_rx}/i
  when /\$=/
    tag_val =~ /#{value.wildcard_to_rx}$/i
  when %r{==}
    tag_val =~ /^#{value.wildcard_to_rx}$/i
  else
    tag_val =~ /#{value.wildcard_to_rx}/i
  end
end