2012-06-17 03:15:46 +02:00
|
|
|
class Story < ActiveRecord::Base
|
2012-07-01 00:41:00 +02:00
|
|
|
belongs_to :user
|
2014-04-09 00:51:12 +02:00
|
|
|
belongs_to :merged_into_story,
|
|
|
|
:class_name => "Story",
|
|
|
|
:foreign_key => "merged_story_id"
|
|
|
|
has_many :merged_stories,
|
|
|
|
:class_name => "Story",
|
|
|
|
:foreign_key => "merged_story_id"
|
2012-11-26 20:39:16 +01:00
|
|
|
has_many :taggings,
|
|
|
|
:autosave => true
|
2015-10-15 03:32:24 +02:00
|
|
|
has_many :suggested_taggings
|
|
|
|
has_many :suggested_titles
|
2014-01-07 23:58:22 +01:00
|
|
|
has_many :comments,
|
|
|
|
:inverse_of => :story
|
2012-06-17 03:15:46 +02:00
|
|
|
has_many :tags, :through => :taggings
|
2013-03-11 19:19:26 +01:00
|
|
|
has_many :votes, -> { where(:comment_id => nil) }
|
|
|
|
has_many :voters, -> { where('votes.comment_id' => nil) },
|
|
|
|
:through => :votes,
|
|
|
|
:source => :user
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-04-09 00:51:12 +02:00
|
|
|
scope :unmerged, -> { where(:merged_story_id => nil) }
|
|
|
|
|
2012-06-30 18:18:36 +02:00
|
|
|
validates_length_of :title, :in => 3..150
|
|
|
|
validates_length_of :description, :maximum => (64 * 1024)
|
2015-07-22 17:51:14 +02:00
|
|
|
validates_length_of :url, :maximum => 250, :allow_nil => true
|
2012-06-30 18:18:36 +02:00
|
|
|
validates_presence_of :user_id
|
|
|
|
|
2014-03-18 06:06:43 +01:00
|
|
|
DOWNVOTABLE_DAYS = 14
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
# after this many minutes old, a story cannot be edited
|
2015-12-08 03:08:36 +01:00
|
|
|
MAX_EDIT_MINS = (60 * 6)
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-02-17 17:13:08 +01:00
|
|
|
# days a story is considered recent, for resubmitting
|
2014-01-13 07:10:31 +01:00
|
|
|
RECENT_DAYS = 30
|
|
|
|
|
2015-10-15 17:02:26 +02:00
|
|
|
# users needed to make similar suggestions go live
|
2015-12-03 23:02:32 +01:00
|
|
|
SUGGESTION_QUORUM = 3
|
2015-10-15 17:02:26 +02:00
|
|
|
|
2015-03-10 23:41:40 +01:00
|
|
|
attr_accessor :vote, :already_posted_story, :previewing, :seen_previous,
|
|
|
|
:is_hidden_by_cur_user
|
2015-10-15 17:02:26 +02:00
|
|
|
attr_accessor :editor, :moderation_reason, :merge_story_short_id,
|
|
|
|
:editing_from_suggestions
|
2015-03-10 23:41:40 +01:00
|
|
|
attr_accessor :fetching_ip
|
2015-10-11 20:00:34 +02:00
|
|
|
attr_writer :fetched_content
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-03-04 00:13:00 +01:00
|
|
|
before_validation :assign_short_id_and_upvote,
|
2013-07-20 05:05:00 +02:00
|
|
|
:on => :create
|
2015-01-13 19:41:05 +01:00
|
|
|
before_create :assign_initial_hotness
|
2012-11-12 18:02:18 +01:00
|
|
|
before_save :log_moderation
|
2015-06-19 20:30:32 +02:00
|
|
|
before_save :fix_bogus_chars
|
2014-03-04 00:13:00 +01:00
|
|
|
after_create :mark_submitter, :record_initial_upvote
|
2015-10-15 04:17:54 +02:00
|
|
|
after_save :update_merged_into_story_comments, :recalculate_hotness!
|
2013-01-23 07:15:05 +01:00
|
|
|
|
2012-06-30 21:14:35 +02:00
|
|
|
validate do
|
|
|
|
if self.url.present?
|
|
|
|
# URI.parse is not very lenient, so we can't use it
|
|
|
|
|
2015-07-24 06:25:14 +02:00
|
|
|
if self.url.match(/\Ahttps?:\/\/([^\.]+\.)+[a-z]+(\/|\z)/i)
|
2014-01-13 07:10:31 +01:00
|
|
|
if self.new_record? && (s = Story.find_similar_by_url(self.url))
|
2012-06-30 21:14:35 +02:00
|
|
|
self.already_posted_story = s
|
2014-01-13 07:10:31 +01:00
|
|
|
if s.is_recent?
|
|
|
|
errors.add(:url, "has already been submitted within the past " <<
|
|
|
|
"#{RECENT_DAYS} days")
|
|
|
|
end
|
2012-06-30 21:14:35 +02:00
|
|
|
end
|
|
|
|
else
|
|
|
|
errors.add(:url, "is not valid")
|
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
elsif self.description.to_s.strip == ""
|
2012-07-01 01:00:05 +02:00
|
|
|
errors.add(:description, "must contain text if no URL posted")
|
|
|
|
end
|
|
|
|
|
2015-07-31 00:15:48 +02:00
|
|
|
if !errors.any? && self.url.blank?
|
|
|
|
self.user_is_author = true
|
|
|
|
end
|
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
check_tags
|
2012-06-30 21:14:35 +02:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-01-13 07:10:31 +01:00
|
|
|
def self.find_similar_by_url(url)
|
2012-07-18 20:15:48 +02:00
|
|
|
urls = [ url.to_s ]
|
|
|
|
urls2 = [ url.to_s ]
|
2012-07-18 01:17:46 +02:00
|
|
|
|
|
|
|
# https
|
|
|
|
urls.each do |u|
|
|
|
|
urls2.push u.gsub(/^http:\/\//i, "https://")
|
|
|
|
urls2.push u.gsub(/^https:\/\//i, "http://")
|
|
|
|
end
|
|
|
|
urls = urls2.clone
|
|
|
|
|
|
|
|
# trailing slash
|
|
|
|
urls.each do |u|
|
|
|
|
urls2.push u.gsub(/\/+\z/, "")
|
2012-07-18 20:15:48 +02:00
|
|
|
urls2.push (u + "/")
|
2012-07-18 01:17:46 +02:00
|
|
|
end
|
|
|
|
urls = urls2.clone
|
|
|
|
|
|
|
|
# www prefix
|
|
|
|
urls.each do |u|
|
|
|
|
urls2.push u.gsub(/^(https?:\/\/)www\d*\./i) {|_| $1 }
|
|
|
|
urls2.push u.gsub(/^(https?:\/\/)/i) {|_| "#{$1}www." }
|
|
|
|
end
|
|
|
|
urls = urls2.clone
|
|
|
|
|
2014-03-28 16:45:01 +01:00
|
|
|
if s = Story.where(:url => urls, :is_expired => false).order("id DESC").first
|
2012-07-18 01:17:46 +02:00
|
|
|
return s
|
2012-07-11 21:22:26 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
2012-10-09 18:06:43 +02:00
|
|
|
def self.recalculate_all_hotnesses!
|
2014-05-07 04:32:17 +02:00
|
|
|
Story.all.order("id DESC").each do |s|
|
2012-10-09 18:06:43 +02:00
|
|
|
s.recalculate_hotness!
|
|
|
|
end
|
2014-07-02 17:09:03 +02:00
|
|
|
true
|
2012-10-09 18:06:43 +02:00
|
|
|
end
|
|
|
|
|
2015-01-28 22:02:40 +01:00
|
|
|
def self.score_sql
|
|
|
|
"(CAST(upvotes AS #{votes_cast_type}) - " <<
|
|
|
|
"CAST(downvotes AS #{votes_cast_type}))"
|
|
|
|
end
|
|
|
|
|
2014-10-04 00:02:02 +02:00
|
|
|
def self.votes_cast_type
|
|
|
|
Story.connection.adapter_name.match(/mysql/i) ? "signed" : "integer"
|
|
|
|
end
|
|
|
|
|
2012-12-17 03:00:41 +01:00
|
|
|
def as_json(options = {})
|
2015-08-04 02:12:39 +02:00
|
|
|
h = [
|
2012-12-17 03:00:41 +01:00
|
|
|
:short_id,
|
2015-08-04 02:12:39 +02:00
|
|
|
:short_id_url,
|
2012-12-19 00:16:02 +01:00
|
|
|
:created_at,
|
2012-12-17 03:00:41 +01:00
|
|
|
:title,
|
|
|
|
:url,
|
2015-08-04 02:12:39 +02:00
|
|
|
:score,
|
|
|
|
:upvotes,
|
|
|
|
:downvotes,
|
|
|
|
{ :comment_count => :comments_count },
|
|
|
|
{ :description => :markeddown_description },
|
|
|
|
:comments_url,
|
|
|
|
{ :submitter_user => :user },
|
|
|
|
{ :tags => self.tags.map{|t| t.tag }.sort },
|
|
|
|
]
|
2012-12-30 19:13:19 +01:00
|
|
|
|
|
|
|
if options && options[:with_comments]
|
2015-08-04 02:12:39 +02:00
|
|
|
h.push({ :comments => options[:with_comments] })
|
2012-12-30 19:13:19 +01:00
|
|
|
end
|
|
|
|
|
2015-08-04 02:12:39 +02:00
|
|
|
js = {}
|
|
|
|
h.each do |k|
|
|
|
|
if k.is_a?(Symbol)
|
|
|
|
js[k] = self.send(k)
|
|
|
|
elsif k.is_a?(Hash)
|
|
|
|
if k.values.first.is_a?(Symbol)
|
|
|
|
js[k.keys.first] = self.send(k.values.first)
|
|
|
|
else
|
|
|
|
js[k.keys.first] = k.values.first
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
js
|
2012-12-17 03:00:41 +01:00
|
|
|
end
|
|
|
|
|
2015-01-13 19:41:05 +01:00
|
|
|
def assign_initial_hotness
|
|
|
|
self.hotness = self.calculated_hotness
|
|
|
|
end
|
|
|
|
|
2014-03-04 00:13:00 +01:00
|
|
|
def assign_short_id_and_upvote
|
2013-01-23 07:15:05 +01:00
|
|
|
self.short_id = ShortId.new(self.class).generate
|
2014-03-04 00:13:00 +01:00
|
|
|
self.upvotes = 1
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def calculated_hotness
|
2015-12-15 19:35:18 +01:00
|
|
|
base = self.tags.map{|t| t.hotness_mod }.sum
|
2014-08-26 01:11:53 +02:00
|
|
|
|
2015-12-15 19:35:18 +01:00
|
|
|
# give a story's comment votes some weight (unless the hotness mod is
|
|
|
|
# negative), but ignore the story submitter's own comments
|
|
|
|
if base < 0
|
|
|
|
cpoints = 0
|
|
|
|
else
|
|
|
|
cpoints = self.comments.where("user_id <> ?", self.user_id).
|
|
|
|
select(:upvotes, :downvotes).map{|c| c.upvotes + 1 - c.downvotes }.
|
|
|
|
inject(&:+).to_f * 0.5
|
|
|
|
end
|
2014-09-16 17:53:25 +02:00
|
|
|
|
2015-12-31 16:14:09 +01:00
|
|
|
# mix in any stories this one cannibalized
|
|
|
|
cpoints += self.merged_stories.map{|s| s.score }.inject(&:+).to_f
|
|
|
|
|
2014-03-18 06:06:43 +01:00
|
|
|
# don't immediately kill stories at 0 by bumping up score by one
|
2015-01-01 17:25:14 +01:00
|
|
|
order = Math.log([ (score + 1).abs + cpoints, 1 ].max, 10)
|
2014-02-17 17:07:36 +01:00
|
|
|
if score > 0
|
|
|
|
sign = 1
|
|
|
|
elsif score < 0
|
|
|
|
sign = -1
|
2012-09-02 16:50:07 +02:00
|
|
|
else
|
2014-02-17 17:07:36 +01:00
|
|
|
sign = 0
|
2012-09-02 16:50:07 +02:00
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
# TODO: as the site grows, shrink this down to 12 or so.
|
2015-07-24 07:34:00 +02:00
|
|
|
window = 60 * 60 * 24
|
2012-09-02 16:50:07 +02:00
|
|
|
|
2015-01-13 19:41:05 +01:00
|
|
|
return -((order * sign) + base +
|
|
|
|
((self.created_at || Time.now).to_f / window)).round(7)
|
2012-09-02 16:50:07 +02:00
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def can_be_seen_by_user?(user)
|
|
|
|
if is_gone? && !(user && (user.is_moderator? || user.id == self.user_id))
|
|
|
|
return false
|
|
|
|
end
|
2012-07-03 21:29:00 +02:00
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
true
|
2012-07-01 20:38:01 +02:00
|
|
|
end
|
|
|
|
|
2015-10-15 16:38:26 +02:00
|
|
|
def can_have_suggestions_from_user?(user)
|
2015-10-17 17:38:48 +02:00
|
|
|
if !user
|
|
|
|
return false
|
|
|
|
end
|
2015-10-15 16:38:26 +02:00
|
|
|
|
2015-10-17 17:38:48 +02:00
|
|
|
if user.id == self.user_id
|
2015-10-15 16:38:26 +02:00
|
|
|
return false
|
|
|
|
end
|
2015-10-17 17:38:48 +02:00
|
|
|
|
|
|
|
if self.tags.select{|t| t.privileged? }.any?
|
|
|
|
return false
|
|
|
|
end
|
|
|
|
|
|
|
|
return true
|
2015-10-15 16:38:26 +02:00
|
|
|
end
|
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
# this has to happen just before save rather than in tags_a= because we need
|
|
|
|
# to have a valid user_id
|
2012-09-20 04:13:20 +02:00
|
|
|
def check_tags
|
2014-03-06 20:54:30 +01:00
|
|
|
u = self.editor || self.user
|
|
|
|
|
2012-11-26 20:39:16 +01:00
|
|
|
self.taggings.each do |t|
|
2014-03-06 20:54:30 +01:00
|
|
|
if !t.tag.valid_for?(u)
|
|
|
|
raise "#{u.username} does not have permission to use privileged " <<
|
|
|
|
"tag #{t.tag.tag}"
|
2015-03-19 18:10:52 +01:00
|
|
|
elsif t.tag.inactive? && !t.new_record? && !t.marked_for_destruction?
|
2014-02-21 17:51:48 +01:00
|
|
|
# stories can have inactive tags as long as they existed before
|
2014-03-06 20:54:30 +01:00
|
|
|
raise "#{u.username} cannot add inactive tag #{t.tag.tag}"
|
2012-09-20 04:13:20 +02:00
|
|
|
end
|
|
|
|
end
|
2012-11-12 18:02:18 +01:00
|
|
|
|
2012-11-26 20:39:16 +01:00
|
|
|
if !self.taggings.reject{|t| t.marked_for_destruction? || t.tag.is_media?
|
|
|
|
}.any?
|
2013-02-14 01:50:51 +01:00
|
|
|
errors.add(:base, "Must have at least one non-media (PDF, video) " <<
|
|
|
|
"tag. If no tags apply to your content, it probably doesn't " <<
|
|
|
|
"belong here.")
|
2012-11-12 18:02:18 +01:00
|
|
|
end
|
2012-09-20 04:13:20 +02:00
|
|
|
end
|
|
|
|
|
2015-01-03 00:02:55 +01:00
|
|
|
def comments_path
|
|
|
|
"#{short_id_path}/#{self.title_as_url}"
|
|
|
|
end
|
|
|
|
|
2012-07-03 18:59:50 +02:00
|
|
|
def comments_url
|
|
|
|
"#{short_id_url}/#{self.title_as_url}"
|
2012-07-02 19:13:12 +02:00
|
|
|
end
|
2013-01-23 07:15:05 +01:00
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def description=(desc)
|
|
|
|
self[:description] = desc.to_s.rstrip
|
|
|
|
self.markeddown_description = self.generated_markeddown_description
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2015-07-31 01:12:57 +02:00
|
|
|
def description_or_story_cache(chars = 0)
|
|
|
|
s = if self.description.present?
|
2015-07-31 00:56:09 +02:00
|
|
|
self.markeddown_description.gsub(/<[^>]*>/, "")
|
|
|
|
else
|
|
|
|
self.story_cache
|
|
|
|
end
|
2015-07-31 01:12:57 +02:00
|
|
|
|
|
|
|
if chars > 0
|
|
|
|
# remove last truncated word
|
2015-07-31 15:03:10 +02:00
|
|
|
s = s.to_s[0, chars].gsub(/ [^ ]*\z/, "")
|
2015-07-31 01:12:57 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
HTMLEntities.new.decode(s.to_s)
|
2015-07-31 00:56:09 +02:00
|
|
|
end
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def domain
|
|
|
|
if self.url.blank?
|
|
|
|
nil
|
|
|
|
else
|
2014-06-05 14:54:48 +02:00
|
|
|
# URI.parse is not very lenient, so we can't use it
|
|
|
|
self.url.
|
2015-08-09 22:21:22 +02:00
|
|
|
gsub(/^[^:]+:\/\//, ""). # proto
|
|
|
|
gsub(/\/.*/, ""). # path
|
|
|
|
gsub(/:\d+$/, ""). # possible port
|
|
|
|
gsub(/^www\d*\.(.+\..+)/, '\1') # possible "www3." in host unless
|
|
|
|
# it's the only non-TLD
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-01-29 17:04:02 +01:00
|
|
|
def domain_search_url
|
|
|
|
"/search?q=domain:#{self.domain}&order=newest"
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def fetch_story_cache!
|
|
|
|
if self.url.present?
|
|
|
|
self.story_cache = StoryCacher.get_story_text(self.url)
|
2012-07-02 03:47:25 +02:00
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def generated_markeddown_description
|
|
|
|
Markdowner.to_html(self.description, { :allow_images => true })
|
|
|
|
end
|
|
|
|
|
|
|
|
def give_upvote_or_downvote_and_recalculate_hotness!(upvote, downvote)
|
|
|
|
self.upvotes += upvote.to_i
|
|
|
|
self.downvotes += downvote.to_i
|
|
|
|
|
|
|
|
Story.connection.execute("UPDATE #{Story.table_name} SET " <<
|
|
|
|
"upvotes = COALESCE(upvotes, 0) + #{upvote.to_i}, " <<
|
|
|
|
"downvotes = COALESCE(downvotes, 0) + #{downvote.to_i}, " <<
|
|
|
|
"hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}")
|
|
|
|
end
|
|
|
|
|
2014-03-13 16:51:12 +01:00
|
|
|
def hider_count
|
2015-02-11 18:37:03 +01:00
|
|
|
@hider_count ||= HiddenStory.where(:story_id => self.id).count
|
2014-03-13 16:51:12 +01:00
|
|
|
end
|
|
|
|
|
2015-10-16 20:08:05 +02:00
|
|
|
def html_class_for_user
|
2015-07-31 00:15:48 +02:00
|
|
|
c = []
|
|
|
|
if !self.user.is_active?
|
|
|
|
c.push "inactive_user"
|
|
|
|
elsif self.user.is_new?
|
|
|
|
c.push "new_user"
|
|
|
|
elsif self.user_is_author?
|
|
|
|
c.push "user_is_author"
|
|
|
|
end
|
|
|
|
|
|
|
|
c.join("")
|
|
|
|
end
|
|
|
|
|
2014-03-18 06:06:43 +01:00
|
|
|
def is_downvotable?
|
2015-02-11 18:37:03 +01:00
|
|
|
return true
|
2014-03-18 06:06:43 +01:00
|
|
|
if self.created_at
|
|
|
|
Time.now - self.created_at <= DOWNVOTABLE_DAYS.days
|
|
|
|
else
|
|
|
|
false
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def is_editable_by_user?(user)
|
|
|
|
if user && user.is_moderator?
|
|
|
|
return true
|
|
|
|
elsif user && user.id == self.user_id
|
|
|
|
if self.is_moderated?
|
|
|
|
return false
|
|
|
|
else
|
|
|
|
return (Time.now.to_i - self.created_at.to_i < (60 * MAX_EDIT_MINS))
|
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
else
|
2014-02-17 17:07:36 +01:00
|
|
|
return false
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
2014-02-17 17:07:36 +01:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def is_gone?
|
|
|
|
is_expired?
|
|
|
|
end
|
2012-07-10 18:53:05 +02:00
|
|
|
|
2015-02-11 18:37:03 +01:00
|
|
|
def is_hidden_by_user?(user)
|
|
|
|
!!HiddenStory.where(:user_id => user.id, :story_id => self.id).first
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def is_recent?
|
|
|
|
self.created_at >= RECENT_DAYS.days.ago
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
2012-12-17 03:00:41 +01:00
|
|
|
|
2015-01-06 21:22:18 +01:00
|
|
|
def is_unavailable
|
|
|
|
self.unavailable_at != nil
|
|
|
|
end
|
|
|
|
def is_unavailable=(what)
|
|
|
|
self.unavailable_at = (what.to_i == 1 && !self.is_unavailable ?
|
|
|
|
Time.now : nil)
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def is_undeletable_by_user?(user)
|
|
|
|
if user && user.is_moderator?
|
|
|
|
return true
|
|
|
|
elsif user && user.id == self.user_id && !self.is_moderated?
|
|
|
|
return true
|
|
|
|
else
|
|
|
|
return false
|
|
|
|
end
|
2012-12-17 03:00:41 +01:00
|
|
|
end
|
2013-01-23 07:15:05 +01:00
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def log_moderation
|
2015-10-15 17:02:26 +02:00
|
|
|
if self.new_record? ||
|
|
|
|
(!self.editing_from_suggestions &&
|
|
|
|
(!self.editor || self.editor.id == self.user_id))
|
2014-02-17 17:07:36 +01:00
|
|
|
return
|
2012-12-09 05:37:30 +01:00
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
all_changes = self.changes.merge(self.tagging_changes)
|
2015-01-06 21:22:18 +01:00
|
|
|
all_changes.delete("unavailable_at")
|
|
|
|
|
|
|
|
if !all_changes.any?
|
|
|
|
return
|
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
m = Moderation.new
|
2015-10-15 17:02:26 +02:00
|
|
|
if self.editing_from_suggestions
|
|
|
|
m.is_from_suggestions = true
|
|
|
|
else
|
|
|
|
m.moderator_user_id = self.editor.try(:id)
|
|
|
|
end
|
2014-02-17 17:07:36 +01:00
|
|
|
m.story_id = self.id
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
if all_changes["is_expired"] && self.is_expired?
|
|
|
|
m.action = "deleted story"
|
|
|
|
elsif all_changes["is_expired"] && !self.is_expired?
|
|
|
|
m.action = "undeleted story"
|
|
|
|
else
|
2014-04-09 00:51:12 +02:00
|
|
|
m.action = all_changes.map{|k,v|
|
|
|
|
if k == "merged_story_id"
|
|
|
|
if v[1]
|
|
|
|
"merged into #{self.merged_into_story.short_id} " <<
|
|
|
|
"(#{self.merged_into_story.title})"
|
|
|
|
else
|
|
|
|
"unmerged from another story"
|
|
|
|
end
|
|
|
|
else
|
|
|
|
"changed #{k} from #{v[0].inspect} to #{v[1].inspect}"
|
|
|
|
end
|
|
|
|
}.join(", ")
|
2014-02-17 17:07:36 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
m.reason = self.moderation_reason
|
|
|
|
m.save
|
|
|
|
|
|
|
|
self.is_moderated = true
|
2012-07-05 17:08:14 +02:00
|
|
|
end
|
|
|
|
|
2013-06-25 20:58:52 +02:00
|
|
|
def mailing_list_message_id
|
2013-06-30 08:29:51 +02:00
|
|
|
"story.#{short_id}.#{created_at.to_i}@#{Rails.application.domain}"
|
2013-06-25 20:58:52 +02:00
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def mark_submitter
|
|
|
|
Keystore.increment_value_for("user:#{self.user_id}:stories_submitted")
|
|
|
|
end
|
|
|
|
|
2014-04-09 00:51:12 +02:00
|
|
|
def merge_into_story!(story)
|
|
|
|
self.merged_story_id = story.id
|
|
|
|
self.save!
|
|
|
|
end
|
|
|
|
|
|
|
|
def merged_comments
|
|
|
|
# TODO: make this a normal has_many?
|
|
|
|
Comment.where(:story_id => Story.select(:id).
|
|
|
|
where(:merged_story_id => self.id) + [ self.id ])
|
|
|
|
end
|
|
|
|
|
|
|
|
def merge_story_short_id=(sid)
|
|
|
|
self.merged_story_id = sid.present??
|
|
|
|
Story.where(:short_id => sid).first.id : nil
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def recalculate_hotness!
|
|
|
|
update_column :hotness, calculated_hotness
|
|
|
|
end
|
|
|
|
|
2014-03-04 00:13:00 +01:00
|
|
|
def record_initial_upvote
|
|
|
|
Vote.vote_thusly_on_story_or_comment_for_user_because(1, self.id, nil,
|
|
|
|
self.user_id, nil, false)
|
|
|
|
end
|
|
|
|
|
2015-06-19 20:30:32 +02:00
|
|
|
def fix_bogus_chars
|
2016-01-20 20:41:02 +01:00
|
|
|
# this is needlessly complicated to work around character encoding issues
|
|
|
|
# that arise when doing just self.title.to_s.gsub(160.chr, "")
|
2015-06-19 20:30:32 +02:00
|
|
|
self.title = self.title.to_s.split("").map{|chr|
|
|
|
|
if chr.ord == 160
|
|
|
|
" "
|
|
|
|
else
|
|
|
|
chr
|
|
|
|
end
|
|
|
|
}.join("")
|
|
|
|
|
|
|
|
true
|
|
|
|
end
|
|
|
|
|
2015-01-05 19:06:24 +01:00
|
|
|
def score
|
|
|
|
upvotes - downvotes
|
|
|
|
end
|
|
|
|
|
2015-01-03 00:02:55 +01:00
|
|
|
def short_id_path
|
|
|
|
Rails.application.routes.url_helpers.root_path + "s/#{self.short_id}"
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def short_id_url
|
2015-01-03 00:02:55 +01:00
|
|
|
Rails.application.root_url + "s/#{self.short_id}"
|
2014-02-17 17:07:36 +01:00
|
|
|
end
|
|
|
|
|
2015-01-05 19:06:24 +01:00
|
|
|
def sorted_taggings
|
|
|
|
self.taggings.sort_by{|t| t.tag.tag }.sort_by{|t| t.tag.is_media?? -1 : 0 }
|
2014-02-17 17:07:36 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
def tagging_changes
|
|
|
|
old_tags_a = self.taggings.reject{|tg| tg.new_record? }.map{|tg|
|
|
|
|
tg.tag.tag }.join(" ")
|
|
|
|
new_tags_a = self.taggings.reject{|tg| tg.marked_for_destruction?
|
|
|
|
}.map{|tg| tg.tag.tag }.join(" ")
|
|
|
|
|
|
|
|
if old_tags_a == new_tags_a
|
|
|
|
{}
|
|
|
|
else
|
|
|
|
{ "tags" => [ old_tags_a, new_tags_a ] }
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2012-07-12 01:28:27 +02:00
|
|
|
@_tags_a = []
|
2012-06-17 03:15:46 +02:00
|
|
|
def tags_a
|
2015-10-15 03:32:24 +02:00
|
|
|
@_tags_a ||= self.taggings.reject{|t| t.marked_for_destruction?
|
|
|
|
}.map{|t| t.tag.tag }
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
|
2012-11-26 20:39:16 +01:00
|
|
|
def tags_a=(new_tag_names_a)
|
|
|
|
self.taggings.each do |tagging|
|
|
|
|
if !new_tag_names_a.include?(tagging.tag.tag)
|
|
|
|
tagging.mark_for_destruction
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2012-11-26 20:39:16 +01:00
|
|
|
new_tag_names_a.each do |tag_name|
|
2013-12-24 04:20:06 +01:00
|
|
|
if tag_name.to_s != "" && !self.tags.exists?(:tag => tag_name)
|
2014-02-21 17:51:48 +01:00
|
|
|
if t = Tag.active.where(:tag => tag_name).first
|
2012-11-26 20:39:16 +01:00
|
|
|
# we can't lookup whether the user is allowed to use this tag yet
|
|
|
|
# because we aren't assured to have a user_id by now; we'll do it in
|
|
|
|
# the validation with check_tags
|
|
|
|
tg = self.taggings.build
|
|
|
|
tg.tag_id = t.id
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2013-01-23 07:15:05 +01:00
|
|
|
|
2015-10-15 03:32:24 +02:00
|
|
|
def save_suggested_tags_a_for_user!(new_tag_names_a, user)
|
|
|
|
st = self.suggested_taggings.where(:user_id => user.id)
|
|
|
|
|
|
|
|
st.each do |tagging|
|
|
|
|
if !new_tag_names_a.include?(tagging.tag.tag)
|
|
|
|
tagging.destroy
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
st.reload
|
|
|
|
|
|
|
|
new_tag_names_a.each do |tag_name|
|
|
|
|
# XXX: AR bug? st.exists?(:tag => tag_name) does not work
|
|
|
|
if tag_name.to_s != "" && !st.map{|x| x.tag.tag }.include?(tag_name)
|
|
|
|
if (t = Tag.active.where(:tag => tag_name).first) &&
|
|
|
|
t.valid_for?(user)
|
|
|
|
tg = self.suggested_taggings.build
|
|
|
|
tg.user_id = user.id
|
|
|
|
tg.tag_id = t.id
|
|
|
|
tg.save!
|
|
|
|
|
|
|
|
st.reload
|
|
|
|
else
|
|
|
|
next
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-10-15 17:02:26 +02:00
|
|
|
# if enough users voted on the same set of replacement tags, do it
|
|
|
|
tag_votes = {}
|
|
|
|
self.suggested_taggings.group_by(&:user_id).each do |u,stg|
|
|
|
|
stg.each do |st|
|
|
|
|
tag_votes[st.tag.tag] ||= 0
|
|
|
|
tag_votes[st.tag.tag] += 1
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
final_tags = []
|
|
|
|
tag_votes.each do |k,v|
|
|
|
|
if v >= SUGGESTION_QUORUM
|
|
|
|
final_tags.push k
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
if final_tags.any? && (final_tags.sort != self.tags_a.sort)
|
|
|
|
Rails.logger.info "[s#{self.id}] promoting suggested tags " <<
|
|
|
|
"#{final_tags.inspect} instead of #{self.tags_a.inspect}"
|
|
|
|
self.editor = nil
|
|
|
|
self.editing_from_suggestions = true
|
|
|
|
self.moderation_reason = "Automatically changed from user suggestions"
|
|
|
|
self.tags_a = final_tags.sort
|
|
|
|
if !self.save
|
|
|
|
Rails.logger.error "[s#{self.id}] failed auto promoting: " <<
|
|
|
|
self.errors.inspect
|
|
|
|
end
|
|
|
|
end
|
2015-10-15 03:32:24 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
def save_suggested_title_for_user!(title, user)
|
|
|
|
st = self.suggested_titles.where(:user_id => user.id).first
|
|
|
|
if !st
|
|
|
|
st = self.suggested_titles.build
|
|
|
|
st.user_id = user.id
|
|
|
|
end
|
|
|
|
st.title = title
|
|
|
|
st.save!
|
2015-10-17 01:28:57 +02:00
|
|
|
|
|
|
|
# if enough users voted on the same exact title, save it
|
|
|
|
title_votes = {}
|
|
|
|
self.suggested_titles.each do |st|
|
|
|
|
title_votes[st.title] ||= 0
|
|
|
|
title_votes[st.title] += 1
|
|
|
|
end
|
|
|
|
|
|
|
|
title_votes.sort_by{|k,v| v }.reverse.each do |kv|
|
|
|
|
if kv[1] >= SUGGESTION_QUORUM
|
|
|
|
Rails.logger.info "[s#{self.id}] promoting suggested title " <<
|
|
|
|
"#{kv[0].inspect} instead of #{self.title.inspect}"
|
|
|
|
self.editor = nil
|
|
|
|
self.editing_from_suggestions = true
|
|
|
|
self.moderation_reason = "Automatically changed from user suggestions"
|
|
|
|
self.title = kv[0]
|
|
|
|
if !self.save
|
|
|
|
Rails.logger.error "[s#{self.id}] failed auto promoting: " <<
|
|
|
|
self.errors.inspect
|
|
|
|
end
|
|
|
|
|
|
|
|
break
|
|
|
|
end
|
|
|
|
end
|
2015-10-15 03:32:24 +02:00
|
|
|
end
|
|
|
|
|
2012-06-30 18:18:36 +02:00
|
|
|
def title=(t)
|
|
|
|
# change unicode whitespace characters into real spaces
|
2013-05-26 19:32:54 +02:00
|
|
|
self[:title] = t.strip
|
2012-06-30 18:18:36 +02:00
|
|
|
end
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def title_as_url
|
|
|
|
u = self.title.downcase.gsub(/[^a-z0-9_-]/, "_")
|
|
|
|
while u.match(/__/)
|
|
|
|
u.gsub!("__", "_")
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
2012-07-02 18:12:24 +02:00
|
|
|
u.gsub(/^_+/, "").gsub(/_+$/, "")
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def to_param
|
|
|
|
self.short_id
|
|
|
|
end
|
|
|
|
|
2015-01-06 21:22:18 +01:00
|
|
|
def update_availability
|
|
|
|
if self.is_unavailable && !self.unavailable_at
|
|
|
|
self.unavailable_at = Time.now
|
|
|
|
elsif self.unavailable_at && !self.is_unavailable
|
|
|
|
self.unavailable_at = nil
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def update_comments_count!
|
2014-04-09 00:51:12 +02:00
|
|
|
comments = self.merged_comments.arrange_for_user(nil)
|
2014-02-17 17:07:36 +01:00
|
|
|
|
|
|
|
# calculate count after removing deleted comments and threads
|
2014-05-07 04:32:17 +02:00
|
|
|
self.update_column :comments_count,
|
|
|
|
(self.comments_count = comments.count{|c| !c.is_gone? })
|
|
|
|
|
|
|
|
self.recalculate_hotness!
|
2014-02-17 17:07:36 +01:00
|
|
|
end
|
|
|
|
|
2014-04-09 00:51:12 +02:00
|
|
|
def update_merged_into_story_comments
|
|
|
|
if self.merged_into_story
|
|
|
|
self.merged_into_story.update_comments_count!
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2013-03-26 18:22:23 +01:00
|
|
|
def url=(u)
|
|
|
|
# strip out stupid google analytics parameters
|
|
|
|
if u && (m = u.match(/\A([^\?]+)\?(.+)\z/))
|
|
|
|
params = m[2].split("&")
|
|
|
|
params.reject!{|p|
|
|
|
|
p.match(/^utm_(source|medium|campaign|term|content)=/) }
|
|
|
|
|
|
|
|
u = m[1] << (params.any?? "?" << params.join("&") : "")
|
|
|
|
end
|
|
|
|
|
2013-07-20 05:07:03 +02:00
|
|
|
self[:url] = u.to_s.strip
|
2013-03-26 18:22:23 +01:00
|
|
|
end
|
|
|
|
|
|
|
|
def url_is_editable_by_user?(user)
|
|
|
|
if self.new_record?
|
|
|
|
true
|
|
|
|
elsif user && user.is_moderator? && self.url.present?
|
|
|
|
true
|
|
|
|
else
|
|
|
|
false
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-01-03 00:02:55 +01:00
|
|
|
def url_or_comments_path
|
|
|
|
self.url.blank? ? self.comments_path : self.url
|
|
|
|
end
|
|
|
|
|
2014-02-17 17:07:36 +01:00
|
|
|
def url_or_comments_url
|
|
|
|
self.url.blank? ? self.comments_url : self.url
|
2012-07-05 02:33:12 +02:00
|
|
|
end
|
2014-03-18 06:06:43 +01:00
|
|
|
|
|
|
|
def vote_summary_for(user)
|
|
|
|
r_counts = {}
|
|
|
|
r_whos = {}
|
2014-03-24 16:24:43 +01:00
|
|
|
Vote.where(:story_id => self.id, :comment_id => nil).where("vote != 0").each do |v|
|
2014-03-18 06:06:43 +01:00
|
|
|
r_counts[v.reason.to_s] ||= 0
|
|
|
|
r_counts[v.reason.to_s] += v.vote
|
|
|
|
if user && user.is_moderator?
|
|
|
|
r_whos[v.reason.to_s] ||= []
|
|
|
|
r_whos[v.reason.to_s].push v.user.username
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
r_counts.keys.sort.map{|k|
|
|
|
|
if k == ""
|
|
|
|
"+#{r_counts[k]}"
|
|
|
|
else
|
|
|
|
"#{r_counts[k]} " +
|
2014-03-24 16:24:43 +01:00
|
|
|
(Vote::STORY_REASONS[k] || Vote::OLD_STORY_REASONS[k] || k) +
|
2014-03-18 06:06:43 +01:00
|
|
|
(user && user.is_moderator?? " (#{r_whos[k].join(", ")})" : "")
|
|
|
|
end
|
|
|
|
}.join(", ")
|
|
|
|
end
|
2015-03-10 23:41:40 +01:00
|
|
|
|
2015-12-02 18:01:27 +01:00
|
|
|
def fetched_attributes
|
|
|
|
if @fetched_attributes
|
|
|
|
return @fetched_attributes
|
|
|
|
end
|
|
|
|
|
|
|
|
@fetched_attributes = {
|
|
|
|
:url => self.url,
|
|
|
|
:title => "",
|
|
|
|
}
|
2015-03-10 23:41:40 +01:00
|
|
|
|
2015-12-03 21:54:55 +01:00
|
|
|
if !@fetched_content
|
|
|
|
begin
|
|
|
|
s = Sponge.new
|
|
|
|
s.timeout = 3
|
|
|
|
@fetched_content = s.fetch(self.url, :get, nil, nil, {
|
|
|
|
"User-agent" => "#{Rails.application.domain} for #{self.fetching_ip}"
|
|
|
|
}, 3)
|
|
|
|
rescue
|
|
|
|
return @fetched_attributes
|
|
|
|
end
|
2015-03-10 23:41:40 +01:00
|
|
|
end
|
|
|
|
|
2015-12-03 21:54:55 +01:00
|
|
|
parsed = Nokogiri::HTML(@fetched_content.to_s)
|
2015-03-10 23:41:40 +01:00
|
|
|
|
2015-12-02 18:01:27 +01:00
|
|
|
# parse best title from html tags
|
2015-03-10 23:41:40 +01:00
|
|
|
# try <meta property="og:title"> first, it probably won't have the site
|
|
|
|
# name
|
2015-12-02 18:01:27 +01:00
|
|
|
title = ""
|
2015-03-10 23:41:40 +01:00
|
|
|
begin
|
2015-12-02 18:01:27 +01:00
|
|
|
title = parsed.at_css("meta[property='og:title']").
|
2015-03-10 23:41:40 +01:00
|
|
|
attributes["content"].text
|
|
|
|
rescue
|
|
|
|
end
|
|
|
|
|
|
|
|
# then try <meta name="title">
|
|
|
|
if title.to_s == ""
|
|
|
|
begin
|
2015-12-02 18:01:27 +01:00
|
|
|
title = parsed.at_css("meta[name='title']").attributes["content"].text
|
2015-03-10 23:41:40 +01:00
|
|
|
rescue
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
# then try plain old <title>
|
|
|
|
if title.to_s == ""
|
2015-12-02 18:01:27 +01:00
|
|
|
title = parsed.at_css("title").try(:text).to_s
|
2015-03-10 23:41:40 +01:00
|
|
|
end
|
|
|
|
|
2015-12-02 18:01:27 +01:00
|
|
|
@fetched_attributes[:title] = title
|
2015-03-10 23:41:40 +01:00
|
|
|
|
2015-12-02 18:01:27 +01:00
|
|
|
# now get canonical version of url (though some cms software puts incorrect
|
|
|
|
# urls here, hopefully the user will notice)
|
|
|
|
begin
|
|
|
|
if (cu = parsed.at_css("link[rel='canonical']").attributes["href"].
|
|
|
|
text).present? && (ucu = URI.parse(cu)) && ucu.scheme.present? &&
|
|
|
|
ucu.host.present?
|
|
|
|
@fetched_attributes[:url] = cu
|
2015-03-10 23:41:40 +01:00
|
|
|
end
|
2015-12-02 18:01:27 +01:00
|
|
|
rescue
|
2015-03-10 23:41:40 +01:00
|
|
|
end
|
|
|
|
|
2015-12-02 18:01:27 +01:00
|
|
|
@fetched_attributes
|
2015-03-10 23:41:40 +01:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|