publiclab/plots2

View on GitHub
app/models/node.rb

Summary

Maintainability
F
1 wk
Test Coverage
class UniqueUrlValidator < ActiveModel::Validator
  def validate(record)
    if record.title.blank?
      record.errors[:base] << "You must provide a title."
      # otherwise the below title uniqueness check fails, as title presence validation doesn't run until after
    elsif record.type == 'page'
      array = %w(create edit update delete new)
      if array.include? record.title.downcase
        record.errors[:base] << "You may not use the title '#{record.title}'"
      end
    else
      if !Node.where(path: record.generate_path).first.nil? && record.type == 'note'
        record.errors[:base] << 'You have already used this title.'
      end
    end
  end
end

class Node < ActiveRecord::Base
  extend RawStats
  include NodeShared # common methods for node-like models

  self.table_name = 'node'
  self.primary_key = 'nid'

  def self.search(query:, order: :default, type: :natural, limit: 25)
    order_param = if order == :default
                    { changed: :desc }
                  elsif order == :likes
                    { cached_likes: :desc }
                  elsif order == :views
                    { views: :desc }
                  end

    # We can drastically have this simplified using one DB
    if ActiveRecord::Base.connection.adapter_name == 'Mysql2'
      if order == :natural
        query = connection.quote(query.to_s)
        nids = if type == :boolean
                 # Query is done as a boolean full-text search. More info here: https://dev.mysql.com/doc/refman/5.5/en/fulltext-boolean.html
                 Revision.select("node_revisions.nid, node_revisions.body, node_revisions.title, MATCH(node_revisions.body, node_revisions.title) AGAINST(#{query} IN BOOLEAN MODE) AS score")
                   .where("MATCH(node_revisions.body, node_revisions.title) AGAINST(#{query} IN BOOLEAN MODE)")
                   .limit(limit)
                   .distinct
                   .collect(&:nid)
               else
                 Revision.select("node_revisions.nid, node_revisions.body, node_revisions.title, MATCH(node_revisions.body, node_revisions.title) AGAINST(#{query} IN NATURAL LANGUAGE MODE) AS score")
                   .where("MATCH(node_revisions.body, node_revisions.title) AGAINST(#{query} IN NATURAL LANGUAGE MODE)")
                   .limit(limit)
                   .distinct
                   .collect(&:nid)
               end
        where(nid: nids, status: 1)
      elsif order == :natural_titles_only
        Revision.select("node_revisions.nid, node_revisions.body, node_revisions.title, MATCH(node_revisions.title) AGAINST(#{query} IN NATURAL LANGUAGE MODE) AS score")
          .where("MATCH(node_revisions.body, node_revisions.title) AGAINST(#{query} IN NATURAL LANGUAGE MODE)")
          .limit(limit)
          .distinct
          .collect(&:nid)
        where(nid: nids, status: 1)
      elsif
        nids = Revision.where('MATCH(node_revisions.body, node_revisions.title) AGAINST(?)', query).collect(&:nid)

        tnids = Tag.find_nodes_by_type(query, %w(note page)).collect(&:nid) # include results by tag
        where(nid: nids + tnids, status: 1)
          .order(order_param)
          .limit(limit)
          .distinct
      end
    else
      query ||= ""
      Node.limit(limit)
        .where('title LIKE ?', '%' + query + '%')
        .where(status: 1)
        .order(order_param)
    end
  end

  def updated_month
    updated_at.strftime('%B %Y')
  end

  has_many :revision, foreign_key: 'nid', dependent: :destroy
  has_many :drupal_upload, foreign_key: 'nid' # , dependent: :destroy # re-enable in Rails 5
  has_many :drupal_files, through: :drupal_upload
  has_many :node_tag, foreign_key: 'nid' # , dependent: :destroy # re-enable in Rails 5
  has_many :tag, through: :node_tag
  has_many :comments, foreign_key: 'nid', dependent: :destroy # re-enable in Rails 5
  has_many :images, foreign_key: :nid
  has_many :node_selections, foreign_key: :nid, dependent: :destroy

  belongs_to :user, foreign_key: 'uid'

  validates :title,
    presence: true,
    length: { minimum: 2 },
    format: { with: /[A-Z][\w\-_]*/i, message: 'can only include letters, numbers, and dashes' }
  validates_with UniqueUrlValidator, on: :create

  scope :published, -> { where(status: 1) }
  scope :past_week, -> { published.where("created > ?", (Time.now - 7.days).to_i) }
  scope :past_month, -> { published.where("created > ?", (Time.now - 1.months).to_i) }
  scope :past_year, -> { published.where("created > ?", (Time.now - 1.years).to_i) }

  # making drupal and rails database conventions play nice;
  # 'changed' is a reserved word in rails
  class << self
    def instance_method_already_implemented?(method_name)
      return true if method_name == 'changed'
      return true if method_name == 'changed?'

      super
    end
  end

  # making drupal and rails database conventions play nice;
  # 'type' is a reserved word in rails
  def self.inheritance_column
    'rails_type'
  end

  def slug_from_path
    path.split('/').last
  end

  def self.hidden_response_node_ids
    Node.joins(:node_tag)
        .joins('LEFT OUTER JOIN term_data ON term_data.tid = community_tags.tid')
        .select('node.nid, term_data.tid, term_data.name, community_tags.tid')
        .where(type: 'note', status: 1)
        .where('term_data.name = (?)', 'hidden:response')
        .collect(&:nid)
  end

  before_save :set_changed_and_created
  after_create :setup
  before_validation :set_path_and_slug, on: :create

  # can switch to a "question-style" path if specified
  def path(type = :default)
    if type == :question
      self[:path].gsub('/notes/', '/questions/')
    else
      # default path
      self[:path]
    end
  end

  # should only be run at actual creation time --
  # or, we should refactor to use node.created instead of Time.now
  def generate_path
    time = Time.now.strftime('%m-%d-%Y')

    case type
    when "note"
      username = User.find_by(id: uid).name # name? or username?
      "/notes/#{username}/#{time}/#{title.parameterize}"
    when "feature"
      "/feature/#{title.parameterize}"
    when "page"
      "/wiki/#{title.parameterize}"
    end
  end

  private

  def set_path_and_slug
    self.path = generate_path if path.blank? && !title.blank?
    self.slug = path.split('/').last unless path.blank?
  end

  def set_changed_and_created
    self['changed'] = DateTime.now.to_i
  end

  # determines URL ("slug"), and sets up a created timestamp
  def setup
    self['created'] = DateTime.now.to_i
    save
  end

  public

  is_impressionable counter_cache: true, column_name: :views, unique: :ip_address

  def is_question?
    has_power_tag('question')
  end

  def self.weekly_tallies(type = 'note', span = 52, time = Time.now)
    weeks = {}
    (0..span).each do |week|
      weeks[span - week] = Node.select(:created)
                               .where(type:    type,
                                      status:  1,
                                      created: time.to_i - week.weeks.to_i..time.to_i - (week - 1).weeks.to_i)
                               .size
    end
    weeks
  end

  def self.contribution_graph_making(type = 'note', start = Time.now - 1.year, fin = Time.now)
    date_hash = {}
    week = start.to_date.step(fin.to_date, 7).count

    while week >= 1
      month = (fin - (week * 7 - 1).days)
      range = (fin.to_i - week.weeks.to_i)..(fin.to_i - (week - 1).weeks.to_i)

      weekly_nodes = Node.published.select(:created)
                    .where(type: type,
                    created: range)
                    .size
      date_hash[month.to_f * 1000] = weekly_nodes
      week -= 1
    end
    date_hash
  end

  def self.frequency(type, starting, ending)
    weeks = (ending.to_date - starting.to_date).to_i / 7.0
    Node.published.select(%i(created type))
      .where(type: type, created: starting.to_i..ending.to_i)
      .size / weeks
  end

  def notify
    if status == 4
      AdminMailer.notify_node_moderators(self).deliver_later!(wait_until: 24.hours.from_now)
    else
      SubscriptionMailer.notify_node_creation(self).deliver_later!
    end
  end

  def publish
    self.status = 1
    save
    self
  end

  def spam
    self.status = 0
    save
    self
  end

  def flag_node
    self.flag += 1
    save
    self
  end

  def unflag_node
    self.flag = 0
    save
    self
  end

  def files
    drupal_files
  end

  # users who like this node
  def likers
    node_selections
      .joins(:user)
      .references(:rusers)
      .where(liking: true)
      .where('rusers.status': 1)
      .collect(&:user)
  end

  def latest
    revisions
      .where(status: 1)
      .order(timestamp: :desc)
      .first
  end

  def revisions
    revision
      .order(timestamp: :desc)
  end

  def author
    User.find(uid)
  end

  def authors
    revisions.collect(&:author).uniq
  end

  def coauthors
    User.where(username: power_tags('with')) if has_power_tag('with')
  end

  # tag- and node-based followers
  def subscribers(conditions = false)
    users = TagSelection.where(tid: tags.collect(&:tid))
                        .collect(&:user)
    users += NodeSelection.where(nid: nid)
                          .collect(&:user)

    users = users.where(conditions) if conditions
    users.uniq
  end

  # view adaptors for typical rails db conventions so we can migrate someday
  def id
    nid
  end

  def created_at
    Time.at(created)
  end

  def updated_at
    Time.at(self['changed'])
  end

  def body
    latest&.body
  end

  def summary
    body.lines.first
  end

  # was unable to set up this relationship properly with ActiveRecord associations
  def drupal_main_image
    DrupalMainImage.order('vid')
                   .where('nid = ? AND field_main_image_fid IS NOT NULL', nid)
                   .last
  end

  # provide either a Drupally main_image or a Railsy one
  def main_image(node_type = :all)
    if !images.empty? && node_type != :drupal
      if main_image_id.blank?
        images.order('vid').last
      else
        images.where(id: main_image_id).first
      end
    elsif drupal_main_image && node_type != :rails
      drupal_main_image.drupal_file
    end
  end

  # scan for first image in the body and use this instead
  # (in future, maybe just do this for all images?)
  def scraped_image
    match = latest&.render_body&.scan(/<img(.*?)\/>/)&.first&.first
    match&.split('src="')&.last&.split('"')&.first
  end

  # ============================================
  # Tag-related methods

  def has_mailing_list?
    has_power_tag('list')
  end

  # Nodes this node is responding to with a `response:<nid>` power tag;
  # The key word "response" can be customized, i.e. `replication:<nid>` for other uses.
  def responded_to(key = 'response')
    Node.where(nid: power_tags(key)) || []
  end

  # Nodes that respond to this node with a `response:<nid>` power tag;
  # The key word "response" can be customized, i.e. `replication:<nid>` for other uses.
  def responses(key = 'response')
    Tag.find_nodes_by_type([key + ':' + id.to_s])
  end

  # Nodes that respond to this node with a `response:<nid>` power tag;
  # The key word "response" can be customized, i.e. `replication:<nid>` for other uses.
  def response_count(key = 'response')
    Node.where(status: 1, type: 'note')
        .includes(:revision, :tag)
        .references(:term_data)
        .where('term_data.name = ?', "#{key}:#{id}")
        .size
  end

  # power tags have "key:value" format, and should be searched with a "key:*" wildcard
  def has_power_tag(key)
    !power_tag(key).blank?
  end

  # returns the value for the most recent power tag of form key:value
  def power_tag(tag)
    tids = Tag.includes(:node_tag)
              .references(:community_tags)
              .where('community_tags.nid = ? AND name LIKE ?', id, tag + ':%')
              .collect(&:tid)
    node_tag = NodeTag.where('nid = ? AND tid IN (?)', id, tids)
                                     .order('nid DESC')
    if node_tag&.first
      node_tag.first.tag.name.gsub(tag + ':', '')
    else
      ''
    end
  end

  # returns all tagnames for a given power tag
  def power_tags(tagname)
    tags = []
    power_tag_objects(tagname).each do |nt|
      tags << nt.name.gsub(tagname + ':', '')
    end
    tags
  end

  # returns all power tag results as whole community_tag objects
  def power_tag_objects(tagname = nil)
    tags = Tag.includes(:node_tag)
              .references(:community_tags)
              .where('community_tags.nid = ?', id)
    if tagname
      tags = tags.where('name LIKE ?', tagname + ':%')
    else
      tags = tags.where('name LIKE ?', '%:%') # any powertag
    end
    tids = tags.collect(&:tid)
    NodeTag.where('nid = ? AND tid IN (?)', id, tids)
  end

  # return whole community_tag objects but no powertags or "event"
  def normal_tags(order = :none)
    all_tags = tags.select { |tag| !tag.name.include?(':') }
    tids = all_tags.collect(&:tid)
    if order == :followers
     tags = NodeTag.where(nid: id)
       .where(tid: tids)
       .joins(:tag)
       .joins("LEFT OUTER JOIN tag_selections ON term_data.tid = tag_selections.tid") 
       .order(Arel.sql('count(tag_selections.user_id) DESC')) 
       .group('community_tags.tid, community_tags.uid, community_tags.date, community_tags.created_at, community_tags.updated_at') 
    else
      tags = NodeTag.where('nid = ? AND tid IN (?)', id, tids)
    end
    tags
  end

  def location_tags
    if lat && lon && place
      power_tag_objects('lat') + power_tag_objects('lon') + power_tag_objects('place')
    elsif lat && lon
      power_tag_objects('lat') + power_tag_objects('lon')
    else
      []
    end
  end

  # access a tagname /or/ tagname ending in wildcard such as "tagnam*"
  # also searches for other tags whose parent field matches given tagname,
  # but not tags matching given tag's parent field
  def has_tag(tagname)
    tags = get_matching_tags_without_aliasing(tagname)
    # search for tags with parent matching this
    tags += Tag.includes(:node_tag)
                .references(:community_tags)
                .where('community_tags.nid = ? AND parent LIKE ?', id, tagname)
    # search for parent tag of this, if exists
    # tag = Tag.where(name: tagname).try(:first)
    # if tag && tag.parent
    #  tags += Tag.includes(:node_tag)
    #             .references(:community_tags)
    #             .where("community_tags.nid = ? AND name LIKE ?", self.id, tag.parent)
    # end
    tids = tags.collect(&:tid).uniq
    !NodeTag.where('nid IN (?) AND tid IN (?)', id, tids).empty?
  end

  # can return multiple Tag records -- we don't yet hard-enforce uniqueness, but should soon
  # then, this would just be replaced by Tag.where(name: tagname).first
  def get_matching_tags_without_aliasing(tagname)
    tags = Tag.includes(:node_tag)
              .references(:community_tags)
              .where('community_tags.nid = ? AND name LIKE ?', id, tagname)
    # search for tags which end in wildcards
    if tagname[-1] == '*'
      tags += Tag.includes(:node_tag)
                 .references(:community_tags)
                 .where('community_tags.nid = ? AND (name LIKE ? OR name LIKE ?)', id, tagname, tagname.tr('*', '%'))
    end
    tags
  end

  def has_tag_without_aliasing(tagname)
    tags = get_matching_tags_without_aliasing(tagname)
    tids = tags.collect(&:tid).uniq
    !NodeTag.where('nid IN (?) AND tid IN (?)', id, tids).empty?
  end

  # has it been tagged with "list:foo" where "foo" is the name of a Google Group?
  def mailing_list
    Rails.cache.fetch('feed-' + id.to_s + '-' + (updated_at.to_i / 300).to_i.to_s) do
      RSS::Parser.parse(open('https://groups.google.com/group/' + power_tag('list') + '/feed/rss_v2_0_topics.xml').read, false).items
    end
  rescue StandardError
    []
  end

  # End of tag-related methods

  # used in typeahead autocomplete search results
  def icon
    icon = 'file' if type == 'note'
    icon = 'book' if type == 'page'
    icon = 'flag' if has_tag('chapter')
    # icon = 'wrench' if type == 'tool'
    icon = 'question-circle' if has_power_tag('question')
    icon
  end

  def tags
    tag
  end

  def node_tags
    node_tag
  end

  def tagnames
    tags.collect(&:name)
  end

  # Here we re-query to fetch /all/ tagnames; this is used in
  # /views/notes/_notes.html.erb in a way that would otherwise only
  # return a single tag due to a join, yet select() keeps this efficient
  def tagnames_as_classes
    Node.select([:nid])
        .find(id)
        .tagnames
        .map { |t| 'tag-' + t.tr(':', '-') }
        .join(' ')
  end

  def edit_path
    path = if type == 'page'
             '/wiki/edit/' + self.path.split('/').last
           else
             '/notes/edit/' + id.to_s
    end
    path
  end

  def self.find_by_path(title)
    Node.where(path: ["/#{title}"]).first
  end

  def blurred?
    has_power_tag('location') && power_tag('location') == "blurred"
  end

  def lat
    if has_power_tag('lat')
      power_tag('lat').to_f
    else
      false
    end
  end

  def lon
    if has_power_tag('lon')
      power_tag('lon').to_f
    else
      false
    end
  end

  def zoom
    if has_power_tag('zoom')
      power_tag('zoom').to_f
    else
      false
    end
  end

  def place
    if has_power_tag('place')
      power_tag('place')
    else
      false
    end
  end

  def next_by_author
    Node.where('uid = ? and nid > ? and type = "note"', author.uid, nid)
        .order('nid')
        .first
  end

  def prev_by_author
    Node.where('uid = ? and nid < ? and type = "note"', author.uid, nid)
        .order('nid desc')
        .first
  end

  def self.for_tagname_and_type(tagname, type = 'note', options = {})
    return Node.for_wildcard_tagname_and_type(tagname, type) if options[:wildcard]

    return Node.for_question_tagname_and_type(tagname, type) if options[:question]

    Node.where(status: 1, type: type)
      .includes(:revision, :tag)
      .references(:term_data, :node_revisions)
      .where('term_data.name = ? OR term_data.parent = ?', tagname, tagname)
  end

  def self.for_wildcard_tagname_and_type(tagname, type = 'note')
    search_term = tagname[0..-2] + '%'
    Node.where(status: 1, type: type)
      .includes(:revision, :tag)
      .references(:term_data, :node_revisions)
      .where('term_data.name LIKE (?) OR term_data.parent LIKE (?)', search_term, search_term)
  end

  def self.for_question_tagname_and_type(tagname, type = 'note')
    other_tag = tagname.include?("question:") ? tagname.split(':')[1] : "question:#{tagname}"
    Node.where(status: 1, type: type)
      .includes(:revision, :tag)
      .references(:term_data, :node_revisions)
      .where('term_data.name = ? OR term_data.name = ? OR term_data.parent = ?', tagname, other_tag, tagname)
  end

  # ============================================
  # Automated constructors for associated models

  def add_comment(params = {})
    thread = !comments.empty? && !comments.last.nil? && !comments.last.thread.nil? ? comments.last.next_thread : '01/'
    comment_via_status = params[:comment_via].nil? ? 0 : params[:comment_via].to_i
    user = User.find(params[:uid])
    status = user.first_time_poster && user.first_time_commenter ? 4 : 1
    c = Comment.includes(:node).new(pid: 0,
                    nid: nid,
                    uid: params[:uid],
                    subject: '',
                    hostname: '',
                    comment: params[:body],
                    status: status,
                    format: 1,
                    thread: thread,
                    timestamp: DateTime.now.to_i,
                    comment_via: comment_via_status,
                    message_id: params[:message_id],
                    tweet_id: params[:tweet_id])
    c.save
    tag_activity(c.cid) if c.valid?
    c
  end

  def tag_activity(cid)
    tids = tag.pluck(:tid)
    comment_id = "c#{cid}"
    Tag.update_tags_activity(tids, comment_id)
  end

  def new_revision(params)
    title = params[:title] || self.title
    Revision.new(nid: id,
                 uid: params[:uid],
                 title: title,
                 body: params[:body])
  end

  # handle creating a new note with attached revision and main image
  # this is kind of egregiously bad... must revise after
  # researching simultaneous creation of associated records
  def self.new_note(params)
    saved = false
    author = User.find(params[:uid])
    node = Node.new(uid:     author.uid,
                    title:   params[:title],
                    comment: 2,
                    type:    'note')
    node.status = 4 if author.first_time_poster
    node.save_draft if params[:draft] == "true"

    if node.valid? # is this not triggering title uniqueness validation?
      saved = true
      revision = false
      ActiveRecord::Base.transaction do
        node.save!
        revision = node.new_revision(uid:   author.uid,
                                     title: params[:title],
                                     body:  params[:body])
        if revision.valid?
          revision.save!
          node.vid = revision.vid
          # save main image
          if params[:main_image] && (params[:main_image] != '')
            img = Image.find params[:main_image]
            img.nid = node.id
            img.save
          end
          node.save!
          if node.status == 1
            node.notify
          end
        else
          saved = false
          node.destroy
          # and numerical title validation bug in https://github.com/publiclab/plots2/issues/10361
          raise ActiveRecord::Rollback
        end
        # prevent vid non-unique bug in https://github.com/publiclab/plots2/issues/7062
        raise ActiveRecord::Rollback if !node.valid? || node.vid == 0 || !revision.valid?
      end
    end
    [saved, node, revision]
  end

  def self.new_preview_note(params)
    author = User.find(params[:uid])
    lat, lon, precision = nil

    if params[:location].present?
      lat = params[:location][:latitude].to_f
      lon = params[:location][:longitude].to_f
    end

    node = Node.new(uid:     author.uid,
                    title:   params[:title],
                    latitude: lat,
                    longitude: lon,
                    comment: 2,
                    type:    'note')

    precision = node.decimals(lat.to_s) if params[:location].present?

    node.precision = precision
    revision = node.new_revision(uid:   author.uid,
                                title: params[:title],
                                body:  params[:body])
    if params[:main_image] && (params[:main_image] != '')
      img = Image.find_by(id: params[:main_image])
    end
    [node, img, revision]
  end

  def self.new_wiki(params)
    saved = false
    node = Node.new(uid: params[:uid],
                    title: params[:title],
                    type: 'page')
    if node.valid?
      revision = false
      saved = true
      ActiveRecord::Base.transaction do
        node.save!
        revision = node.new_revision(nid: node.id,
                                     uid: params[:uid],
                                     title: params[:title],
                                     body: params[:body])
        if revision.valid?
          revision.save!
          node.vid = revision.vid
          node.save!
          # node.notify # we don't yet notify of wiki page creations
        else
          saved = false
          node.destroy # clean up
        end
      end
    end
    [saved, node, revision]
  end

  # same as new_note or new_wiki but with arbitrary type -- use for maps, DRY out new_note and new_wiki
  def self.new_node(params)
    saved = false
    node = Node.new(uid: params[:uid],
                    title: params[:title],
                    type: params[:type])
    if node.valid?
      revision = false
      saved = true
      ActiveRecord::Base.transaction do
        node.save!
        revision = node.new_revision(nid: node.id,
                                     uid: params[:uid],
                                     title: params[:title],
                                     body: params[:body])
        if revision.valid?
          revision.save!
          node.vid = revision.vid
          node.save!
        else
          saved = false
          node.destroy # clean up
        end
      end
    end
    [saved, node, revision]
  end

  def barnstar
    power_tag_objects('barnstar').first
  end

  def barnstars
    power_tag_objects('barnstar')
  end

  def add_barnstar(tagname, giver)
    add_tag(tagname, giver)
    CommentMailer.notify_barnstar(giver, self).deliver_later
  end

  def add_tag(tagname, user)
    if user.status == 1
      tagname = tagname.downcase
      unless has_tag_without_aliasing(tagname)
        saved = false
        table_updated = false
        tag = Tag.find_by(name: tagname) || Tag.new(vid:         3, # vocabulary id; 1
                                                  name:        tagname,
                                                  description: '',
                                                  weight:      0)

        ActiveRecord::Base.transaction do
          if tag.valid?
            key = tag.name.split(':')[0]
            value = tag.name.split(':')[1]
            # add base tags:
            if ['question', 'upgrade', 'activity'].include?(key)
              add_tag(value, user)
            end
            # add sub-tags:
            subtags = {}
            subtags['pm'] = 'particulate-matter'
            subtags['h2s'] = 'hydrogen-sulfide'
            subtags['near-infrared-camera'] = 'multispectral-imaging'
            subtags['infragram'] = 'multispectral-imaging'
            subtags['odors'] = 'odor'
            subtags['oil'] = 'oil-and-gas'
            subtags['purple-air'] = 'purpleair'
            subtags['reagent'] = 'reagents'
            subtags['spectrometer'] = 'spectrometry'
            if subtags.include?(key)
              add_tag(subtags[key], user)
            end
            # parse date tags:
            if key == 'date'
              begin
                DateTime.strptime(value, '%m-%d-%Y').to_date.to_s(:long)
              rescue StandardError
                return [false, tag.destroy]
              end
            end
            tag.save!
            node_tag = NodeTag.new(tid: tag.id,
                                 uid: user.uid,
                                 date: DateTime.now.to_i,
                                 nid: id)

            # Adding lat/lon values into node table
            if key == 'lat'
              tagvalue = value
              table_updated = update_attributes(latitude: tagvalue, precision: decimals(tagvalue).to_s)
            elsif key == 'lon'
              tagvalue = value
              table_updated = update_attributes(longitude: tagvalue)
            end

            if node_tag.save
              saved = true
              # send email notification if there are subscribers, status is OK, and less than 1 month old
              isStatusValid = status == 3 || status == 4
              isMonthOld = created < (DateTime.now - 1.month).to_i
              unless tag.subscriptions.empty? || isStatusValid || !isMonthOld
                SubscriptionMailer.notify_tag_added(self, tag, user).deliver_later
              end
            else
              saved = false
              tag.destroy
            end
          end
        end
        return [saved, tag, table_updated]
      end
    end
  end

  def decimals(number)
    !number.include?('.') ? 0 : number.split('.').last.size
  end

  def delete_coord_attribute(tagname)
    if tagname.split(':')[0] == "lat"
      update_attributes(latitude: nil, precision: nil)
    else
      update_attributes(longitude: nil)
    end
  end

  def mentioned_users
    usernames = body.scan(Callouts.const_get(:FINDER))
    User.where(username: usernames.map { |m| m[1] }).uniq
  end

  def self.find_notes(author, date, title)
    Node.where(path: "/notes/#{author}/#{date}/#{title}").first
  end

  def self.find_wiki(title)
    Node.where(path: ["/#{title}", "/tool/#{title}", "/wiki/#{title}", "/place/#{title}"]).first
  end

  def self.research_notes
    nids = Node.where(type: 'note')
               .joins(:tag)
               .where('term_data.name LIKE ?', 'question:%')
               .group('node.nid')
               .collect(&:nid)

    # The nids variable contains nodes with the Tag name 'question'
    # that should be removed from the query
    # if the nids are empty, the query in the else block
    # will not return the valid notes
    Node.where(type: 'note').where.not(nid: nids)
  end

  def body_preview(length = 100)
    try(:latest).body_preview(length)
  end

  # so we can quickly fetch questions corresponding to this node
  # with node.questions
  def questions
    # override with a tag like `questions:h2s`
    tagname = if has_power_tag('questions')
                power_tag('questions')
              else
                slug_from_path
              end
    Node.where(status: 1, type: 'note')
        .includes(:revision, :tag)
        .references(:term_data)
        .where('term_data.name LIKE ?', "question:#{tagname}")
  end

  # all questions
  def self.questions
    Node.where(type: 'note')
        .joins(:tag)
        .where('term_data.name LIKE ?', 'question:%')
        .group('node.nid')
  end

  # all nodes with tagname
  def self.find_by_tag(tagname)
    Node.includes(:node_tag, :tag)
      .where('term_data.name = ? OR term_data.parent = ?', tagname, tagname)
      .references(:term_data, :node_tag)
  end

  # finds nodes by tag name, user id, and optional node type
  def self.find_by_tag_and_author(tagname, user_id, type = 'notes')

    node_type = 'note' if type == 'notes' || type == 'questions'
    node_type = 'page' if type == 'wiki'
    
    order = 'node_revisions.timestamp DESC'
    order = 'created DESC' if node_type == 'note'

    qids = Node.questions.where(status: 1).collect(&:nid)

    nodes = Tag.tagged_nodes_by_author(tagname, user_id)
      .includes(:revision)
      .references(:node_revisions)
      .where(status: 1, type: node_type)
      .order(order)

    nodes = nodes.where('node.nid NOT IN (?)', qids) if type == 'notes'
    nodes = nodes.where('node.nid IN (?)', qids) if type == 'questions'

    nodes
  end

  # so we can quickly fetch activities corresponding to this node
  # with node.activities
  def activities
    # override with a tag like `activities:h2s`
    tagname = if has_power_tag('activities')
                power_tag('activities')
              else
                slug_from_path
              end
    Node.activities(tagname)
  end

  # so we can call Node.activities('balloon-mapping')
  def self.activities(tagname)
    Node.where(status: 1, type: 'note')
        .includes(:revision, :tag)
        .references(:term_data)
        .where('term_data.name LIKE ?', "activity:#{tagname}")
  end

  # so we can quickly fetch upgrades corresponding to this node
  # with node.upgrades
  def upgrades
    # override with a tag like `upgrades:h2s`
    tagname = if has_power_tag('upgrades')
                node.power_tag('upgrades')
              else
                slug_from_path
              end
    Node.upgrades(tagname)
  end

  # so we can call Node.upgrades('balloon-mapping')
  def self.upgrades(tagname)
    Node.where(status: 1, type: 'note')
        .includes(:revision, :tag)
        .references(:term_data)
        .where('term_data.name LIKE ?', "upgrade:#{tagname}")
  end

  def can_tag(tagname, user, errors = false)
    one_split = tagname.split(':')[1]
    socials = { facebook: 'Facebook', github: 'Github', google_oauth2: 'Google', twitter: 'Twitter' }

    if tagname[0..4] == 'with:'
      if User.find_by_username_case_insensitive(one_split).nil?
        errors ? I18n.t('node.cannot_find_username') : false
      elsif author.uid != user.uid
        errors ? I18n.t('node.only_author_use_powertag') : false
      elsif one_split == user.username
        errors ? I18n.t('node.cannot_add_yourself_coauthor') : false
      else
        true
      end
    elsif tagname == 'format:raw' && user.role != 'admin'
      errors ? "Only admins may create raw pages." : false
    elsif tagname[0..4] == 'rsvp:' && user.username != one_split
      errors ? I18n.t('node.only_RSVP_for_yourself') : false
    elsif tagname == 'locked' && !user.can_moderate?
      errors ? I18n.t('node.only_admins_can_lock') : false
    elsif tagname == 'blog' && user.basic_user?
      errors ? 'Only moderators or admins can use this tag.' : false
    elsif tagname.split(':')[0] == 'redirect' && Node.where(slug: one_split).size <= 0
      errors ? I18n.t('node.page_does_not_exist') : false
    elsif socials[one_split&.to_sym].present?
      errors ? "This tag is used for associating a #{socials[one_split.to_sym]} account. <a href='https://publiclab.org/wiki/oauth'>Click here to read more </a>" : false
    elsif user.first_time_poster && !(user.username == self.author.username || self.coauthors&.exists?(username: user.username) || (['admin', 'moderator'].include? user.role))
      errors ? 'Adding tags to other people’s posts is not available to you until your own first post has been approved by site moderators' : false
    else
      true
    end
  end

  def replace(before, after, user)
    matches = latest.body.scan(before)
    if matches.size == 1
      revision = new_revision(uid: user.id,
                              body: latest.body.gsub(before, after))
      revision.save
    else
      false
    end
  end

  def is_liked_by(user)
    !NodeSelection.where(user_id: user.uid, nid: id, liking: true).empty?
  end

  def toggle_like(user)
    node_likes = NodeSelection.where(nid: id, liking: true)
                              .joins(:user)
                              .references(:rusers)
                              .where(liking: true)
                              .where('rusers.status': 1)
                              .size
    self.cached_likes = if is_liked_by(user)
                          node_likes - 1
                        else
                          node_likes + 1
                        end
  end

  def self.like(nid, user)
    # scope like variable outside the transaction
    like = nil
    count = nil

    ActiveRecord::Base.transaction do
      # Create the entry if it isn't already created.
      like = NodeSelection.where(user_id: user.uid,
                                 nid: nid).first_or_create
      like.liking = true
      node = Node.find(nid)
      if node.type == 'note' && !UserTag.exists?(node.uid, 'notify-likes-direct:false')
        SubscriptionMailer.notify_note_liked(node, like.user).deliver_later
      end
      if node.uid != user.id && UserTag.where(uid: user.id, value: ['notifications:all', 'notifications:like']).any?
        notification = Hash.new
        notification[:title] = "New Like on your research note"
        notification[:path] = node.path
        option = {
          body: "#{user.name} just liked your note #{node.title}",
          icon: "https://publiclab.org/logo.png"
        }
        notification[:option] = option
        User.send_browser_notification [user.id], notification
      end
      count = 1
      node.toggle_like(like.user)
      # Save the changes.
      node.save!
      like.save!
    end
    count
  end



  def self.unlike(nid, user)
    like = nil
    count = nil

    ActiveRecord::Base.transaction do
      like = NodeSelection.where(user_id: user.uid,
                                 nid: nid).first_or_create
      like.liking = false
      count = -1
      node = Node.find(nid)
      node.toggle_like(like.user)
      node.save!
      like.save!
    end
    count
  end

  # status = 3 for draft nodes,visible to author only
  def save_draft
    self.status = 3
    save
    self
  end

  def draft_url(base_url)
    token = slug.split('token:').last
    base_url + '/notes/show/' + nid.to_s + '/' + token
  end

  def comments_viewable_by(user)
    if user&.can_moderate?
      comments.where('status = 1 OR status = 4')
    elsif user
      comments.where('comments.status = 1 OR (comments.status = 4 AND comments.uid = ?)', user.uid)
    else
      comments.where(status: 1)
    end
  end

  def self.spam_graph_making(status)
    start = Time.now - 1.year
    fin = Time.now
    time_hash = {}
    week = start.to_date.step(fin.to_date, 7).count
    while week >= 1
      months = (fin - (week * 7 - 1).days)
      range = (fin.to_i - week.weeks.to_i)..(fin.to_i - (week - 1).weeks.to_i)
      nodes = Node.where(created: range).where(status: status).select(:created).size
      time_hash[months.to_f * 1000] = nodes
      week -= 1
    end
    time_hash
  end

  def notify_callout_users
    # notify mentioned users
    mentioned_users.each do |user|
      NodeMailer.notify_callout(self, user).deliver_later if user.username != author.username
    end
  end

  def self.sort_features(features, sorting_type)
    case sorting_type
    when 'title'
      features.sort_by(&:title.downcase)
    when 'last_edited'
      features.sort_by(&:changed).reverse!
    else
      features
    end
  end
end