locomotivecms/engine

View on GitHub
app/services/locomotive/page_parsing_service.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require 'active_support/benchmarkable'

module Locomotive

  class PageParsingService < Struct.new(:site, :locale)

    include ActiveSupport::Benchmarkable

    def find_all_elements(page)
      find_or_create_editable_elements(page)&.slice(:elements, :sections)
    end

    def find_or_create_editable_elements(page)
      benchmark "Parse page #{page._id} find_or_create_editable_elements" do
        parsed = {
          extends:                  {},
          blocks:                   {},
          super_blocks:             {},
          elements:                 [],
          sections:                 { top: [], bottom: [], dropzone: false }
        }

        subscribe(parsed) do
          parse(page)

          # remove the sections if hidden (sections might be hidden by an overidding block).
          # also sort them by their placement.
          # finally, only return their types (+ sectionId)
          extract_section_attributes!(page, parsed)

          # !Important! Non visible editable elements are not removed
          persist_editable_elements!(page, parsed)
        end

        parsed
      end
    rescue Exception => e
      logger.error "[PageParsing] " + e.message + "\n\t" + e.backtrace.join("\n\t")
      puts "[PageParsing] " + e.message + "\n\t" + e.backtrace.join("\n\t")
      nil
    end

    # Each element of the elements parameter is a couple: Page, EditableElement
    def group_and_sort_editable_elements(elements)
      elements.group_by { |(_, el)| el.block }.tap do |groups|
        groups.each do |_, list|
          list.sort! { |(_, a), (_, b)| (b.priority || 0) <=> (a.priority || 0) }
        end
      end
    end

    def blocks_from_grouped_editable_elements(groups)
      groups.map do |block, elements|
        next if elements.empty?

        element = elements.first.last

        { name: block, label: element.block_label, priority: element.block_priority || 0 }
      end.compact.sort { |a, b| b[:priority] <=> a[:priority] }
    end

    private

    def subscribe(parsed, &block)
      subscribers = [
        subscribe_to_extends(parsed[:extends]),
        subscribe_to_blocks(parsed[:blocks], parsed[:super_blocks]),
        subscribe_to_editable_elements(parsed[:elements]),
        subscribe_to_sections(parsed[:sections])
      ]

      yield.tap do
        subscribers.each do |subscriber|
          ActiveSupport::Notifications.unsubscribe(subscriber)
        end
      end
    end

    def subscribe_to_extends(extends)
      ActiveSupport::Notifications.subscribe('steam.parse.extends') do |name, start, finish, id, payload|
        parent_id, page_id = payload[:parent]._id, payload[:page]._id
        extends[parent_id] = page_id
      end
    end

    def subscribe_to_blocks(blocks, super_blocks)
      ActiveSupport::Notifications.subscribe('steam.parse.inherited_block') do |name, start, finish, id, payload|
        page_id, block_name, found_super = payload[:page]._id, payload[:name], payload[:found_super]
        super_blocks[page_id] ||= {}
        super_blocks[page_id][block_name] = found_super

        blocks[block_name] ||= payload.slice(:short_name, :priority)
      end
    end

    def subscribe_to_editable_elements(elements)
      ActiveSupport::Notifications.subscribe(/\Asteam\.parse\.editable\./) do |name, start, finish, id, payload|
        page, attributes = payload[:page], payload[:attributes]
        elements << [page, attributes]
      end
    end

    def subscribe_to_sections(sections)
      ActiveSupport::Notifications.subscribe('steam.parse.section') do |name, start, finish, id, payload|
        page, block, attributes = payload[:page], payload[:block], payload[:attributes]

        if attributes[:is_dropzone]
          sections[:dropzone] ||= [page, block, attributes]
        else
          placement = attributes[:placement] || :top
          attributes[:position] ||= block.blank? ? 0 : block.split('/').size
          sections[placement].push([page, block, attributes])
        end
      end
    end

    def parse(page)
      entity = repository.build(page.attributes.dup)
      decorated_page = Locomotive::Steam::Decorators::TemplateDecorator.new(entity, self.locale, self.site.default_locale)

      parser = services.liquid_parser
      parser.parse(decorated_page)
    end

    # Remove sections from hidden blocks.
    # It also tells if there is a visible sections dropzone.
    def extract_section_attributes!(page, parsed)
      [:top, :bottom].each do |placement|
        parsed[:sections][placement] = parsed[:sections][placement].map do |(_page, block, attributes)|
          # we don't want hidden sections
          next unless is_element_visible?(page, parsed, _page, block)

          attributes.slice(:source, :type, :key, :id, :label, :position)
        end.compact.sort_by { |attributes| attributes[:position] }
      end

      if parsed[:sections][:dropzone]
        # we don't want a hidden dropzone
        _page, block, _ = parsed[:sections][:dropzone]
        parsed[:sections][:dropzone] = is_element_visible?(page, parsed, _page, block)
      else
        parsed[:sections][:dropzone] = false
      end
    end

    def persist_editable_elements!(page, parsed)
      modified_pages, pages = [], { page._id => page } # group modifications by page

      parsed[:elements].map! do |couple|
        _page, attributes = couple

        next if !is_element_visible?(page, parsed, _page, attributes[:block])

        # Note: _page is a Steam entity but we need a Mongoid document to save the elements
        _page = attributes[:fixed] ? find_page(_page._id, pages) : page

        element = add_or_modify_editable_element(_page, attributes)
        couple[0], couple[1] = _page, element # we get now a Mongoid document instead of a Steam entity

        assign_block_information(element, parsed[:blocks])

        modified_pages << _page

        couple
      end.compact!

      modified_pages.uniq.map(&:save!)
    end

    def is_element_visible?(page, parsed, _page, block)
      if page._id == _page._id # same page, we are sure that it's visible
        true
      elsif block.blank? # Ex: an editable_element outside a block (impossible to remove it in pages extending this template)
        true
      else
        block_visible?(_page._id, parsed, { block: block })
      end
    end

    def block_visible?(page_id, parsed, attributes)
      block_name = attributes[:block]
      descendant = parsed[:extends][page_id]

      return true if descendant.nil?

      # find if the descendant hides the block
      if (blocks = parsed[:super_blocks][descendant]).blank?
        # we can not know for sure, ask the descendant of the descendant
        block_visible?(descendant, parsed, attributes)
      else
        found_super = blocks[block_name]
        hidden = blocks.keys.any? { |name| block_name =~ /\A#{name}(\Z|\/)/ }
        if found_super || !hidden
          # again, we need to ask the descendant of the descendant
          block_visible?(descendant, parsed, attributes)
        end
      end
    end

    def add_or_modify_editable_element(page, attributes)
      if element = page.editable_elements.by_block_and_slug(attributes[:block], attributes[:slug]).first
        # context: the editable element has been created from the page YAML header
        existing_content = element._type.nil? && !element.content.blank?

        # FIXME: we don't want to deal here with the generic Locomotive::EditableElement class
        element = page.editable_elements.with_same_class!(element, "Locomotive::#{attributes[:type].to_s.classify}".constantize)
        element.attributes = attributes

        # we know now this was an editable_text element. If it already had
        # a content, then it shouldn't be flagged as default_content.
        if element.respond_to?(:default_content?) && existing_content
          element.default_content = false
        end

        element
      else
        klass = "Locomotive::#{attributes[:type].to_s.classify}".constantize
        page.editable_elements.build(attributes, klass)
      end
    end

    def assign_block_information(element, blocks)
      if element.block && (options = blocks[element.block])
        element.block_name      = element.block.split('/').last if options[:short_name]
        element.block_priority  = options[:priority]
      end
    end

    def services
      @services ||= Locomotive::Steam::Services.build_instance.tap do |services|
        services.set_site(self.site)
        services.locale = self.locale
      end
    end

    def repository
      services.repositories.page
    end

    def find_page(id, in_memory)
      in_memory[id] ||= Locomotive::Page.find(id)
    end

    def logger
      Rails.logger
    end

  end

end