class Doing::Item

This class describes a single WWID item

Attributes

date[RW]
note[RW]
section[RW]
title[RW]

Public Class Methods

new(date, title, section, note = nil) click to toggle source

Initialize an item with date, title, section, and optional note

@param date [Time] The item's start date @param title [String] The title @param section [String] The section to which the item belongs @param note [Array or String] The note (optional)

# File lib/doing/item.rb, line 25
def initialize(date, title, section, note = nil)
  @date = date.is_a?(Time) ? date : Time.parse(date)
  @title = title
  @section = section
  @note = Note.new(note)
end

Public Instance Methods

calculate_end_date(opt) click to toggle source
# File lib/doing/item.rb, line 64
def calculate_end_date(opt)
  if opt[:took]
    if @date + opt[:took] > Time.now
      @date = Time.now - opt[:took]
      Time.now
    else
      @date + opt[:took]
    end
  elsif opt[:back]
    if opt[:back].is_a? Integer
      @date + opt[:back]
    else
      @date + (opt[:back] - @date)
    end
  else
    Time.now
  end
end
clone() click to toggle source
# File lib/doing/item.rb, line 471
def clone
  Marshal.load(Marshal.dump(self))
end
duration() click to toggle source

If the entry doesn't have a @done date, return the elapsed time

# File lib/doing/item.rb, line 37
def duration
  return nil unless should_time? && should_finish?

  return nil if @title =~ /(?<=^| )@done\b/

  return Time.now - @date
end
end_date() click to toggle source

Get the value of the item's @done tag

@return [Time] @done value

# File lib/doing/item.rb, line 60
def end_date
  @end_date ||= Time.parse(Regexp.last_match(1)) if @title =~ /@done\((\d{4}-\d\d-\d\d \d\d:\d\d.*?)\)/
end
equal?(other, match_section: false) click to toggle source

Test for equality between items

@param other [Item] The other item @param match_section [Boolean] If true, require item sections to match

@return [Boolean] is equal?

# File lib/doing/item.rb, line 98
def equal?(other, match_section: false)
  return false if @title.strip != other.title.strip

  return false if @date != other.date

  return false unless @note.equal?(other.note)

  return false if match_section && @section != other.section

  true
end
expand_date_tags(additional_tags = nil) click to toggle source

Updates the title of the Item by expanding natural language dates within configured date tags (tags whose value is expected to be a date)

@param additional_tags An array of additional tag names to consider dates

# File lib/doing/item.rb, line 150
def expand_date_tags(additional_tags = nil)
  @title.expand_date_tags(additional_tags)
end
finished?() click to toggle source

Test if item has a @done tag

@return [Boolean] true item has @done tag

# File lib/doing/item.rb, line 383
def finished?
  tags?('done')
end
id() click to toggle source

Generate a hash that represents the entry

@return [String] entry hash

# File lib/doing/item.rb, line 86
def id
  @id ||= (@date.to_s + @title + @section).hash
end
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.rb, line 291
def ignore_case(search, case_type)
  (case_type == :smart && search !~ /[A-Z]/) || case_type == :ignore
end
inspect() click to toggle source

@private

