pages/app/models/refinery/page.rb
# Encoding: utf-8
require 'friendly_id'
require 'friendly_id/mobility'
require 'refinery/core/base_model'
require 'refinery/pages/url'
require 'refinery/pages/finder'
module Refinery
class Page < Core::BaseModel
extend Mobility
translates :title, :menu_title, :custom_slug, :slug, :browser_title, :meta_description
after_save { translations.in_locale(Mobility.locale).seo_meta.save! }
class Translation
is_seo_meta
end
has_many :parts, -> {
scope = ::Refinery::PagePart.respond_to?(:mobility) ? i18n.includes(:translations) : all
scope = scope.order('position ASC')
scope
}, :foreign_key => :refinery_page_id,
:class_name => '::Refinery::PagePart',
:inverse_of => :page,
:dependent => :destroy
accepts_nested_attributes_for :parts, :allow_destroy => true
# Docs for acts_as_nested_set https://github.com/collectiveidea/awesome_nested_set
# rather than :delete_all we want :destroy
acts_as_nested_set :dependent => :destroy
class FriendlyIdOptions
def self.options
# Docs for friendly_id https://github.com/norman/friendly_id
friendly_id_options = {
use: [:mobility, :reserved],
reserved_words: Refinery::Pages.friendly_id_reserved_words
}
if ::Refinery::Pages.scope_slug_by_parent
friendly_id_options[:use] << :scoped
friendly_id_options.merge!(scope: :parent)
end
friendly_id_options
end
end
extend FriendlyId
friendly_id :custom_slug_or_title, FriendlyIdOptions.options
# If title changes tell friendly_id to regenerate slug when saving record
def should_generate_new_friendly_id?
title_changed? || custom_slug_changed?
end
validates :title, presence: true
validates :custom_slug, uniqueness: true, allow_blank: true
before_destroy :deletable?
after_save :reposition_parts!
after_save :update_all_descendants
after_move :update_all_descendants
class << self
# Live pages are 'allowed' to be shown in the frontend of your website.
# By default, this is all pages that are not set as 'draft'.
def live
where(:draft => false)
end
# Find page by path, checking for scoping rules
def find_by_path(path)
Pages::Finder.by_path(path)
end
# Helps to resolve the situation where you have a path and an id
# and if the path is unfriendly then a different finder method is required
# than find_by_path.
def find_by_path_or_id(path, id)
Pages::Finder.by_path_or_id(path, id)
end
# Helps to resolve the situation where you have a path and an id
# and if the path is unfriendly then a different finder method is required
# than find_by_path.
#
# raise ActiveRecord::RecordNotFound if not found.
def find_by_path_or_id!(path, id)
page = find_by_path_or_id(path, id)
raise ::ActiveRecord::RecordNotFound unless page
page
end
# Finds pages by their title. This method is necessary because pages
# are translated which means the title attribute does not exist on the
# pages table thus requiring us to find the attribute on the translations table
# and then join to the pages table again to return the associated record.
def by_title(title)
Pages::Finder.by_title(title)
end
# Finds pages by their slug. This method is necessary because pages
# are translated which means the slug attribute does not exist on the
# pages table thus requiring us to find the attribute on the translations table
# and then join to the pages table again to return the associated record.
def by_slug(slug, conditions = {})
Pages::Finder.by_slug(slug, conditions)
end
# Shows all pages with :show_in_menu set to true, but it also
# rejects any page that has not been translated to the current locale.
# This works using a query against the translated content first and then
# using all of the page_ids we further filter against this model's table.
def in_menu
where(show_in_menu: true).with_mobility
end
# An optimised scope containing only live pages ordered for display in a menu.
def fast_menu
live.in_menu.order(arel_table[:lft]).includes(:parent, :translations)
end
# Wrap up the logic of finding the pages based on the translations table.
def with_mobility(conditions = {})
Pages::Finder.with_mobility(conditions)
end
# Returns how many pages per page should there be when paginating pages
def per_page(dialog = false)
dialog ? Pages.pages_per_dialog : Pages.pages_per_admin_index
end
def rebuild!
super
nullify_duplicate_slugs_under_the_same_parent!
end
protected
def nullify_duplicate_slugs_under_the_same_parent!
t_slug = Translation.arel_table[:slug]
joins(:translations).group(:locale, :parent_id, t_slug).having(t_slug.count.gt(1)).count.
each do |(locale, parent_id, slug), count|
by_slug(slug, :locale => locale).where(:parent_id => parent_id).drop(1).each do |page|
page.slug = nil # kill the duplicate slug
page.save # regenerate the slug
end
end
end
end
def translated_to_default_locale?
persisted? && translations.any?{ |t| t.locale.to_sym == Refinery::I18n.default_frontend_locale}
end
# The canonical page for this particular page.
# Consists of:
# * The current locale's translated slug
def canonical
Mobility.with_locale(::Refinery::I18n.current_frontend_locale) { url }
end
# The canonical slug for this particular page.
# This is the slug for the current frontend locale.
def canonical_slug
Mobility.with_locale(::Refinery::I18n.current_frontend_locale) { slug }
end
# Returns in cascading order: custom_slug or menu_title or title depending on
# which attribute is first found to be present for this page.
def custom_slug_or_title
(Refinery::Pages.use_custom_slugs && custom_slug.presence) ||
menu_title.presence || title.presence
end
# Am I allowed to delete this page?
# If a link_url is set we don't want to break the link so we don't allow them to delete
# If deletable is set to false then we don't allow this page to be deleted. These are often Refinery system pages
def deletable?
deletable && link_url.blank? && menu_match.blank?
end
# Repositions the child page_parts that belong to this page.
# This ensures that they are in the correct 0,1,2,3,4... etc order.
def reposition_parts!
reload.parts.each_with_index do |part, index|
part.update_columns position: index
end
end
# Before destroying a page we check to see if it's a deletable page or not
# Refinery system pages are not deletable.
def destroy
return super if deletable?
puts_destroy_help
false
end
# If you want to destroy a page that is set to be not deletable this is the way to do it.
def destroy!
self.update(:menu_match => nil, :link_url => nil, :deletable => true)
self.destroy
end
# Returns the full path to this page.
# This automatically prints out this page title and all parent page titles.
# The result is joined by the path_separator argument.
def path(path_separator: ' - ', ancestors_first: true)
return title if root?
chain = ancestors_first ? self_and_ancestors : self_and_ancestors.reverse
chain.map(&:title).join(path_separator)
end
def url
Pages::Url.build(self)
end
def nested_url
Mobility.with_locale(slug_locale) do
if ::Refinery::Pages.scope_slug_by_parent && !root?
self_and_ancestors.includes(:translations).map(&:to_param)
else
[to_param.to_s]
end
end
end
# Returns an array with all ancestors to_param, allow with its own
# Ex: with an About page and a Mission underneath,
# ::Refinery::Page.find('mission').nested_url would return:
#
# ['about', 'mission']
#
alias_method :uncached_nested_url, :nested_url
# Returns the string version of nested_url, i.e., the path that should be
# generated by the router
def nested_path
['', nested_url].join('/')
end
# Returns true if this page is "published"
def live?
!draft?
end
# Return true if this page can be shown in the navigation.
# If it's a draft or is set to not show in the menu it will return false.
def in_menu?
live? && show_in_menu?
end
def not_in_menu?
!in_menu?
end
# Returns all visible sibling pages that can be rendered for the menu
def shown_siblings
siblings.reject(&:not_in_menu?)
end
def to_refinery_menu_item
{
:id => id,
:lft => lft,
:depth => depth,
:menu_match => menu_match,
:parent_id => parent_id,
:rgt => rgt,
:title => menu_title.presence || title.presence,
:type => self.class.name,
:url => url
}
end
# Accessor method to get a page part from a page.
# Example:
#
# ::Refinery::Page.first.content_for(:body)
#
# Will return the body page part of the first page.
def content_for(part_slug)
part_with_slug(part_slug).try(:body)
end
# Accessor method to test whether a page part
# exists and has content for this page.
# Example:
#
# ::Refinery::Page.first.content_for?(:body)
#
# Will return true if the page has a body page part and it is not blank.
def content_for?(part_slug)
content_for(part_slug).present?
end
# Accessor method to get a page part object from a page.
# Example:
#
# ::Refinery::Page.first.part_with_slug(:body)
#
# Will return the Refinery::PagePart object with that slug using the first page.
def part_with_slug(part_slug)
# self.parts is usually already eager loaded so we can now just grab
# the first element matching the title we specified.
self.parts.detect do |part|
part.slug_matches?(part_slug)
end
end
# Protects generated slugs from title if they are in the list of reserved words
# This applies mostly to plugin-generated pages.
# This only kicks in when Refinery::Pages.marketable_urls is enabled.
# Also check for global scoping, and if enabled, allow slashes in slug.
#
# Returns the sluggified string
def normalize_friendly_id(slug_string)
FriendlyIdPath.normalize_friendly_id(slug_string)
end
private
class FriendlyIdPath
def self.normalize_friendly_id_path(slug_string)
# Remove leading and trailing slashes, but allow internal
slug_string
.sub(%r{^/*}, '')
.sub(%r{/*$}, '')
.split('/')
.select(&:present?)
.map { |slug| self.normalize_friendly_id(slug) }.join('/')
end
def self.normalize_friendly_id(slug_string)
# If we are scoping by parent, no slashes are allowed. Otherwise, slug is
# potentially a custom slug that contains a custom route to the page.
if !Pages.scope_slug_by_parent && slug_string.include?('/')
self.normalize_friendly_id_path(slug_string)
else
self.protected_slug_string(slug_string)
end
end
def self.protected_slug_string(slug_string)
sluggified = slug_string.to_slug.normalize!
if Pages.marketable_urls && Refinery::Pages.friendly_id_reserved_words.include?(sluggified)
sluggified << "-page"
end
sluggified
end
end
def puts_destroy_help
puts "This page is not deletable. Please use .destroy! if you really want it deleted "
puts "unset .link_url," if link_url.present?
puts "unset .menu_match," if menu_match.present?
puts "set .deletable to true" unless deletable
end
def slug_locale
return Mobility.locale if slug
if translations.empty? || slug(locale: Refinery::I18n.default_frontend_locale)
Refinery::I18n.default_frontend_locale
else
translations.first.locale
end
end
def update_all_descendants
self.descendants.map(&:touch)
end
end
end