5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-07-03 12:56:07 +00:00
panel/app/models/post.rb
f 2a70d6a8db no permitir modificaciones en los artículos marcados como solo lectura
antes permitiamos la modificación en memoria pero no al salvar, lo que
producía un bug porque el valor se seteaba pero no se aplicaban
conversiones al guardarlo.  los numeros, por ejemplo se guardaban como
strings.

XXX: no aplicar conversiones al guardar sino al setear.
2021-04-16 14:11:28 -03:00

388 lines
11 KiB
Ruby

# frozen_string_literal: true
# Esta clase representa un post en un sitio jekyll e incluye métodos
# para modificarlos y crear nuevos.
#
# * Los metadatos se tienen que cargar dinámicamente, solo usamos los
# que necesitamos
#
#
class Post
# Atributos por defecto
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
# Otros atributos que no vienen en los metadatos
PRIVATE_ATTRIBUTES = %i[path slug attributes errors].freeze
PUBLIC_ATTRIBUTES = %i[lang date uuid].freeze
ATTR_SUFFIXES = %w[? =].freeze
attr_reader :attributes, :errors, :layout, :site, :document
class << self
# Obtiene el layout sin leer el Document
#
# TODO: Reemplazar cuando leamos el contenido del Document
# a demanda?
def find_layout(path)
IO.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
end
end
# Redefinir el inicializador de OpenStruct
#
# @param site: [Site] el sitio en Sutty
# @param document: [Jekyll::Document] el documento leído por Jekyll
# @param layout: [Layout] la plantilla
#
def initialize(**args)
default_attributes_missing(**args)
# Genera un método con todos los atributos disponibles
@layout = args[:layout]
@site = args[:site]
@document = args[:document]
@attributes = layout.attributes + PUBLIC_ATTRIBUTES
@errors = {}
@metadata = {}
# Inicializar valores
attributes.each do |attr|
public_send(attr)&.value = args[attr] if args.key?(attr)
end
# XXX: No usamos Post#read porque a esta altura todavía no sabemos
# nada del Document
document.read! if File.exist? document.path
end
def inspect
"#<Post id=\"#{id}\">"
end
# Renderiza el artículo para poder previsualizarlo. Leemos solo la
# información básica, con lo que no van a funcionar artículos
# relacionados y otras cuestiones.
#
# @see app/lib/jekyll/tags/base.rb
def render
Dir.chdir site.path do
# Compatibilidad con jekyll-locales, necesario para el filtro
# date_local
#
# TODO: Cambiar el locale en otro lado
l = lang.value.to_s
site.jekyll.config['locale'] = site.jekyll.config['lang'] = l
# Payload básico con traducciones.
document.renderer.payload = {
'site' => {
'data' => site.data,
'i18n' => site.data[l],
'lang' => l,
'locale' => l
},
'page' => document.to_liquid
}
# Renderizar lo estrictamente necesario y convertir a HTML para
# poder reemplazar valores.
html = Nokogiri::HTML document.renderer.render_document
# Las imágenes se cargan directamente desde el repositorio, porque
# no son públicas hasta que se publica el artículo.
html.css('img').each do |img|
next if %r{\Ahttps?://} =~ img.attributes['src']
img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site,
file: img.attributes['src'].value)
end
# Notificar a les usuaries que están viendo una previsualización
# XXX: Asume que estamos usando Bootstrap :B
html.at_css('body')&.first_element_child&.before("<div class=\"alert alert-warning text-center\">#{I18n.t('posts.preview.message')}</div>")
# Cacofonía
html.to_html.html_safe
end
end
# Devuelve una llave para poder guardar el post en una cache
def cache_key
'posts/' + uuid.value
end
def cache_version
updated_at.utc.to_s(:usec)
end
# Agregar el timestamp para saber si cambió, siguiendo el módulo
# ActiveRecord::Integration
def cache_key_with_version
cache_key + '-' + cache_version
end
# TODO: Convertir a UUID?
def id
path.basename
end
alias to_param id
# Fecha de última modificación del archivo
def updated_at
File.mtime(path.absolute)
end
# Obtiene la fecha actual de modificación y la guarda hasta la próxima
# vez.
def modified_at
@modified_at ||= Time.now
end
def [](attr)
public_send attr
end
# Define metadatos a demanda
def method_missing(name, *_args)
# Limpiar el nombre del atributo, para que todos los ayudantes
# reciban el método en limpio
unless attribute? name
raise NoMethodError, I18n.t('exceptions.post.no_method',
method: name)
end
define_singleton_method(name) do
template = layout.metadata[name.to_s]
@metadata[name] ||=
MetadataFactory.build(document: document,
post: self,
site: site,
name: name,
layout: layout,
type: template['type'],
label: template['label'],
help: template['help'],
required: template['required'])
end
public_send name
end
# TODO: Mover a method_missing
def slug
@metadata[:slug] ||= MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug,
post: self, required: true)
end
# TODO: Mover a method_missing
def date
@metadata[:date] ||= MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date,
type: :document_date, post: self, required: true)
end
# TODO: Mover a method_missing
def path
@metadata[:path] ||= MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path,
post: self, required: true)
end
# TODO: Mover a method_missing
def lang
@metadata[:lang] ||= MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang,
post: self, required: true)
end
# TODO: Mover a method_missing
def uuid
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
post: self, required: true)
end
# Detecta si es un atributo válido o no, a partir de la tabla de la
# plantilla
def attribute?(mid)
included = DEFAULT_ATTRIBUTES.include?(mid) ||
PRIVATE_ATTRIBUTES.include?(mid) ||
PUBLIC_ATTRIBUTES.include?(mid)
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
included
end
# Devuelve los strong params para el layout.
#
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
# del valor por defecto que a su vez depende de Layout.
def params
attributes.map do |attr|
public_send(attr)&.to_param
end.compact
end
# Genera el post con metadatos en YAML
#
# TODO: Cachear por un minuto
def full_content
body = ''
yaml = layout.attributes.map do |attr|
template = public_send attr
unless template.front_matter?
body += "\n\n"
body += template.value
next
end
next if template.empty?
[attr.to_s, template.value]
end.compact.to_h
# TODO: Convertir a Metadata?
# Asegurarse que haya un layout
yaml['layout'] = layout.name.to_s
yaml['uuid'] = uuid.value
# Y que no se procese liquid
yaml['liquid'] = false
yaml['usuaries'] = usuaries.map(&:id).uniq
yaml['last_modified_at'] = modified_at
"#{yaml.to_yaml}---\n\n#{body}"
end
# Eliminar el artículo del repositorio y de la lista de artículos del
# sitio
def destroy
FileUtils.rm_f path.absolute
site.delete_post self
end
alias destroy! destroy
# Guarda los cambios
# TODO: Agregar una forma de congelar todos los valores y solo guardar
# uno, para no incorporar modificaciones
# rubocop:disable Metrics/CyclomaticComplexity
def save(validate: true)
return false if validate && !valid?
# Salir si tenemos que cambiar el nombre del archivo y no pudimos
return false if !new? && path.changed? && !update_path!
# Si el archivo va a estar duplicado, agregar un número al slug
if new? && written?
original_slug = slug.value
count = 1
while written?
count += 1
slug.value = "#{original_slug}-#{count}"
end
end
return false unless save_attributes!
return false unless write
# Vuelve a leer el post para tomar los cambios
read
written?
end
# rubocop:enable Metrics/CyclomaticComplexity
alias save! save
# Actualiza la ruta del documento y lo lee
def read
return unless written?
document.path = path.absolute
document.read!
end
def new?
document.path.blank?
end
def written?
File.exist? path.absolute
end
# Actualizar la ubicación del archivo si cambió de lugar y si no
# existe el destino
def update_path!
!File.exist?(path.absolute) &&
FileUtils.mv(path.value_was, path.absolute) &&
document.path = path.absolute
end
# Detecta si el artículo es válido para guardar
def valid?
@errors = {}
attributes.each do |attr|
errors[attr] = self[attr].errors unless self[attr].valid?
end
errors.blank?
end
# Guarda los cambios en el archivo destino
def write
return true if persisted?
Site::Writer.new(site: site, file: path.absolute,
content: full_content).save
end
# Verifica si hace falta escribir cambios
#
# TODO: Cachear el resultado o usar otro método, por ejemplo guardando
# la fecha de modificación al leer y compararla al hacer cambios sin
# escribirlos.
def persisted?
File.exist?(path.absolute) && full_content == File.read(path.absolute)
end
def destroyed?
!File.exist?(path.absolute)
end
def update_attributes(hashable)
hashable.to_hash.each do |attr, value|
next unless self[attr].writable?
self[attr].value = value
end
save
end
alias update update_attributes
# El Document guarda un Array de los ids de Usuarie. Si está vacío,
# no hacemos una consulta vacía. Si no, traemos todes les Usuaries
# por su id y convertimos a Array para poder agregar o quitar luego
# sin pasar por ActiveRecord.
def usuaries
@usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a
end
private
# Levanta un error si al construir el artículo no pasamos un atributo.
def default_attributes_missing(**args)
DEFAULT_ATTRIBUTES.each do |attr|
raise ArgumentError, I18n.t("exceptions.post.#{attr}_missing") unless args[attr].present?
end
end
def document_usuaries
@document_usuaries ||= document.data.fetch('usuaries', [])
end
# Ejecuta la acción de guardado en cada atributo.
def save_attributes!
attributes.map do |attr|
self[attr].save
end.all?
end
end