require 'json' require 'ostruct' require 'kvom' module RailsConnector # The CMS file class # @api public class Obj extend ActiveModel::Naming include Kvom::ModelIdentity include DateAttribute include SEO # Create a new Obj instance with the given values and attributes. # Normally this method should not be used. # Instead Objs should be loaded from the cms database. def initialize(obj_data = {}) if !obj_data.respond_to?(:value_and_type_of) obj_data = ObjDataFromHash.new(obj_data) end update_data(obj_data) end # instantiate an Obj instance from obj_data. # May result in an instance of a subclass of Obj according to STI rules. def self.instantiate(obj_data) obj_class = obj_data.value_of("_obj_class") Obj.compute_type(obj_class).new(obj_data) end # @api public def id read_attribute('_id') end ### FINDERS #################### # Find an Obj by it's id. # If the paremeter is an Array containing ids, return a list of corresponding Objs. # @api public def self.find(id_or_list) case id_or_list when Array find_objs_by(:id, id_or_list).map(&:first) else obj = find_objs_by(:id, [id_or_list.to_s]).first.first obj or raise ResourceNotFound, "Could not find #{self} with id #{id_or_list}" end end # Returns a {ObjSearchEnumerator} of all Objs. # If invoked on a subclass of Obj, the result will be restricted to Obj of that subclass. def self.all if self.name == 'RailsConnector::Obj' ObjSearchEnumerator.new(nil) else find_all_by_obj_class(self.name) end end # Returns an {ObjSearchEnumerator} of all Objs with the given obj_class. def self.find_all_by_obj_class(obj_class) ObjSearchEnumerator.new([{:field => '_obj_class', :operator => 'equal', :value => obj_class}]) end # Find the Obj with the given path. # Returns nil if no matching Obj exists. # @api public def self.find_by_path(path) find_objs_by(:path, [path]).first.first end def self.find_many_by_paths(pathes) find_objs_by(:path, pathes).map(&:first) end # Find an Obj with the given name. # If several Objs exist with the given name, one of them is chosen and returned. # If no Obj with the name exits, nil is returned. def self.find_by_name(name) enum = ObjSearchEnumerator.new( [{:field => '_name', :operator => 'equal', :value => name}], {:batch_size => 1}) enum.first end # Returns an {ObjSearchEnumerator} of all Objs with the given name. def self.find_all_by_name(name) ObjSearchEnumerator.new([{:field => '_name', :operator => 'equal', :value => name}]) end # Return the Obj with the given permalink or nil if no matching Obj exists. # @api public def self.find_by_permalink(permalink) find_objs_by(:permalink, [permalink]).first.first end # Return the Obj with the given permalink or raise ResourceNotFound if no matching Obj exists. # @api public def self.find_by_permalink!(permalink) find_by_permalink(permalink) or raise ResourceNotFound, "Could not find #{self} with permalink '#{permalink}'" end # accepts the name of an "obj_by" - view and a list of keys. # returns a list of lists of Objs: a list of Objs for each given keys. def self.find_objs_by(view, keys) CmsBackend.find_obj_data_by(Workspace.current.data, view, keys).map do |list| list.map { |obj_data| Obj.instantiate(obj_data) } end end def to_param id end def self.configure_for_content(mode) # this method exists only for compatibility with the fiona connector. end # A CMS administrator can specify the obj_class for a given CMS object. # In Rails, this could be either: # # * A valid and existing model name # * A valid and non-existing model name # * An invalid model name # # Rails' STI mechanism only considers the first case. # In any other case, RailsConnector::Obj is used, except when explicitly asked # for a model in the RailsConnector namespace (RailsConnector::Permission etc.) def self.compute_type(type_name) @compute_type_cache ||= {} @compute_type_cache [type_name] ||= try_type { type_name.constantize } || self end # return the Obj that is the parent of this Obj. # returns nil for the root Obj. # @api public def parent root? ? nil : Obj.find_by_path(parent_path) end # Returns an Array of all the ancestor objects, starting at the root and ending at this object's parent. # @api public def ancestors return [] if root? ancestor_paths = parent_path.scan(/\/[^\/]+/).inject([""]) do |list, component| list << list.last + component end ancestor_paths[0] = "/" Obj.find_many_by_paths(ancestor_paths) end # return a list of all child Objs. # @api public def children Obj.find_objs_by(:ppath, [path]).first end ### ATTRIBUTES ################# # returns the Obj's path as a String. # @api public def path read_attribute('_path') or raise "Obj without path" end # returns the Obj's name, i.e. the last component of the path. # @api public def name if root? "" else path.match(/[^\/]+$/)[0] end end def permissions # FIXME permissions @permissions ||= OpenStruct.new({ :live => permitted_groups, :read => [], :write => [], :root => [], :create_children => [], }) end def permitted_for_user?(user) if permitted_groups.blank? true else if user (permitted_groups & user.live_server_groups).any? else false end end end # Returns the root Obj, i.e. the Obj with the path "/" # @api public def self.root Obj.find_by_path("/") or raise ResourceNotFound, "Obj.root not found: There is no Obj with path '/'." end # Returns the homepage object. This can be overwritten in your application's +ObjExtensions+. # Use Obj#homepage? to check if an object is the homepage. # @api public def self.homepage root end # returns the obj's permalink. # @api public def permalink read_attribute('_permalink') end # This method determines the controller that should be invoked when the Obj is requested. # By default a controller matching the Obj's obj_class will be used. # If the controller does not exist, the CmsController will be used as a fallback. # Overwrite this method to force a different controller to be used. # @api public def controller_name obj_class end # This method determines the action that should be invoked when the Obj is requested. # The default action is 'index'. # Overwrite this method to force a different action to be used. # @api public def controller_action_name "index" end # Returns true if the current object is the homepage object. # @api public def homepage? self == self.class.homepage end # Returns the title of the content or the name. # @api public def display_title self.title || name end # @api public def title read_attribute('title') end # Returns the type of the object: :document, :publication, :image or :generic def object_type read_attribute('_obj_type').to_sym end # Returns true if image? or generic? def binary? [:image, :generic].include? object_type end # Returns true if object_type == :image def image? object_type == :image end # Returns true if object_type == :generic def generic? object_type == :generic end # Returns true if object_type == :publication (for folders) def publication? object_type == :publication end # Returns true if object_type == :document def document? object_type == :document end # Returns true if this object is active (time_when is in object's time interval) # @api public def active?(time_when = nil) return false unless valid_from time_then = time_when || Obj.preview_time valid_from <= time_then && (!valid_until || time_then <= valid_until) end # compatibility with legacy apps. def suppress_export suppressed? ? 1 : 0 end # Returns true if the Obj is suppressed. # A suppressed Obj does not represent an entire web page, but only a part of a page # (for example a teaser) and will not be delivered by the rails application # as a standalone web page. def suppressed? read_attribute('_suppress_export') ? true : false end # Returns true if the export of the object is not suppressed and the content is active? def exportable?(current_time = nil) !suppressed? && active?(current_time) end # Returns the file name to which the Content.file_extension has been appended. def filename Rails.logger.warn( "DEPRECATION WARNING: "\ "The Method Obj#filename is no longer supported. Please use Obj#name instead. "\ "From: #{caller[0]}" ) name end # Returns an array with the names of groups that are permitted to access this Obj. # This corresponds to the cms permission "permissionLiveServerRead". def permitted_groups # FIXME permissions not yet implemented in fiona 7 [] end # Returns true if this object is the root object. # @api public def root? path == "/" end # Returns a list of exportable? children excluding the binary? ones unless :all is specfied. # This is mainly used for navigations. # @api public def toclist(*args) return [] unless publication? time = args.detect {|value| value.kind_of? Time} toclist = children.select{ |toc| toc.exportable?(time) } toclist = toclist.reject { |toc| toc.binary? } unless args.include?(:all) toclist end # Returns the sorted +toclist+, respecting sort order and type of this Obj. # @api public def sorted_toclist(*args) list = self.toclist(*args) return [] if list.blank? cached_sort_key1 = self.sort_key1 cached_sort_type1 = self.sort_type1 sorted_list = if cached_sort_key1.blank? list.sort { |left_obj, right_obj| left_obj.name <=> right_obj.name } else cached_sort_key2 = self.sort_key2 cached_sort_type2 = self.sort_type2 cached_sort_key3 = self.sort_key3 cached_sort_type3 = self.sort_type3 list.sort do |left_obj, right_obj| compare = compare_on_sort_key(left_obj, right_obj, cached_sort_key1, cached_sort_type1) if compare == 0 && cached_sort_key2 compare = compare_on_sort_key(left_obj, right_obj, cached_sort_key2, cached_sort_type2) if compare == 0 && cached_sort_key3 compare = compare_on_sort_key(left_obj, right_obj, cached_sort_key3, cached_sort_type3) end end compare end end return self.sort_order == "descending" ? sorted_list.reverse : sorted_list end def sort_order read_attribute('_sort_order') == 1 ? "descending" : "ascending" end def sort_type1 converted_sort_type('_sort_type1') end def sort_type2 converted_sort_type('_sort_type2') end def sort_type3 converted_sort_type('_sort_type3') end def sort_key1 read_attribute('_sort_key1') end def sort_key2 read_attribute('_sort_key2') end def sort_key3 read_attribute('_sort_key3') end # Returns the Object with the given name next in the hierarchy # returns nil if no object with the given name was found. # @api public def find_nearest(name) obj = self.class.find_by_path(root? ? "/#{name}" : "#{path}/#{name}") return obj if obj and obj.active? parent.find_nearest(name) unless self.root? end # This should be a SET, because it's faster in this particular case. OLD_INTERNAL_KEYS = Set.new(%w[ body id last_changed name obj_class obj_type object_type path permalink sort_key1 sort_key2 sort_key3 sort_order sort_type1 sort_type2 sort_type3 suppress_export text_links title valid_from valid_until ]) # Returns the value of an internal or external attribute specified by its name. # Passing an invalid key will not raise an error, but return nil. # @api public def [](key) key = key.to_s if OLD_INTERNAL_KEYS.include?(key) send(key) elsif key.start_with?('_') && OLD_INTERNAL_KEYS.include?(internal_key = key[1..-1]) # For backwards compatibility reasons send(internal_key) elsif has_attribute?(key) read_attribute(key) else nil end end # Reloads the attributes of this object from the database. # Notice that the ruby class of this Obj instance will NOT change, # even if the obj_class in the database has changed. # @api public def reload obj_data = CmsBackend.find_obj_data_by(Workspace.current.data, :id, [id.to_s]).first.first update_data(obj_data) end def text_links read_attribute('_text_links') end # @api public def obj_class read_attribute('_obj_class') end # @api public def last_changed read_attribute('_last_changed') end # @api public def valid_from read_attribute('_valid_from') end # @api public def valid_until read_attribute('_valid_until') end # For a binary Obj, the content_type is equal to the content_type of it's body (i.e. it's data). # For non-binary Objs, a the default content_type is "text/html". # Override this method in subclasses to define a different content_type. # Note that only Objs with content_type "text/html" # will be rendered with layout and templates by the DefaultCmsController. # @api public def content_type if binary? body_content_type else "text/html" end end alias mime_type content_type # returns the extension (the part after the last dot) from the Obj's name. # returns an empty string if no extension is present in the Obj's name. # @api public def file_extension File.extname(name)[1..-1] || "" end # Returns the body (main content) of the Obj for non-binary Objs. # Returns nil for binary Objs. # @api public def body if binary? nil else StringTagging.tag_as_html(read_attribute('body'), self) end end # for binary Objs body_length equals the file size # for non-binary Objs body_length equals the number of characters in the body (main content) # @api public def body_length if binary? blob = find_blob blob ? blob.length : 0 else (body || "").length end end # returns an URL to retrieve the Obj's body for binary Objs. # returns nil for non-binary Objs. # @api public def body_data_url if binary? blob = find_blob blob.url if blob end end def body_data_path # not needed/supported when using cloud connector. nil end # returns the content type of the Obj's body for binary Objs. # returns nil for non-binary Objs. # @api public def body_content_type if binary? blob = find_blob if blob blob.content_type else "application/octet-stream" end end end def to_liquid LiquidSupport::ObjDrop.new(self) end def respond_to?(method_id, include_private=false) if has_attribute?(method_id) true else super end end def self.preview_time=(t) Thread.current[:preview_time] = t end def self.preview_time Thread.current[:preview_time] || Time.now end def inspect "<#{self.class} id=\"#{id}\" path=\"#{path}\">" end def has_attribute?(name) data_from_cms.has_custom_attribute?(name.to_s) end private attr_accessor :data_from_cms def update_data(data) self.data_from_cms = data @attribute_cache = {} end def read_attribute(attribute_name) @attribute_cache.fetch(attribute_name) do (raw_value, attribute_type) = data_from_cms.value_and_type_of(attribute_name) @attribute_cache[attribute_name] = prepare_attribute_value(raw_value, attribute_type) end end def prepare_attribute_value(attribute_value, attribute_type) case attribute_type when "markdown" StringTagging.tag_as_markdown(attribute_value, self) when "html" StringTagging.tag_as_html(attribute_value, self) when "date" DateAttribute.parse(attribute_value) if attribute_value when "linklist" LinkList.new(attribute_value) else attribute_value end end def parent_path raise "parent_path called for root" if root? path.gsub(/\/[^\/]+$/, "").presence || "/" end def as_date(value) DateAttribute.parse(value) unless value.nil? end def find_blob blob_spec = read_attribute('blob') Blob.find(blob_spec["id"]) if blob_spec end def method_missing(method_name, *args) if has_attribute?(method_name) read_attribute(method_name.to_s) else super end end def compare_on_sort_key(left_obj, right_obj, my_sort_key, my_sort_type) left_value = left_obj[my_sort_key] right_value = right_obj[my_sort_key] if left_value.nil? 1 elsif right_value.nil? -1 # hardcoded type check needed for speed elsif left_value.is_a?(Time) && right_value.is_a?(Time) left_value <=> right_value else if my_sort_type == "numeric" (left_value.to_i rescue 0) <=> (right_value.to_i rescue 0) else left_value.to_s.downcase <=> right_value.to_s.downcase end end end def converted_sort_type(attribute) read_attribute(attribute) == 1 ? "numeric" : "alphaNumeric" end class << self private def try_type result = yield result if subclass_of_obj(result) rescue NameError nil end def subclass_of_obj(klass) if klass == Obj true else klass.superclass && subclass_of_obj(klass.superclass) end end end end end