602 lines
18 KiB
Ruby
Executable file
602 lines
18 KiB
Ruby
Executable file
require 'fileutils'
|
|
|
|
class Skin < ApplicationRecord
|
|
include HtmlCleaner
|
|
include CssCleaner
|
|
include SkinCacheHelper
|
|
include SkinWizard
|
|
|
|
TYPE_OPTIONS = [
|
|
[ts("Site Skin"), "Skin"],
|
|
[ts("Work Skin"), "WorkSkin"],
|
|
]
|
|
|
|
# any media types that are not a single alphanumeric word have to be specially
|
|
# handled in get_media_for_filename/parse_media_from_filename
|
|
MEDIA = %w(all screen handheld speech print braille embossed projection tty tv) + [
|
|
"only screen and (max-width: 42em)",
|
|
"only screen and (max-width: 62em)",
|
|
"(prefers-color-scheme: dark)",
|
|
"(prefers-color-scheme: light)"
|
|
]
|
|
IE_CONDITIONS = %w(IE IE5 IE6 IE7 IE8 IE9 IE8_or_lower)
|
|
ROLES = %w(user override)
|
|
ROLE_NAMES = {"user" => "add on to archive skin", "override" => "replace archive skin entirely"}
|
|
# We don't show some roles to users
|
|
ALL_ROLES = ROLES + %w(admin translator site)
|
|
DEFAULT_ROLE = "user"
|
|
DEFAULT_ROLES_TO_INCLUDE = %w(user override site)
|
|
DEFAULT_MEDIA = ["all"]
|
|
|
|
SKIN_PATH = 'stylesheets/skins/'
|
|
SITE_SKIN_PATH = 'stylesheets/site/'
|
|
|
|
belongs_to :author, class_name: 'User'
|
|
has_many :preferences
|
|
|
|
serialize :media, type: Array, coder: YAML, yaml: { permitted_classes: [String] }
|
|
|
|
# a skin can be both parent and child
|
|
has_many :skin_parents, foreign_key: 'child_skin_id',
|
|
class_name: 'SkinParent',
|
|
dependent: :destroy, inverse_of: :child_skin
|
|
has_many :parent_skins, -> { order("skin_parents.position ASC") }, through: :skin_parents, inverse_of: :child_skins
|
|
|
|
has_many :skin_children, foreign_key: 'parent_skin_id',
|
|
class_name: 'SkinParent', dependent: :destroy, inverse_of: :parent_skin
|
|
has_many :child_skins, through: :skin_children, inverse_of: :parent_skins
|
|
|
|
accepts_nested_attributes_for :skin_parents, allow_destroy: true, reject_if: proc { |attrs| attrs[:position].blank? || (attrs[:parent_skin_title].blank? && attrs[:parent_skin_id].blank?) }
|
|
|
|
has_one_attached :icon do |attachable|
|
|
attachable.variant(:standard, resize_to_limit: [100, 100], loader: { n: -1 })
|
|
end
|
|
|
|
# i18n-tasks-use t("errors.attributes.icon.invalid_format")
|
|
# i18n-tasks-use t("errors.attributes.icon.too_large")
|
|
validates :icon, attachment: {
|
|
allowed_formats: %r{image/\S+},
|
|
maximum_size: ArchiveConfig.ICON_SIZE_KB_MAX.kilobytes
|
|
}
|
|
|
|
after_save :skin_invalidate_cache
|
|
def skin_invalidate_cache
|
|
skin_chooser_expire_cache
|
|
skin_cache_version_update(id)
|
|
|
|
# Work skins can't have children, but site skins (which have type nil)
|
|
# might have children that need expiration:
|
|
return unless type.nil?
|
|
|
|
SkinParent.get_all_child_ids(id).each do |child_id|
|
|
skin_cache_version_update(child_id)
|
|
end
|
|
end
|
|
|
|
validates_length_of :icon_alt_text, allow_blank: true, maximum: ArchiveConfig.ICON_ALT_MAX,
|
|
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.ICON_ALT_MAX)
|
|
|
|
validates_length_of :description, allow_blank: true, maximum: ArchiveConfig.SUMMARY_MAX,
|
|
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.SUMMARY_MAX)
|
|
|
|
validates_length_of :css, allow_blank: true, maximum: ArchiveConfig.CONTENT_MAX,
|
|
too_long: ts("must be less than %{max} characters long.", max: ArchiveConfig.CONTENT_MAX)
|
|
|
|
before_validation :clean_media
|
|
def clean_media
|
|
# handle bizarro cucumber-only error that prevents media from deserializing correctly when attachments are made
|
|
if media && media.is_a?(Array) && !media.empty?
|
|
new_media = media.flatten.compact.collect {|m| m.gsub(/\["(\w+)"\]/, '\1')}
|
|
self.media = new_media
|
|
end
|
|
end
|
|
|
|
validate :valid_media
|
|
def valid_media
|
|
if media && media.is_a?(Array) && media.any? {|m| !MEDIA.include?(m)}
|
|
errors.add(
|
|
:base,
|
|
:invalid_media,
|
|
media: media.join(", ")
|
|
)
|
|
end
|
|
end
|
|
|
|
validates :ie_condition, inclusion: {in: IE_CONDITIONS, allow_nil: true, allow_blank: true}
|
|
validates :role, inclusion: {in: ALL_ROLES, allow_blank: true, allow_nil: true }
|
|
|
|
validate :valid_public_preview
|
|
def valid_public_preview
|
|
return true if self.official? || !self.public? || self.icon.attached?
|
|
errors.add(:base, :no_public_preview)
|
|
end
|
|
|
|
validates :title, presence: true, uniqueness: { case_sensitive: false }
|
|
validate :allowed_title
|
|
def allowed_title
|
|
return true unless self.title.match(/archive/i)
|
|
|
|
authorized_roles = if self.is_a?(WorkSkin)
|
|
%w[superadmin support]
|
|
else
|
|
%w[superadmin]
|
|
end
|
|
|
|
return true if (User.current_user.roles & authorized_roles).present?
|
|
|
|
errors.add(:base, :archive_in_title)
|
|
end
|
|
|
|
validates_numericality_of :margin, :base_em, allow_nil: true
|
|
validate :valid_font
|
|
def valid_font
|
|
return if self.font.blank?
|
|
self.font.split(',').each do |subfont|
|
|
if sanitize_css_font(subfont).blank?
|
|
errors.add(:font, "cannot use #{subfont}.")
|
|
end
|
|
end
|
|
end
|
|
|
|
validate :valid_colors
|
|
def valid_colors
|
|
|
|
if !self.background_color.blank? && sanitize_css_value(self.background_color).blank?
|
|
errors.add(:background_color, "uses a color that is not allowed.")
|
|
end
|
|
|
|
if !self.foreground_color.blank? && sanitize_css_value(self.foreground_color).blank?
|
|
errors.add(:foreground_color, "uses a color that is not allowed.")
|
|
end
|
|
end
|
|
|
|
validate :clean_css
|
|
def clean_css
|
|
return if self.css.blank?
|
|
self.css = clean_css_code(self.css)
|
|
end
|
|
|
|
scope :public_skins, -> { where(public: true) }
|
|
scope :approved_skins, -> { where(official: true, public: true) }
|
|
scope :unapproved_skins, -> { where(public: true, official: false, rejected: false) }
|
|
scope :rejected_skins, -> { where(public: true, official: false, rejected: true) }
|
|
scope :site_skins, -> { where(type: nil) }
|
|
scope :wizard_site_skins, -> { where("type IS NULL AND (
|
|
margin IS NOT NULL OR
|
|
background_color IS NOT NULL OR
|
|
foreground_color IS NOT NULL OR
|
|
font IS NOT NULL OR
|
|
base_em IS NOT NULL OR
|
|
paragraph_margin IS NOT NULL OR
|
|
headercolor IS NOT NULL OR
|
|
accent_color IS NOT NULL
|
|
)
|
|
") }
|
|
|
|
def self.cached
|
|
where(cached: true)
|
|
end
|
|
|
|
def self.in_chooser
|
|
where(in_chooser: true)
|
|
end
|
|
|
|
def self.featured
|
|
where(featured: true)
|
|
end
|
|
|
|
def self.approved_or_owned_by(user = User.current_user)
|
|
if user.nil?
|
|
approved_skins
|
|
else
|
|
approved_or_owned_by_any([user])
|
|
end
|
|
end
|
|
|
|
def self.approved_or_owned_by_any(users)
|
|
where("(public = 1 AND official = 1) OR author_id in (?)", users.map(&:id))
|
|
end
|
|
|
|
def self.usable
|
|
where(unusable: false)
|
|
end
|
|
|
|
def self.sort_by_recent
|
|
order("updated_at DESC")
|
|
end
|
|
|
|
def self.sort_by_recent_featured
|
|
order("featured DESC, updated_at DESC")
|
|
end
|
|
|
|
def approved_or_owned_by?(user)
|
|
self.public? && self.official? || author_id == user.id
|
|
end
|
|
|
|
def remove_me_from_preferences
|
|
Preference.where(skin_id: self.id).update_all(skin_id: AdminSetting.default_skin_id)
|
|
end
|
|
|
|
def editable?
|
|
if self.filename.present?
|
|
return false
|
|
elsif self.official && self.public
|
|
return true if User.current_user.is_a? Admin
|
|
elsif self.author == User.current_user
|
|
return true
|
|
else
|
|
return false
|
|
end
|
|
end
|
|
|
|
def byline
|
|
if self.author.is_a? User
|
|
author.login
|
|
else
|
|
ArchiveConfig.APP_SHORT_NAME
|
|
end
|
|
end
|
|
|
|
def wizard_settings?
|
|
margin.present? || font.present? || background_color.present? || foreground_color.present? || base_em.present? || paragraph_margin.present? || headercolor.present? || accent_color.present?
|
|
end
|
|
|
|
# create the minimal number of files we can, containing all the css for this entire skin
|
|
def cache!
|
|
self.clear_cache!
|
|
self.public = true
|
|
self.official = true
|
|
save!
|
|
css_to_cache = ""
|
|
last_role = ""
|
|
file_count = 1
|
|
skin_dir = Skin.skins_dir + skin_dirname
|
|
FileUtils.mkdir_p skin_dir
|
|
(get_all_parents + [self]).each do |next_skin|
|
|
if next_skin.get_sheet_role != last_role
|
|
# save to file
|
|
if css_to_cache.present?
|
|
cache_filename = skin_dir + "#{file_count}_#{last_role}.css"
|
|
file_count+=1
|
|
File.open(cache_filename, 'w') {|f| f.write(css_to_cache)}
|
|
css_to_cache = ""
|
|
end
|
|
last_role = next_skin.get_sheet_role
|
|
end
|
|
css_to_cache += next_skin.get_css
|
|
end
|
|
# TODO this repetition is all wrong but my brain is fried
|
|
if css_to_cache.present?
|
|
cache_filename = skin_dir + "#{file_count}_#{last_role}.css"
|
|
File.open(cache_filename, 'w') {|f| f.write(css_to_cache)}
|
|
css_to_cache = ""
|
|
end
|
|
self.cached = true
|
|
save!
|
|
end
|
|
|
|
def clear_cache!
|
|
skin_dir = Skin.skins_dir + skin_dirname
|
|
FileUtils.rm_rf skin_dir # clear out old if exists
|
|
self.cached = false
|
|
save!
|
|
end
|
|
|
|
def recache_children!
|
|
child_ids = SkinParent.get_all_child_ids(id)
|
|
Skin.where(cached: true, id: child_ids).find_each(&:cache!)
|
|
end
|
|
|
|
def get_sheet_role
|
|
"#{get_role}_#{get_media_for_filename}_#{ie_condition}"
|
|
end
|
|
|
|
# have to handle any media types that aren't a single alphanumeric word here
|
|
def get_media_for_filename
|
|
((media.nil? || media.empty?) ? DEFAULT_MEDIA : media).map {|m|
|
|
case
|
|
when m.match(/max-width: 42em/)
|
|
"narrow"
|
|
when m.match(/max-width: 62em/)
|
|
"midsize"
|
|
when m.match(/prefers-color-scheme: dark/)
|
|
"dark"
|
|
when m.match(/prefers-color-scheme: light/)
|
|
"light"
|
|
else
|
|
m
|
|
end
|
|
}.join(".")
|
|
end
|
|
|
|
def parse_media_from_filename(media_string)
|
|
media_string.gsub(/narrow/, "only screen and (max-width: 42em)")
|
|
.gsub(/midsize/, "only screen and (max-width: 62em)")
|
|
.gsub(/dark/, "(prefers-color-scheme: dark)")
|
|
.gsub(/light/, "(prefers-color-scheme: light)")
|
|
.gsub(".", ", ")
|
|
end
|
|
|
|
def parse_sheet_role(role_string)
|
|
(sheet_role, sheet_media, sheet_ie_condition) = role_string.split('_')
|
|
sheet_media = parse_media_from_filename(sheet_media)
|
|
[sheet_role, sheet_media, sheet_ie_condition]
|
|
end
|
|
|
|
def get_css
|
|
if filename
|
|
File.read(Rails.public_path.join(filename))
|
|
else
|
|
css
|
|
end
|
|
end
|
|
|
|
def get_media(separator=", ")
|
|
((media.nil? || media.empty?) ? DEFAULT_MEDIA : media).join(separator)
|
|
end
|
|
|
|
def get_role
|
|
self.role || DEFAULT_ROLE
|
|
end
|
|
|
|
def get_all_parents
|
|
all_parents = []
|
|
parent_skins.each do |parent|
|
|
all_parents += parent.get_all_parents
|
|
all_parents << parent
|
|
end
|
|
all_parents
|
|
end
|
|
|
|
# This is the main function that actually returns code to be embedded in a page
|
|
def get_style(roles_to_include = DEFAULT_ROLES_TO_INCLUDE)
|
|
style = ""
|
|
|
|
if self.get_role != "override" && self.get_role != "site" &&
|
|
self.id != AdminSetting.default_skin_id &&
|
|
AdminSetting.default_skin.is_a?(Skin)
|
|
style += AdminSetting.default_skin.get_style(roles_to_include)
|
|
end
|
|
|
|
style += self.get_style_block(roles_to_include)
|
|
style.html_safe
|
|
end
|
|
|
|
def get_ie_comment(style, ie_condition = self.ie_condition)
|
|
if ie_condition.present?
|
|
ie_comment= "<!--[if "
|
|
ie_comment += "lte " if ie_condition.match(/or_lower/)
|
|
ie_comment += "gte " if ie_condition.match(/or_higher/)
|
|
ie_comment += "IE"
|
|
ie_comment += " #{$1}" if ie_condition.match(/IE(\d)/)
|
|
ie_comment += "]>" + style + "<![endif]-->"
|
|
else
|
|
style
|
|
end
|
|
end
|
|
|
|
# This builds the stylesheet, so the order is important
|
|
def get_wizard_settings
|
|
style = ""
|
|
|
|
style += font_size_styles(base_em) if base_em.present?
|
|
|
|
style += font_styles(font) if font.present?
|
|
|
|
style += background_color_styles(background_color) if background_color.present?
|
|
|
|
style += paragraph_margin_styles(paragraph_margin) if paragraph_margin.present?
|
|
|
|
style += foreground_color_styles(foreground_color) if foreground_color.present?
|
|
|
|
style += header_styles(headercolor) if headercolor.present?
|
|
|
|
style += accent_color_styles(accent_color) if accent_color.present?
|
|
|
|
style += work_margin_styles(margin) if margin.present?
|
|
|
|
style
|
|
end
|
|
|
|
def get_style_block(roles_to_include)
|
|
block = ""
|
|
if self.cached?
|
|
# cached skin in a directory
|
|
block = get_cached_style(roles_to_include)
|
|
else
|
|
# recursively get parents
|
|
parent_skins.each do |parent|
|
|
block += parent.get_style_block(roles_to_include) + "\n"
|
|
end
|
|
|
|
# finally get this skin
|
|
if roles_to_include.include?(get_role)
|
|
if self.filename.present?
|
|
block += get_ie_comment(stylesheet_link(self.filename, get_media))
|
|
else
|
|
if (wizard_block = get_wizard_settings).present?
|
|
block += '<style type="text/css" media="' + get_media + '">' + wizard_block + '</style>'
|
|
end
|
|
if self.css.present?
|
|
block += get_ie_comment('<style type="text/css" media="' + get_media + '">' + self.css + '</style>')
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return block
|
|
end
|
|
|
|
def get_cached_style(roles_to_include)
|
|
block = ""
|
|
self_skin_dir = Skin.skins_dir + self.skin_dirname
|
|
Skin.skin_dir_entries(self_skin_dir, /^\d+_(.*)\.css$/).each do |sub_file|
|
|
if sub_file.match(/^\d+_(.*)\.css$/)
|
|
(sheet_role, sheet_media, sheet_ie_condition) = parse_sheet_role($1)
|
|
if roles_to_include.include?(sheet_role)
|
|
block += get_ie_comment(stylesheet_link(SKIN_PATH + self.skin_dirname + sub_file, sheet_media), sheet_ie_condition) + "\n"
|
|
end
|
|
end
|
|
end
|
|
block
|
|
end
|
|
|
|
def stylesheet_link(file, media)
|
|
# we want one and only one / in the url path
|
|
'<link rel="stylesheet" type="text/css" media="' + media + '" href="/' + file.gsub(/^\/*/,"") + '" />'
|
|
end
|
|
|
|
def self.naturalized(string)
|
|
string.scan(/[^\d]+|[\d]+/).collect { |f| f.match(/\d+(\.\d+)?/) ? f.to_f : f }
|
|
end
|
|
|
|
def self.load_site_css
|
|
Skin.skin_dir_entries(Skin.site_skins_dir, /^\d+\.\d+$/).each do |version|
|
|
version_dir = "#{Skin.site_skins_dir + version}/"
|
|
if File.directory?(version_dir)
|
|
# let's load up the file
|
|
skins = []
|
|
Skin.skin_dir_entries(version_dir, /^(\d+)-(.*)\.css/).each do |skin_file|
|
|
filename = SITE_SKIN_PATH + version + '/' + skin_file
|
|
skin_file.match(/^(\d+)-(.*)\.css/)
|
|
position = $1.to_i
|
|
title = $2
|
|
title.gsub!(/(\-|\_)/, ' ')
|
|
description = "Version #{version} of the #{title} component (#{position}) of the default archive site design."
|
|
firstline = File.open(version_dir + skin_file, &:readline)
|
|
skin_role = "site"
|
|
if firstline.match(/ROLE: (\w+)/)
|
|
skin_role = $1
|
|
end
|
|
skin_media = ["screen"]
|
|
if firstline.match(/MEDIA: (.*?) ENDMEDIA/)
|
|
skin_media = $1.split(/,\s?/)
|
|
elsif firstline.match(/MEDIA: (\w+)/)
|
|
skin_media = [$1]
|
|
end
|
|
skin_ie = ""
|
|
if firstline.match(/IE_CONDITION: (\w+)/)
|
|
skin_ie = $1
|
|
end
|
|
|
|
full_title = "Archive #{version}: (#{position}) #{title}"
|
|
skin = Skin.find_by(title: full_title)
|
|
if skin.nil?
|
|
skin = Skin.new
|
|
end
|
|
|
|
# update the attributes
|
|
skin.title ||= full_title
|
|
skin.filename = filename
|
|
skin.description = description
|
|
skin.public = true
|
|
skin.media = skin_media
|
|
skin.role = skin_role
|
|
skin.ie_condition = skin_ie
|
|
skin.unusable = true
|
|
skin.official = true
|
|
skin.icon.attach(io: File.open("#{version_dir}preview.png", "rb"), content_type: "image/png", filename: "preview.png")
|
|
skin.save!(validate: false)
|
|
skins << skin
|
|
end
|
|
|
|
# set up the parent relationship of all the skins in this version
|
|
top_skin = Skin.find_by(title: "Archive #{version}")
|
|
if top_skin
|
|
top_skin.clear_cache! if top_skin.cached?
|
|
top_skin.skin_parents.delete_all
|
|
else
|
|
top_skin = Skin.new(title: "Archive #{version}", css: "", description: "Version #{version} of the default Archive style.",
|
|
public: true, role: "site", media: ["screen"])
|
|
end
|
|
top_skin.icon.attach(io: File.open("#{version_dir}preview.png", "rb"), content_type: "image/png", filename: "preview.png")
|
|
top_skin.official = true
|
|
top_skin.save!(validate: false)
|
|
skins.each_with_index do |skin, index|
|
|
skin_parent = top_skin.skin_parents.build(child_skin: top_skin, parent_skin: skin, position: index+1)
|
|
skin_parent.save!
|
|
end
|
|
if %w(staging unproduction).include? Rails.env
|
|
top_skin.cache!
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
# get the directory name for the skin file
|
|
def skin_dirname
|
|
"skin_#{self.id}_#{self.title.gsub(/[^\w]/, '_')}/".downcase
|
|
end
|
|
|
|
def self.skins_dir
|
|
Rails.public_path.join(SKIN_PATH).to_s
|
|
end
|
|
|
|
def self.skin_dir_entries(dir, regex)
|
|
Dir.entries(dir).select {|f| f.match(regex)}.sort_by {|f| Skin.naturalized(f.to_s)}
|
|
end
|
|
|
|
def self.site_skins_dir
|
|
Rails.public_path.join(SITE_SKIN_PATH).to_s
|
|
end
|
|
|
|
# Get the most recent version and find the topmost skin
|
|
def self.get_current_version
|
|
Skin.skin_dir_entries(Skin.site_skins_dir, /^\d+\.\d+$/).last
|
|
end
|
|
|
|
def self.get_current_site_skin
|
|
current_version = Skin.get_current_version
|
|
if current_version
|
|
Skin.find_by(title: "Archive #{Skin.get_current_version}", official: true)
|
|
else
|
|
nil
|
|
end
|
|
end
|
|
|
|
def self.default
|
|
Skin.find_by(title: "Default", official: true) || Skin.create_default
|
|
end
|
|
|
|
def self.create_default
|
|
transaction do
|
|
skin = Skin.find_or_initialize_by(title: "Default")
|
|
|
|
skin.official = true
|
|
skin.public = true
|
|
skin.role = "site"
|
|
skin.css = ""
|
|
skin.set_thumbnail_from_current_version
|
|
|
|
skin.save!
|
|
skin
|
|
end
|
|
end
|
|
|
|
def self.set_default_to_current_version
|
|
transaction do
|
|
default_skin = default
|
|
|
|
default_skin.set_thumbnail_from_current_version
|
|
|
|
parent_skin = get_current_site_skin
|
|
if parent_skin && default_skin.parent_skins != [parent_skin]
|
|
default_skin.skin_parents.destroy_all
|
|
default_skin.skin_parents.build(parent_skin: parent_skin, position: 1)
|
|
end
|
|
|
|
default_skin.save!
|
|
end
|
|
end
|
|
|
|
def set_thumbnail_from_current_version
|
|
current_version = self.class.get_current_version
|
|
|
|
icon_path = if current_version
|
|
self.class.site_skins_dir + current_version + "/preview.png"
|
|
else
|
|
self.class.site_skins_dir + "preview.png"
|
|
end
|
|
|
|
self.icon.attach(io: File.open(icon_path), content_type: "image/png", filename: "preview.png")
|
|
end
|
|
end
|