# File lib/doing/item.rb, line 466
def inspect
  # %(<Doing::Item @date=#{@date} @title="#{@title}" @section:"#{@section}" @note:#{@note.to_s}>)
  %(<Doing::Item @date=#{@date}>)
end
interval() click to toggle source

Get the difference between the item's start date and the value of its @done tag (if present)

@return Interval in seconds

# File lib/doing/item.rb, line 51
def interval
  @interval ||= calc_interval
end
move_to(new_section, label: true, log: true) click to toggle source

Move item from current section to destination section

@param new_section [String] The destination section @param label [Boolean] add @from(original section) tag @param log [Boolean] log this action

@return nothing

# File lib/doing/item.rb, line 427
def move_to(new_section, label: true, log: true)
  from = @section

  tag('from', rename_to: 'from', value: from, force: true) if label
  @section = new_section

  Doing.logger.count(@section == 'Archive' ? :archived : :moved) if log
  Doing.logger.debug("#{@section == 'Archive' ? 'Archived' : 'Moved'}:",
                     "#{@title.trunc(60)} from #{from} to #{@section}")
  self
end
overlapping_time?(item_b) click to toggle source

Test if the interval between start date and @done value overlaps with another item's

@param item_b [Item] The item to compare

@return [Boolean] overlaps?

# File lib/doing/item.rb, line 129
def overlapping_time?(item_b)
  return true if same_time?(item_b)

  start_a = date
  a_interval = interval
  end_a = a_interval ? start_a + a_interval.to_i : start_a
  start_b = item_b.date
  b_interval = item_b.interval
  end_b = b_interval ? start_b + b_interval.to_i : start_b
  (start_a >= start_b && start_a <= end_b) || (end_a >= start_b && end_a <= end_b) || (start_a < start_b && end_a > end_b)
end
same_time?(item_b) click to toggle source

Test if two items occur at the same time (same start date and equal duration)

@param item_b [Item] The item to compare

@return [Boolean] is equal?

# File lib/doing/item.rb, line 117
def same_time?(item_b)
  date == item_b.date ? interval == item_b.interval : false
end
should_finish?() click to toggle source

Test if item is included in never_finish config and thus should not receive a @done tag

@return [Boolean] item should receive @done tag

# File lib/doing/item.rb, line 402
def should_finish?
  should?('never_finish')
end
should_time?() click to toggle source

Test if item is included in never_time config and thus should not receive a date on the @done tag

@return [Boolean] item should receive @done date

# File lib/doing/item.rb, line 412
def should_time?
  should?('never_time')
end
tag(tags, **options) click to toggle source

Add (or remove) tags from the title of the item

@param tags [Array] The tags to apply @param options Additional options

@option options :date [Boolean] Include timestamp? @option options :single [Boolean] Log as a single change? @option options :value [String] A value to include as @tag(value) @option options :remove [Boolean] if true remove instead of adding @option options :rename_to [String] if not nil, rename target tag to this tag name @option options :regex [Boolean] treat target tag string as regex pattern @option options :force [Boolean] with rename_to, add tag if it doesn't exist

# File lib/doing/item.rb, line 168
def tag(tags, **options)
  added = []
  removed = []

  date = options.fetch(:date, false)
  options[:value] ||= date ? Time.now.strftime('%F %R') : nil
  options.delete(:date)

  single = options.fetch(:single, false)
  options.delete(:single)

  tags = tags.to_tags if tags.is_a? ::String

  remove = options.fetch(:remove, false)
  tags.each do |tag|
    if tag =~ /^(\S+)\((.*?)\)$/
      m = Regexp.last_match
      tag = m[1]
      options[:value] ||= m[2]
    end

    bool = remove ? :and : :not
    if tags?(tag, bool) || options[:value]
      @title = @title.tag(tag, **options).strip
      remove ? removed.push(tag) : added.push(tag)
    end
  end

  Doing.logger.log_change(tags_added: added, tags_removed: removed, count: 1, item: self, single: single)

  self
end
tag_array() click to toggle source

convert tags on item to an array with @ symbols removed

@return [Array] array of tags

# File lib/doing/item.rb, line 225
def tag_array
  tags.tags_to_array
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.rb, line 269
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() click to toggle source

Get a list of tags on the item

@return [Array] array of tags (no values)

# File lib/doing/item.rb, line 206
def tags
  @title.scan(/(?<= |\A)@([^\s(]+)/).map { |tag| tag[0] }.sort.uniq
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.rb, line 238
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
tags_with_values() click to toggle source

Return all tags including parenthetical values

@return [Array<Array>] Array of array pairs, [[tag1, value], [tag2, value]]

# File lib/doing/item.rb, line 216
def tags_with_values
  @title.scan(/(?<= |\A)@([^\s(]+)(?:\((.*?)\))?/).map { |tag| [tag[0], tag[1]] }.sort.uniq
end
to_pretty(elements: %i[date title section]) click to toggle source

outputs a colored string with relative date and highlighted tags

@return Pretty representation of the object.

# File lib/doing/item.rb, line 449
def to_pretty(elements: %i[date title section])
  output = []
  elements.each do |e|
    case e
    when :date
      output << format('%13s |', @date.relative_date).cyan
    when :section
      output << "#{magenta}(#{white(@section)}#{magenta})"
    when :title
      output << @title.white.highlight_tags('cyan')
    end
  end

  output.join(' ')
end
to_s() click to toggle source

outputs item in Doing file format, including leading tab

# File lib/doing/item.rb, line 440
def to_s
  "\t- #{@date.strftime('%Y-%m-%d %H:%M')} | #{@title}#{@note.good? ? "\n#{@note}" : ''}"
end
unfinished?() click to toggle source

Test if item does not contain @done tag

@return [Boolean] true if item is missing @done tag

# File lib/doing/item.rb, line 392
def unfinished?
  tags?('done', negate: true)
end

Private Instance Methods

all_searches?(searches, case_type: :smart) click to toggle source
# File lib/doing/item.rb, line 504
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.rb, line 537
def all_tags?(tags)
  return true unless tags.good?

  tags.each do |tag|
    if tag =~ /done/ && !should_finish?
      next
    else
      return false unless @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
    end
  end
  true
end
all_values?(queries) click to toggle source
# File lib/doing/item.rb, line 612
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.rb, line 526
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.rb, line 563
def any_tags?(tags)
  return true unless tags.good?

  tags.each do |tag|
    if tag =~ /done/ && !should_finish?
      return true
    else
      return true if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
    end
  end
  false
end
any_values?(queries) click to toggle source
# File lib/doing/item.rb, line 602
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
calc_interval() click to toggle source
# File lib/doing/item.rb, line 492
def calc_interval
  return nil unless should_time? && should_finish?

  done = end_date
  return nil if done.nil?

  start = @date

  t = (done - start).to_i
  t.positive? ? t : nil
end
date_matches?(value, comp) click to toggle source
# File lib/doing/item.rb, line 653
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.rb, line 633
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.rb, line 515
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.rb, line 550
def no_tags?(tags)
  return true unless tags.good?

  tags.each do |tag|
    if tag =~ /done/ && !should_finish?
      return false
    else
      return false if @title =~ /@#{tag.wildcard_to_rx}(?= |\(|\Z)/i
    end
  end
  true
end
no_values?(queries) click to toggle source
# File lib/doing/item.rb, line 623
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.rb, line 587
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
should?(key) click to toggle source
# File lib/doing/item.rb, line 477
def should?(key)
  config = Doing.settings
  return true unless config[key].is_a?(Array)

  config[key].each do |tag|
    if tag =~ /^@/
      return false if tags?(tag.sub(/^@/, '').downcase)
    elsif section.downcase == tag.downcase
      return false
    end
  end

  true
end
split_tags(tags) click to toggle source
# File lib/doing/item.rb, line 780
def split_tags(tags)
  tags.to_tags.tags_to_array
end
split_value_query(query) click to toggle source
# File lib/doing/item.rb, line 597
def split_value_query(query)
  val_rx = /^(!)?@?(\S+) +(!?[<>=][=*]?|[$*^]=) +(.*?)$/
  query.match(val_rx)
end
tag_pattern?(tags) click to toggle source
# File lib/doing/item.rb, line 576
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.rb, line 582
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.rb, line 722
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.class == 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.rb, line 689
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.rb, line 676
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