require 'json' require 'ostruct' require 'active_model/naming' module Scrivito # The CMS file class # @api public class BasicObj extend ActiveModel::Naming include AttributeContent include ModelIdentity def self.type_computer @_type_computer ||= TypeComputer.new(Scrivito::BasicObj, ::Obj) end def self.reset_type_computer! @_type_computer = nil end # Create a new {BasicObj Obj} in the cms # # This allows you to set the different attributes types of an obj by # providing a hash with the attributes names as key and the values you want # to set as values # # @example Reference lists have to be provided as an Array of {BasicObj Objs} # Obj.create(:reference_list => [other_obj]) # # @example Passing an {BasicObj Obj} allows you to set a reference # Obj.create(:reference => other_obj) # # @example you can upload files by passing a ruby File object # Obj.create(:blob => File.new("image.png")) # # @example Link list can be set as an Array of {Link Links} # Obj.create(:link_list => [ # # external link # Link.new(:url => "http://www.example.com", :title => "Example"), # # internal link # Link.new(:obj => other_obj, :title => "Other Obj") # ]) # # @example Dates attributes accept Time, Date and their subclasses (DateTime for example) # Obj.create(:date => Time.new) # Obj.create(:date => Date.now) # # @example String, text, html and enum can be set by passing a {String} value # Obj.create(:title => "My Title") # # @example Arrays of {String Strings} allow you to set multi enum fields # Obj.create(:tags => ["ruby", "rails"]) # # @example Simply pass an Array of {BasicWidget Widgets} to change a widget field. See {BasicWidget#clone Widget#clone} on how to clone a widget. # # Add new widgets # Obj.create(:widgets => [Widget.new(_obj_class: 'TitleWidget', title: 'My Title')]) # # # Add a widget clone # Obj.create(:widgets => [another_obj.widgets.first.clone]) # # # Changing a widget field # obj.update(:widgets => [obj.widgets.first]) # # # Clear a widget field # obj.update(:widgets => []) # # @api public # @param [Hash] attributes # @return [Obj] the newly created {BasicObj Obj} def self.create(attributes) attributes = with_default_obj_class(attributes) widget_hash = CmsRestApi::WidgetExtractor.call(attributes) converted_attributes = CmsRestApi::AttributeSerializer.convert(attributes) converted_attributes['_widget_pool'] = CmsRestApi::AttributeSerializer.generate_widget_pool_changes(widget_hash) json = CmsRestApi.post(cms_rest_api_path, obj: converted_attributes) obj = find(json['id'] || json['_id']) CmsRestApi::WidgetExtractor.notify_persisted_widgets(obj, widget_hash) obj end # Create a new {BasicObj 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(attributes = {}) update_data(ObjDataFromHash.new(attributes)) end # @api public def id read_attribute('_id') end def revision=(revision) raise "cannot change revision once set!" if @revision @revision = revision end def revision @revision or raise "revision not set!" end ### FINDERS #################### # Find a {BasicObj Obj} by its id. # If the paremeter is an Array containing ids, return a list of corresponding Objs. # @param [String, Integer, Array]id_or_list # @return [Obj, Array] # @api public def self.find(id_or_list) find_filtering_deleted(id_or_list, false) end def self.find_by_id(id) find_objs_by(:id, [id]).first.first end # Find a {BasicObj Obj} by its id. # If the paremeter is an Array containing ids, return a list of corresponding Objs. # The results include deleted objects as well. # @param [String, Integer, Array]id_or_list # @return [Obj, Array] # @api public def self.find_including_deleted(id_or_list) find_filtering_deleted(id_or_list, true) end # Returns a {ObjSearchEnumerator} with the given initial subquery consisting of the four arguments. # # Note that +field+ and +value+ can also be arrays for searching several fields or searching for several values. # # {ObjSearchEnumerator}s can be chained using one of the chainable methods (e.g. {ObjSearchEnumerator#and} and {ObjSearchEnumerator#and_not}). # # @example Look for the first 10 Objs whose ObjClass is "Pressrelease" and whose title contains "quarterly": # Obj.where(:_obj_class, :equals, 'Pressrelease').and(:title, :contains, 'quarterly').take(10) # @param [Symbol, String, Array] field See {ObjSearchEnumerator#and} for details # @param [Symbol, String] operator See {ObjSearchEnumerator#and} for details # @param [String, Array] value See {ObjSearchEnumerator#and} for details # @param [Hash] boost See {ObjSearchEnumerator#and} for details # @return [ObjSearchEnumerator] # @api public def self.where(field, operator, value, boost = nil) ObjSearchEnumerator.new(nil).and(field, operator, value, boost) end # Returns a {ObjSearchEnumerator} of all {BasicObj Obj}s. # If invoked on a subclass of Obj, the result will be restricted to instances of that subclass. # @return [ObjSearchEnumerator] # @api public def self.all if superclass == Scrivito::BasicObj search_for_all else find_all_by_obj_class(name) end end # Returns a {ObjSearchEnumerator} of all Objs with the given +obj_class+. # @param [String] obj_class Name of the ObjClass. # @return [ObjSearchEnumerator] # @api public def self.find_all_by_obj_class(obj_class) search_for_all.and(:_obj_class, :equals, obj_class) end # Find the {BasicObj Obj} with the given path. # Returns +nil+ if no matching Obj exists. # @param [String] path Path of the {BasicObj Obj}. # @return [Obj] # @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 {BasicObj Obj} with the given name. # If several Objs with the given name exist, an arbitrary one of these Objs is chosen and returned. # If no Obj with the name exits, +nil+ is returned. # @param [String] name Name of the {BasicObj Obj}. # @return [Obj] # @api public def self.find_by_name(name) where(:_name, :equals, name).batch_size(1).first end # Returns a {ObjSearchEnumerator} of all Objs with the given name. # @param [String] name Name of the {BasicObj Obj}. # @return [ObjSearchEnumerator] # @api public def self.find_all_by_name(name) where(:_name, :equals, name) end # Returns the {BasicObj Obj} with the given permalink, or +nil+ if no matching Obj exists. # @param [String] permalink The permalink of the {BasicObj Obj}. # @return [Obj] # @api public def self.find_by_permalink(permalink) find_objs_by(:permalink, [permalink]).first.first end # Returns the {BasicObj Obj} with the given permalink, or raise ResourceNotFound if no matching Obj exists. # @param [String] permalink The permalink of the {BasicObj Obj}. # @return [Obj] # @api public def self.find_by_permalink!(permalink) find_by_permalink(permalink) or raise ResourceNotFound, "Could not find Obj with permalink '#{permalink}'" end # accepts the name of an "obj_by" - view, a list of keys # and an "include_deleted" flag # returns a list of lists of Objs: a list of Objs for each given keys. def self.find_objs_by(view, keys, include_deleted = false) if include_deleted finder_method_name = :find_obj_data_including_deleted_by else finder_method_name = :find_obj_data_by end revision = Workspace.current.revision result = CmsBackend.instance.public_send(finder_method_name, revision, view, keys) result.map do |list| list.map do |obj_data| obj = BasicObj.instantiate(obj_data) obj.revision = revision obj end end end # Hook method to control which page classes should be available for a page with given path. # Override it to allow only certain classes or none. # Must return either +NilClass+, or +Array+. # # Be aware that the given argument is a parent path. # E.g. when creating a page with path +/products/shoes+ then the argument will be +/products+. # # If +NilClass+ is returned, then all possible classes will be available. # By default +NilClass+ is returned. # # If +Array+ is returned, then it should include desired class names. # Each class name must be either a +String+ or a +Symbol+. # Only this class names will be available. Order of the class names will be preserved. # # @param [String] parent_path Path of the parent obj # @return [NilClass, Array] # @api public def self.valid_page_classes_beneath(parent_path) end # Update the {BasicObj Obj} with the attributes provided. # # For an overview of which values you can set via this method see the # documentation of {BasicObj.create Obj.create}. # # @api public # @param [Hash] attributes def update(attributes) widget_hash = CmsRestApi::WidgetExtractor.call(attributes, self) converted_attributes = CmsRestApi::AttributeSerializer.convert(attributes) converted_attributes['_widget_pool'] = CmsRestApi::AttributeSerializer.generate_widget_pool_changes(widget_hash) widget_pool = converted_attributes['_widget_pool'] widget_gc = WidgetGarbageCollection.new(self, {self => attributes}.merge(widget_hash)) widget_gc.widgets_to_delete.each { |widget| widget_pool[widget.id] = nil } CmsRestApi.put(cms_rest_api_path, obj: converted_attributes) Workspace.reload reload CmsRestApi::WidgetExtractor.notify_persisted_widgets(self, widget_hash) self end # Destroys the {BasicObj Obj} in the current {Workspace} # @api public def destroy if children.any? raise ClientError.new(I18n.t('scrivito.errors.models.basic_obj.has_children'), 412) end CmsRestApi.delete(cms_rest_api_path) Workspace.reload end def to_param id end # return the {BasicObj Obj} that is the parent of this Obj. # returns +nil+ for the root Obj. # @api public def parent if child_path? BasicObj.find_by_path(parent_path) end end # Returns an Array of all the ancestor objects, starting at the root and ending at this object's parent. # @return [Array] # @api public def ancestors return [] unless child_path? ancestor_paths = parent_path.scan(/\/[^\/]+/).inject([""]) do |list, component| list << list.last + component end ancestor_paths[0] = "/" BasicObj.find_many_by_paths(ancestor_paths) end # return a list of all child {BasicObj Obj}s. # @return [Array] # @api public def children return [] unless path self.class.find_objs_by(:ppath, [path]).first end ### ATTRIBUTES ################# # returns the {BasicObj Obj}'s path as a String. # @api public def path read_attribute('_path') end # returns the {BasicObj Obj}'s name, i.e. the last component of the path. # @api public def name if child_path? path.match(/[^\/]+$/)[0] else "" end end # Returns the root {BasicObj Obj}, i.e. the Obj with the path "/" # @return [Obj] # @api public def self.root BasicObj.find_by_path("/") or raise ResourceNotFound, "Obj.root not found: There is no Obj with path '/'." end # Returns the homepage obj. This can be overwritten in your application's +Obj+. # Use {#homepage?} to check if an obj is the homepage. # @return [Obj] # @api public def self.homepage root end # @api private def self.generate_widget_pool_id SecureRandom.hex(4) 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. # @return [String] # @api public def controller_name obj_class_name 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. # @return [String] # @api public def controller_action_name "index" end # Returns true if the current obj is the {.homepage} obj. # @api public def homepage? self == self.class.homepage end # This method is used to calculate a part of a URL of this Obj. # # The routing schema: / # # The default is {http://apidock.com/rails/ActiveSupport/Inflector/parameterize parameterize} # on +obj.title+. # # You can customize this part by overwriting {#slug}. # @return [String] # @api public def slug (title || '').parameterize end # This method determines the description that is shown in the changes list. # It can be overriden by a custom value. # @api public def description_for_editor slug.presence || path end # Returns the title of the content or the name. # @return [String] # @api public def display_title self.title || name end # @api public def title read_attribute('title') end # Returns true if image? or generic? def binary? [:image, :generic].include?(read_attribute('_obj_type').to_sym) end # Returns true if this object is the root object. # @api public def root? path == "/" end # Returns a list of children excluding the binary? ones unless :all is specfied. # This is mainly used for navigations. # @return [Array] # @api public def toclist(*args) return [] if binary? toclist = children toclist = toclist.reject { |toc| toc.binary? } unless args.include?(:all) toclist end # @param objs_to_be_sorted [Array] unsorted list of Objs # @param list [Array] list of Objs that defines the order # @return [Array] a sorted list of Objs. Any objs present in +objs_to_be_sorted+ but not in +list+ are appended at the end, sorted by +Obj#id+ def self.sort_by_list(objs_to_be_sorted, list) (list & objs_to_be_sorted) + (objs_to_be_sorted - list).sort_by(&:id) 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_name path permalink text_links title ]) # 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) else super 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 id = self.id.to_s reload_data = Proc.new do CmsBackend.instance.find_obj_data_by(Workspace.current.revision, :id, [id]).first.first end update_data(reload_data) end # @return [String] # @api public def obj_class_name read_attribute('_obj_class') end def obj_class raise ScrivitoError, "BasicObj#obj_class is no longer available"+ ", please use BasicObj#obj_class_name instead." end # @api public def last_changed read_attribute('_last_changed') end def new?(revision=Workspace.current.base_revision) if read_attribute('_modification') != 'deleted' cms_data_for_revision(revision).nil? else false end end def deleted?(revision=Workspace.current.base_revision) if read_attribute('_modification') == 'deleted' cms_data_for_revision(revision).present? end end def modification(revision=Workspace.current.base_revision) obj_data_from_revision = cms_data_for_revision(revision) if deleted?(revision) Modification::DELETED elsif new?(revision) Modification::NEW else # Edited if obj_data_from_revision.present? if data_from_cms == obj_data_from_revision Modification::UNMODIFIED else Modification::EDITED end else Modification::UNMODIFIED end end end def widget_data_for_revision(id, revision) if revision_obj_data = cms_data_for_revision(revision) revision_obj_data.value_of('_widget_pool')[id] end end def in_revision(revision) if obj_data = cms_data_for_revision(revision) obj = Obj.instantiate(obj_data) obj.revision = revision obj end end # For a binary Obj, the content_type is equal to the content_type of its body (i.e. its 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. # @return [String] # @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. # @return [String] # @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. # @return [String] # @api public def body if binary? nil else StringTagging.tag_as_html(read_attribute('body')) 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. # @return [String] # @api public def body_data_url if binary? blob = find_blob blob.url if blob end end # returns the content type of the Obj's body for binary Objs. # returns +nil+ for non-binary Objs. # @return [String] # @api public def body_content_type if binary? blob = find_blob if blob blob.content_type else "application/octet-stream" end end end def inspect "<#{self.class} id=\"#{id}\" path=\"#{path}\">" end def details_view_path "#{obj_class_name.underscore}/details" end def widget_from_pool(widget_id) widget_data = widget_data_from_pool(widget_id) instantiate_widget(widget_id, widget_data) if widget_data end # for internal testing purposes only def blob_id find_blob.try(:id) end # Reverts changes of this object. # After calling this method it's as if this object has been never modified in the current working copy. # This method does not work with +new+ or +deleted+ objects. # This method also does also not work for the +published+ workspace or the +rtc+ working copy. def revert Workspace.current.assert_revertable if binary? raise "revert not supported for binary objs" else case modification when Modification::UNMODIFIED # don't do anything when Modification::EDITED previous_content = CmsRestApi.get( "revisions/#{Workspace.current.base_revision_id}/objs/#{id}") updated_content = previous_content.except('id', '_id') added_widget_ids = read_widget_pool.keys - previous_content['_widget_pool'].keys added_widget_ids.each do |added_widget_id| updated_content['_widget_pool'][added_widget_id] = nil end CmsRestApi.put(cms_rest_api_path, obj: updated_content) reload else raise ScrivitoError, "cannot revert changes, since obj is #{modification}." end end end def mark_resolved CmsRestApi.put(cms_rest_api_path, obj: {_conflicts: nil}) reload end def container_and_field_name_for_widget(widget_id) if field_name = field_name_in_data_for_widget(data_from_cms, widget_id) return [self, field_name] else read_widget_pool.each do |parent_widget_id, widget_data| if field_name = field_name_in_data_for_widget(widget_data, widget_id) return [widget_from_pool(parent_widget_id), field_name] end end end [nil, nil] end def widget_data_from_pool(widget_id) read_widget_pool[widget_id] end def has_conflict? read_attribute('_conflicts') != nil end def all_widgets_from_pool read_widget_pool.keys.map do |widget_id| widget_from_pool(widget_id) end end def generate_widget_pool_id 10.times do id = self.class.generate_widget_pool_id return id if widget_data_from_pool(id).nil? end raise ScrivitoError.new('Could not generate a new unused widget id') end private def cms_data_for_revision(revision) if revision CmsBackend.instance.find_obj_data_by(revision, "id", [id]).first.first end end def field_name_in_data_for_widget(data, widget_id) data.all_custom_attributes.find do |attribute_name| (value, type) = data.value_and_type_of(attribute_name) type == "widget" && value.include?(widget_id) end end def read_widget_pool read_attribute('_widget_pool') end def instantiate_widget(widget_id, widget_data) BasicWidget.instantiate(widget_data).tap do |widget| widget.id = widget_id widget.obj = self 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 cms_rest_api_path(obj_id = id) "#{self.class.cms_rest_api_path}/#{obj_id}" end def child_path? !path.nil? && !root? end class << self def restore(obj_id) Workspace.current.assert_revertable base_revision_path = "revisions/#{Workspace.current.base_revision_id}/objs/#{obj_id}" obj_attributes = CmsRestApi.get(base_revision_path).except('id').merge('_id' => obj_id) CmsRestApi.post(cms_rest_api_path, obj: obj_attributes) end def cms_rest_api_path "workspaces/#{Workspace.current.id}/objs" end private def find_filtering_deleted(id_or_list, include_deleted) case id_or_list when Array find_objs_by(:id, id_or_list, include_deleted).map(&:first).compact else obj = find_objs_by(:id, [id_or_list.to_s], include_deleted).first.first obj or raise ResourceNotFound, "Could not find Obj with id #{id_or_list}" end end def search_for_all ObjSearchEnumerator.new(nil).batch_size(1000) end end end end