module Simplec # This class represents a page in the system. # # == Model and Template Relationship. # # This is a para with `code`. WTF # # Each page has: # # - a class located in: `app/models/page/NAME.rb` # # - a partial template in: +app/views/pages/_NAME.html.erb+ # # Where NAME is the demodulized, snake-case name of the Page Subclass. # # @example Class and template # # # app/models/page/home.rb # class Page::Home < Page # field :h1 # end # # #

My Application

#

<%= @page.tagline %>

# # @!visibility public class Page < ApplicationRecord FILE_FIELDS = [:file, :image].freeze belongs_to :subdomain, optional: false belongs_to :parent, class_name: 'Page', optional: true has_many :children, class_name: 'Page', foreign_key: :parent_id has_many :embedded_images, as: :embeddable, dependent: :delete_all validates :type, presence: true validates :path, uniqueness: { scope: :subdomain_id } validate :validate_path_not_nil! validates :layout, inclusion: {in: :layouts, allow_blank: true} validates :title, presence: true before_validation :match_parent_subdomain before_validation :build_path after_save :link_embedded_images! after_save :index! # @!attribute slug # The value is normalized to a string starting without a leading slash # and ending without a slash. Case is not changed. # @return [String] # @!attribute [r] path # The the path is computed from the slug and the sum all parent pages. # @return [String] # @!attribute title # This is the title of the page. # @return [String] # @!attribute meta_description # This is the meta description tag for the page. # @return [String] # @!attribute layout # This is the layout to be used when the page is rendered. This attribute # overrides the associated Subdomain's default_layout. # # See Simplec::Subdomain#layouts to get a list of optional layouts. # # @return [String] # @!attribute fields # JSONB Postgres field that holds all defined fields. Use only if you # know what you are doing. # @return [JSON] # @!method search(term, options={}) # # If the term is nil or blank, all results will be returned. # # @param term [String] # @param options [Hash] # @option options [Class, Array, String] :types # A Class or Array of Classes (of page types, typically `Page::Home`) to # limit results to. # @option options [Symbol, String] all other options # All other options are matched to the `query` JSONB field. These are # all direct matches on the indexed `query` field. If you want to do # anything more complicated, append it to the scope returned. # # @return [ActiveRecord::Relation] a relation for the query # @!scope class scope :search, ->(term, options={}) { _types = Array(options.delete(:types)) _subdomains = Array(options.delete(:subdomains)) query = all query = query.includes(:subdomain). where(simplec_subdomains: {name: _subdomains}) if _subdomains.any? query = query.where(type: _types) if _types.any? options.each { |k,v| query = query.where("query->>:k = :v", k: k, v: v) } if term.blank? query else tsq = tsquery term query.where("tsv @@ #{tsq}").order("ts_rank_cd(tsv, #{tsq}) DESC") end } # Define a field on the page # # There is as template for each type for customization located in: # app/views/shared/fields/_TYPE.html.erb # # Defines a field on a subclass. This creates a getter and setter for the # name passed in. The options are used when building the administration forms. # # Regular dragonfly validations are available on :file and :image fields. # http://markevans.github.io/dragonfly/models#validations # :string - yields a text input # :text - yields a textarea # :editor - yields a summernote editor # :file - yields a file field # :image - yields a file field with image preview # # @param name [String] name of field to be defined # @param options [Hash] field options # @option options [Symbol] :type one of :string (default), :text, :editor, :file, :image def self.field(name, options={}) fields[name] = {name: name, type: :string}.merge(options) if FILE_FIELDS.member?(fields[name][:type]) dragonfly_accessor name data_field :"#{name}_uid" data_field :"#{name}_name" else data_field(name) end end # @return [Hash] def self.fields @fields ||= Hash.new end # Return a constantized type, whitelisted by known subclasses. # # @raise [RuntimeError] if Page subclass isn't defined. # @return [Class] def self.type(type) ::Page rescue raise '::Page not defined, define it in app/models' raise 'Unsupported Page Type; define in app/models/page/' unless ::Page.subclasses.map(&:name). member?(type) type.constantize end # Return names of fields. # # type: :file, is the only option # def self.field_names(type=nil) _fields = case type when :file fields.select {|k, v| FILE_FIELDS.member?(v[:type])} when :textual fields.select {|k, v| !FILE_FIELDS.member?(v[:type])} else fields end _fields.keys end # Set extra attributes on the record for querying. # # @example set attributes # class Page::Home < Page # has_many :tags # # field :category # # # Where category is a Simplec::Page::field and tags is a defined # # method. # search_query_attributes! :category, :tags # # def tags # self.tags.pluck(:name) # end # end # # # Built-in matching # Page.search('foo', category: 'how-to') # # # Manual matching # Page.search('bar').where("query->>'tags' IN ('home', 'garden')") # def self.search_query_attributes!(*args) @_search_query_attrs = args.map(&:to_sym) end # Get extra attributes on the record for querying. # # See #search_query_attributes! for more information. # # @return [Array] of attributes def self.search_query_attributes @_search_query_attrs = Set.new(@_search_query_attrs).add(:id).to_a end # Index every record. # # Internally this method iterates over all pages in batches of 3. # # @return [NilClass] def self.index! find_each(batch_size: 3) { |page| page.index! } end # Create a to_tsquery statement. # # Mainly used internally, but could be used in custom queries. # # @param input [String] string to be queried # @param options [Hash] optional # @option options [String] :language defaults to 'english' # This is really a future addition, all of the tsvector fields are set to # 'english'. # # @return [String] a to_tsquery statement def self.tsquery(input, options={}) options[:language] ||= 'english' value = input.to_s.strip value = value. gsub('(', ''). gsub(')', ''). gsub(%q('), ''). gsub(' ', '\\ '). gsub(':', ''). gsub("\t", ''). gsub("!", '') value << ':*' query = "to_tsquery(?, ?)" sanitize_sql_array([query, options[:language], value]) end # Return field options for building forms. # def field_options self.class.fields.values end # List parents, closest to furthest. # # This is a recursive, expensive call. # def parents page, parents = self, Array.new while page.parent page = page.parent parents << page end parents end # Build the path of the page to be used in routing. # # Used as a before validation hook. # # @return [String] the computed path for the page def build_path _pages = self.parents.reverse + [self] self.path = _pages.map(&:slug).reject(&:blank?).join('/') end # Sets the #subdomain t that of the parent. # # All pages need to have a matching subdomain to parent page. Does nothing # if there is no parent. # # Used as a before validation hook. # # @return [Simplec::Subdomain] the parent's subdomain def match_parent_subdomain return unless self.parent self.subdomain = self.parent.subdomain end # Search all of the fields text and create an array of all found # Simplec::EmbeddedImages. # # @return [Array] of Simplec::EmbeddedImages def find_embedded_images text = self.fields.values.join(' ') matches = text.scan(/ei=([^&]*)/) encoded_ids = matches.map(&:first) ids = encoded_ids.map { |eid| Base64.urlsafe_decode64(URI.unescape(eid)) } EmbeddedImage.includes(:embeddable).find(ids) end # Set this instance as the #embeddable association on the # Simplec::EmbeddedImage # # Used as an after_save hook. # # @return [Array] of Simplec::EmbeddedImages def link_embedded_images! images = self.find_embedded_images images.each do |image| raise AlreadyLinkedEmbeddedImage if image.embeddable && image.embeddable != self image.update!(embeddable: self) end end # Get layout options. # # See Simplec::Subdomain#layouts. # # @return [Array] of layout String names def layouts @layouts ||= Subdomain.new.layouts end # Index this record for search. # # Internally, this method uses update_columns so it can be used in # `after_save` callbacks, etc. # # @return [Boolean] success def index! set_search_text! set_query_attributes! update_columns text: self.text, query: self.query end # Extract text out of HTML or plain strings. Basically removes html # formatting. # # @param attributes [Symbol, String] # variable list of attributes or methods to be extracted for search # # @return [String] content of each attribute separated by new lines def extract_search_text(*attributes) Array(attributes).map { |meth| Nokogiri::HTML(self.send(meth)).xpath("//text()"). map {|node| text = node.text; text.try(:strip!); text}.join(" ") }.reject(&:blank?).join("\n") end # Set the text which will be index. # # a title, meta_description # b slug, path (non-printable, add tags, added terms) # c textual fields # d (reserved for sub-records, etc) # # 'a' correlates to 'A' priority in Postgresql. For more information: # https://www.postgresql.org/docs/9.6/static/functions-textsearch.html # def set_search_text! self.text['a'] = extract_search_text :title, :meta_description self.text['b'] = extract_search_text :slug, :path self.text['c'] = extract_search_text *self.class.field_names(:textual) self.text['d'] = nil end # Build query attribute hash. # # Internally stored as JSONB. # # @return [Hash] to be set for query attribute def set_query_attributes! attr_names = self.class.search_query_attributes.map(&:to_s) self.query = attr_names.inject({}) { |memo, attr| memo[attr] = self.send(attr) memo } end # @!visibility private module Normalizers def slug=(val) super clean_path(val) end def slug if self.parent_id && self.parent.nil? clean_path(self.path) else super end end end prepend Normalizers class AlreadyLinkedEmbeddedImage < StandardError; end private def clean_path(val) val ? val.to_s.strip.gsub(/[\s-]+/, '-').gsub(/[^\w-]/, '').split('/').reject(&:blank?).join('/') : val end def validate_path_not_nil! return unless self.path.nil? errors.add :path, "cannot be nil" end def self.data_field(name) define_method(name) { fields[name.to_s] } define_method("#{name}=") { |val| fields[name.to_s] = val } end end end