class Article < ActiveRecord::Base attr_accessible :name, :body, :abstract, :profile, :tag_list, :parent, :allow_members_to_edit, :translation_of_id, :language, :license_id, :parent_id, :display_posts_in_current_language, :category_ids, :posts_per_page, :moderate_comments, :accept_comments, :feed, :published, :source, :source_name, :highlighted, :notify_comments, :display_hits, :slug, :external_feed_builder, :display_versions, :external_link, :image_builder, :show_to_followers, :author acts_as_having_image SEARCHABLE_FIELDS = { :name => {:label => _('Name'), :weight => 10}, :abstract => {:label => _('Abstract'), :weight => 3}, :body => {:label => _('Content'), :weight => 2}, :slug => {:label => _('Slug'), :weight => 1}, :filename => {:label => _('Filename'), :weight => 1}, } SEARCH_FILTERS = { :order => %w[more_recent more_popular more_comments], :display => %w[full] } def initialize(*params) super if !params.blank? && params.first.has_key?(:profile) && !params.first[:profile].blank? profile = params.first[:profile] self.published = false unless profile.public? end end def self.default_search_display 'full' end #FIXME This is necessary because html is being generated on the model... include ActionView::Helpers::TagHelper # use for internationalizable human type names in search facets # reimplement on subclasses def self.type_name _('Content') end track_actions :create_article, :after_create, :keep_params => [:name, :url, :lead, :first_image], :if => Proc.new { |a| a.is_trackable? && !a.image? } # xss_terminate plugin can't sanitize array fields before_save :sanitize_tag_list before_create do |article| if article.author article.author_name = article.author.name end end belongs_to :profile validates_presence_of :profile_id, :name validates_presence_of :slug, :path, :if => lambda { |article| !article.name.blank? } validates_length_of :name, :maximum => 150 validates_uniqueness_of :slug, :scope => ['profile_id', 'parent_id'], :message => N_('The title (article name) is already being used by another article, please use another title.'), :if => lambda { |article| !article.slug.blank? } belongs_to :author, :class_name => 'Person' belongs_to :last_changed_by, :class_name => 'Person', :foreign_key => 'last_changed_by_id' belongs_to :created_by, :class_name => 'Person', :foreign_key => 'created_by_id' has_many :comments, :class_name => 'Comment', :foreign_key => 'source_id', :dependent => :destroy, :order => 'created_at asc' has_many :article_categorizations, :conditions => [ 'articles_categories.virtual = ?', false ] has_many :categories, :through => :article_categorizations has_many :article_categorizations_including_virtual, :class_name => 'ArticleCategorization' has_many :categories_including_virtual, :through => :article_categorizations_including_virtual, :source => :category acts_as_having_settings :field => :setting settings_items :display_hits, :type => :boolean, :default => true settings_items :author_name, :type => :string, :default => "" settings_items :allow_members_to_edit, :type => :boolean, :default => false settings_items :moderate_comments, :type => :boolean, :default => false settings_items :followers, :type => Array, :default => [] has_and_belongs_to_many :article_privacy_exceptions, :class_name => 'Person', :join_table => 'article_privacy_exceptions' belongs_to :reference_article, :class_name => "Article", :foreign_key => 'reference_article_id' belongs_to :license has_many :translations, :class_name => 'Article', :foreign_key => :translation_of_id belongs_to :translation_of, :class_name => 'Article', :foreign_key => :translation_of_id before_destroy :rotate_translations acts_as_voteable before_create do |article| article.published_at ||= Time.now if article.reference_article && !article.parent parent = article.reference_article.parent if parent && parent.blog? && article.profile.has_blog? article.parent = article.profile.blog end end if article.created_by article.author_name = article.created_by.name end end after_destroy :destroy_activity def destroy_activity self.activity.destroy if self.activity end after_destroy :destroy_link_article def destroy_link_article Article.where(:reference_article_id => self.id, :type => LinkArticle).destroy_all end xss_terminate :only => [ :name ], :on => 'validation', :with => 'white_list' scope :in_category, lambda { |category| {:include => 'categories_including_virtual', :conditions => { 'categories.id' => category.id }} } scope :by_range, lambda { |range| { :conditions => [ 'articles.published_at BETWEEN :start_date AND :end_date', { :start_date => range.first, :end_date => range.last } ] }} URL_FORMAT = /\A(http|https):\/\/[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(([0-9]{1,5})?\/.*)?\Z/ix validates_format_of :external_link, :with => URL_FORMAT, :if => lambda { |article| !article.external_link.blank? } validate :known_language validate :used_translation validate :native_translation_must_have_language validate :translation_must_have_language validate :no_self_reference validate :no_cyclical_reference, :if => 'parent_id.present?' def no_self_reference errors.add(:parent_id, _('self-reference is not allowed.')) if id && parent_id == id end def no_cyclical_reference current_parent = Article.find(parent_id) while current_parent if current_parent == self errors.add(:parent_id, _('cyclical reference is not allowed.')) break end current_parent = current_parent.parent end end def is_trackable? self.published? && self.notifiable? && self.advertise? && self.profile.public_profile end def external_link=(link) if !link.blank? && link !~ /^[a-z]+:\/\//i link = 'http://' + link end self[:external_link] = link end def action_tracker_target self.profile end def self.human_attribute_name_with_customization(attrib, options={}) case attrib.to_sym when :name _('Title') else _(self.human_attribute_name_without_customization(attrib)) end end class << self alias_method_chain :human_attribute_name, :customization end def css_class_list [self.class.name.to_css_class] end def css_class_name [css_class_list].flatten.compact.join(' ') end def pending_categorizations @pending_categorizations ||= [] end def add_category(c, reload=false) if new_record? pending_categorizations << c else ArticleCategorization.add_category_to_article(c, self) self.categories(reload) end end def category_ids=(ids) ArticleCategorization.remove_all_for(self) ids.uniq.each do |item| add_category(Category.find(item)) unless item.to_i.zero? end self.categories(true) end after_create :create_pending_categorizations def create_pending_categorizations pending_categorizations.each do |item| ArticleCategorization.add_category_to_article(item, self) end self.categories(true) pending_categorizations.clear end acts_as_taggable N_('Tag list') acts_as_filesystem acts_as_versioned self.non_versioned_columns << 'setting' def version_condition_met? (['name', 'body', 'abstract', 'filename', 'start_date', 'end_date', 'image_id', 'license_id'] & changed).length > 0 end def comment_data comments.map {|item| [item.title, item.body].join(' ') }.join(' ') end before_update do |article| article.advertise = true end before_save do |article| article.parent = article.parent_id ? Article.find(article.parent_id) : nil parent_path = article.parent ? article.parent.path : nil article.path = [parent_path, article.slug].compact.join('/') end # retrieves all articles belonging to the given +profile+ that are not # sub-articles of any other article. scope :top_level_for, lambda { |profile| {:conditions => [ 'parent_id is null and profile_id = ?', profile.id ]} } scope :public, :conditions => [ "advertise = ? AND published = ? AND profiles.visible = ? AND profiles.public_profile = ?", true, true, true, true ], :joins => [:profile] scope :more_recent, :conditions => [ "advertise = ? AND published = ? AND profiles.visible = ? AND profiles.public_profile = ? AND ((articles.type != ?) OR articles.type is NULL)", true, true, true, true, 'RssFeed' ], :order => 'articles.published_at desc, articles.id desc' # retrives the most commented articles, sorted by the comment count (largest # first) def self.most_commented(limit) paginate(:order => 'comments_count DESC', :page => 1, :per_page => limit) end scope :more_popular, :order => 'hits DESC' scope :relevant_as_recent, :conditions => ["(articles.type != 'UploadedFile' and articles.type != 'RssFeed' and articles.type != 'Blog') OR articles.type is NULL"] def self.recent(limit = nil, extra_conditions = {}, pagination = true) result = scoped({:conditions => extra_conditions}). public. relevant_as_recent. limit(limit). order(['articles.published_at desc', 'articles.id desc']) pagination ? result.paginate({:page => 1, :per_page => limit}) : result end # produces the HTML code that is to be displayed as this article's contents. # # The implementation in this class just provides the +body+ attribute as the # HTML. Other article types can override this method to provide customized # views of themselves. # (To override short format representation, override the lead method) def to_html(options = {}) if options[:format] == 'short' article = self proc do display_short_format(article) end else body || '' end end # returns the data of the article. Must be overriden in each subclass to # provide the correct content for the article. def data body end # provides the icon name to be used for this article. In this class this # method just returns 'text-html', but subclasses may (and should) override # to return their specific icons. # # FIXME use mime_type and generate this name dinamically def self.icon_name(article = nil) 'text-html' end # TODO Migrate the class method icon_name to instance methods. def icon_name self.class.icon_name(self) end def mime_type 'text/html' end def mime_type_description _('HTML Text document') end def self.description raise NotImplementedError, "#{self} does not implement #description" end def self.short_description raise NotImplementedError, "#{self} does not implement #short_description" end def title name end include ActionView::Helpers::TextHelper def short_title truncate self.title, :length => 15, :omission => '...' end def belongs_to_blog? self.parent and self.parent.blog? end def belongs_to_forum? self.parent and self.parent.forum? end def info_from_last_update last_comment = comments.last if last_comment {:date => last_comment.created_at, :author_name => last_comment.author_name, :author_url => last_comment.author_url} else {:date => updated_at, :author_name => author_name, :author_url => author_url} end end def url @url ||= self.profile.url.merge(:page => path.split('/')) end def view_url @view_url ||= is_a?(UploadedFile) ? url.merge(:view => true) : url end def comment_url_structure(comment, action = :edit) if comment.new_record? profile.url.merge(:page => path.split("/"), :controller => :comment, :action => :create) else profile.url.merge(:page => path.split("/"), :controller => :comment, :action => action || :edit, :id => comment.id) end end def allow_children? true end def has_posts? false end def download? view = nil (self.uploaded_file? and not self.image?) or (self.image? and view.blank?) or (not self.uploaded_file? and self.mime_type != 'text/html') end def download_headers {} end def alternate_languages self.translations.map(&:language) end scope :native_translations, :conditions => { :translation_of_id => nil } def translatable? false end def native_translation self.translation_of.nil? ? self : self.translation_of end def possible_translations possibilities = environment.locales.keys - self.native_translation.translations(:select => :language).map(&:language) - [self.native_translation.language] possibilities << self.language unless self.language_changed? possibilities end def known_language unless self.language.blank? errors.add(:language, N_('Language not supported by the environment.')) unless environment.locales.key?(self.language) end end def used_translation unless self.language.blank? or self.translation_of.nil? errors.add(:language, N_('Language is already used')) unless self.possible_translations.include?(self.language) end end def translation_must_have_language unless self.translation_of.nil? errors.add(:language, N_('Language must be choosen')) if self.language.blank? end end def native_translation_must_have_language unless self.translation_of.nil? errors.add(:base, N_('A language must be choosen for the native article')) if self.translation_of.language.blank? end end def rotate_translations unless self.translations.empty? rotate = self.translations root = rotate.shift root.update_attribute(:translation_of_id, nil) root.translations = rotate end end def get_translation_to(locale) if self.language.nil? || self.language.blank? || self.language == locale self elsif self.native_translation.language == locale self.native_translation else self.native_translation.translations.first(:conditions => { :language => locale }) end end def published? if self.published if self.parent && !self.parent.published? return false end true else false end end def self.folder_types ['Folder', 'Blog', 'Forum', 'Gallery'] end def self.text_article_types ['TextArticle', 'TextileArticle', 'TinyMceArticle'] end scope :published, :conditions => ['articles.published = ?', true] scope :folders, lambda {|profile|{:conditions => ['articles.type IN (?)', profile.folder_types] }} scope :no_folders, lambda {|profile|{:conditions => ['articles.type NOT IN (?)', profile.folder_types]}} scope :galleries, :conditions => [ "articles.type IN ('Gallery')" ] scope :images, :conditions => { :is_image => true } scope :no_images, :conditions => { :is_image => false } scope :text_articles, :conditions => [ 'articles.type IN (?)', text_article_types ] scope :files, :conditions => { :type => 'UploadedFile' } scope :with_types, lambda { |types| { :conditions => [ 'articles.type IN (?)', types ] } } scope :more_popular, :order => 'hits DESC' scope :more_comments, :order => "comments_count DESC" scope :more_recent, :order => "created_at DESC" scope :display_filter, lambda {|user, profile| return published if (user.nil? && profile && profile.public?) return [] if user.nil? || (profile && !profile.public? && !user.follows?(profile)) where( [ "published = ? OR last_changed_by_id = ? OR profile_id = ? OR ? OR (show_to_followers = ? AND ? AND profile_id IN (?))", true, user.id, user.id, profile.nil? ? false : user.has_permission?(:view_private_content, profile), true, (profile.nil? ? true : user.follows?(profile)), ( profile.nil? ? (user.friends.select('profiles.id')) : [profile.id]) ] ) } def display_unpublished_article_to?(user) user == author || allow_view_private_content?(user) || user == profile || user.is_admin?(profile.environment) || user.is_admin?(profile) || article_privacy_exceptions.include?(user) || (self.show_to_followers && user.follows?(profile)) end def display_to?(user = nil) if published? (profile.secret? || !profile.visible?) ? profile.display_info_to?(user) : true else if !user false else display_unpublished_article_to?(user) end end end def allow_post_content?(user = nil) return true if allow_edit_topic?(user) user && (user.has_permission?('post_content', profile) || allow_publish_content?(user) && (user == author)) end def allow_publish_content?(user = nil) user && user.has_permission?('publish_content', profile) end def allow_view_private_content?(user = nil) user && user.has_permission?('view_private_content', profile) end alias :allow_delete? :allow_post_content? def allow_spread?(user = nil) user && public? end def allow_create?(user) allow_post_content?(user) || allow_publish_content?(user) end def allow_edit?(user) return true if allow_edit_topic?(user) allow_post_content?(user) || user && allow_members_to_edit && user.is_member_of?(profile) end def allow_edit_topic?(user) self.belongs_to_forum? && (user == author) && user.present? && user.is_member_of?(profile) end def moderate_comments? moderate_comments == true end def comments_updated solr_save end def accept_category?(cat) !cat.is_a?(ProductCategory) end def public? profile.visible? && profile.public? && published? end def copy_without_save(options = {}) attrs = attributes.reject! { |key, value| ATTRIBUTES_NOT_COPIED.include?(key.to_sym) } attrs.merge!(options) object = self.class.new attrs.each do |key, value| object.send(key.to_s+'=', value) end object end def copy(options = {}) object = copy_without_save(options) object.save object end def copy!(options = {}) object = copy_without_save(options) object.save! object end ATTRIBUTES_NOT_COPIED = [ :id, :profile_id, :parent_id, :path, :slug, :updated_at, :created_at, :version, :lock_version, :type, :children_count, :comments_count, :hits, ] def self.find_by_old_path(old_path) find(:first, :include => :versions, :conditions => ['article_versions.path = ?', old_path], :order => 'article_versions.id desc') end def hit self.class.connection.execute('update articles set hits = hits + 1 where id = %d' % self.id.to_i) self.hits += 1 end def can_display_hits? true end def display_hits? can_display_hits? && display_hits end def image? false end def event? false end def gallery? false end def tiny_mce? false end def folder? false end def blog? false end def forum? false end def uploaded_file? false end settings_items :display_versions, :type => :boolean, :default => false def can_display_versions? false end def display_versions? can_display_versions? && display_versions end def get_version(version_number = nil) version_number ? versions.find(:first, :order => 'version', :offset => version_number - 1) : versions.earliest end def author_by_version(version_number = nil) version_number ? profile.environment.people.find_by_id(get_version(version_number).author_id) : author end def author_name(version_number = nil) person = author_by_version(version_number) if version_number person ? person.name : _('Unknown') else person ? person.name : (setting[:author_name] || _('Unknown')) end end def author_url(version_number = nil) person = author_by_version(version_number) person ? person.url : nil end def author_id(version_number = nil) person = author_by_version(version_number) person ? person.id : nil end def version_license(version_number = nil) return license if version_number.nil? profile.environment.licenses.find_by_id(get_version(version_number).license_id) end alias :active_record_cache_key :cache_key def cache_key(params = {}, the_profile = nil, language = 'en') active_record_cache_key+'-'+language + (allow_post_content?(the_profile) ? "-owner" : '') + (params[:npage] ? "-npage-#{params[:npage]}" : '') + (params[:year] ? "-year-#{params[:year]}" : '') + (params[:month] ? "-month-#{params[:month]}" : '') + (params[:version] ? "-version-#{params[:version]}" : '') end def first_paragraph paragraphs = Nokogiri::HTML.fragment(to_html).css('p') paragraphs.empty? ? '' : paragraphs.first.to_html end def lead(length = nil) content = abstract.blank? ? first_paragraph.html_safe : abstract.html_safe length.present? ? content.truncate(length) : content end def short_lead truncate sanitize_html(self.lead), :length => 170, :omission => '...' end def notifiable? false end def accept_uploads? self.parent && self.parent.accept_uploads? end def body_images_paths require 'uri' Nokogiri::HTML.fragment(self.body.to_s).css('img[src]').collect do |i| (self.profile && self.profile.environment) ? URI.join(self.profile.environment.top_url, URI.escape(i['src'])).to_s : i['src'] end end def more_comments_label amount = self.comments_count { 0 => _('no comments'), 1 => _('one comment') }[amount] || _("%s comments") % amount end def more_popular_label amount = self.hits { 0 => _('no views'), 1 => _('one view') }[amount] || _("%s views") % amount end def more_recent_label _('Created at: ') end def activity ActionTracker::Record.find_by_target_type_and_target_id 'Article', self.id end def create_activity if is_trackable? && !image? save_action_for_verb 'create_article', [:name, :url, :lead, :first_image], Proc.new{}, :author end end def first_image img = ( image.present? && { 'src' => image.public_filename } ) || Nokogiri::HTML.fragment(self.lead.to_s).css('img[src]').first || Nokogiri::HTML.fragment(self.body.to_s).search('img').first img.nil? ? '' : img['src'] end delegate :lat, :lng, :region, :region_id, :environment, :environment_id, :to => :profile, :allow_nil => true def has_macro? true end private def sanitize_tag_list sanitizer = HTML::FullSanitizer.new self.tag_list.map!{|i| strip_tag_name sanitizer.sanitize(i) } end def strip_tag_name(tag_name) tag_name.gsub(/[<>]/, '') end def sanitize_html(text) sanitizer = HTML::FullSanitizer.new sanitizer.sanitize(text) end end