5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-22 16:26:21 +00:00
panel/app/models/post.rb
f 996c70f298 guardar la fecha de modificación en el archivo
last_modified_at es utilizada por jekyll-feed y otros plugins, aunque
nos hubiera gustado updated_at
2021-01-25 15:29:14 -03:00

405 lines
12 KiB
Ruby

# frozen_string_literal: true
# Esta clase representa un post en un sitio jekyll e incluye métodos
# para modificarlos y crear nuevos.
#
# rubocop:disable Metrics/ClassLength
# rubocop:disable Style/MissingRespondToMissing
class Post < OpenStruct
# 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
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)
super(**args)
# Genera un método con todos los atributos disponibles
self.attributes = layout.metadata.keys.map(&:to_sym) + PUBLIC_ATTRIBUTES
self.errors = {}
# Genera un atributo por cada uno de los campos de la plantilla,
# MetadataFactory devuelve un tipo de campo por cada campo. A
# partir de ahí se pueden obtener los valores actuales y una lista
# de valores por defecto.
#
# XXX: En el primer intento de hacerlo más óptimo, movimos esta
# lógica a instanciación bajo demanda, pero no solo no logramos
# optimizar sino que aumentamos el tiempo de carga :/
layout.metadata.each_pair do |name, template|
send "#{name}=".to_sym,
MetadataFactory.build(document: document,
post: self,
site: site,
name: name.to_sym,
value: args[name.to_sym],
layout: layout,
type: template['type'],
label: template['label'],
help: template['help'],
required: template['required'])
end
# TODO: Llamar dinámicamente
load_lang!
load_slug!
load_date!
load_path!
load_uuid!
# 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
site.jekyll.config['lang'] = lang.value
site.jekyll.config['locale'] = lang.value
# Payload básico con traducciones.
document.renderer.payload = {
'site' => {
'data' => site.data,
'i18n' => site.data[lang.value],
'lang' => lang.value,
'locale' => lang.value
},
'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
# Solo ejecuta la magia de OpenStruct si el campo existe en la
# plantilla
#
# XXX: Reemplazarlo por nuestro propio método, mantener todo lo demás
# compatible con OpenStruct
#
# XXX: rubocop dice que tenemos que usar super cuando ya lo estamos
# usando...
def method_missing(mid, *args)
# Limpiar el nombre del atributo, para que todos los ayudantes
# reciban el método en limpio
name = attribute_name mid
unless attribute? name
raise NoMethodError, I18n.t('exceptions.post.no_method',
method: mid)
end
# OpenStruct
super(mid, *args)
# Devolver lo mismo que devuelve el método después de definirlo
send(mid, *args)
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
def params
attributes.map do |attr|
send(attr).to_param
end
end
# Genera el post con metadatos en YAML
#
# TODO: Cachear por un minuto
def full_content
body = ''
yaml = layout.metadata.keys.map(&:to_sym).map do |metadata|
template = send(metadata)
unless template.front_matter?
body += "\n\n"
body += template.value
next
end
next if template.empty?
[metadata.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!
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?
self.errors = {}
layout.metadata.keys.map(&:to_sym).each do |metadata|
template = send(metadata)
errors[metadata] = template.errors unless template.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 |name, value|
self[name].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 ||= if (d = document_usuaries).empty?
[]
else
Usuarie.where(id: d).to_a
end
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|
i18n = I18n.t("exceptions.post.#{attr}_missing")
raise ArgumentError, i18n unless args[attr].present?
end
end
def document_usuaries
document.data.fetch('usuaries', [])
end
# Obtiene el nombre del atributo a partir del nombre del método
def attribute_name(attr)
# XXX: Los simbolos van al final
@attribute_name_cache ||= {}
@attribute_name_cache[attr] ||= ATTR_SUFFIXES.reduce(attr.to_s) do |a, suffix|
a.chomp suffix
end.to_sym
end
def load_slug!
self.slug = MetadataSlug.new(document: document, site: site,
layout: layout, name: :slug, type: :slug,
post: self,
required: true)
end
def load_date!
self.date = MetadataDocumentDate.new(document: document, site: site,
layout: layout, name: :date,
type: :document_date,
post: self,
required: true)
end
def load_path!
self.path = MetadataPath.new(document: document, site: site,
layout: layout, name: :path,
type: :path, post: self,
required: true)
end
def load_lang!
self.lang = MetadataLang.new(document: document, site: site,
layout: layout, name: :lang,
type: :lang, post: self,
required: true)
end
def load_uuid!
self.uuid = MetadataUuid.new(document: document, site: site,
layout: layout, name: :uuid,
type: :uuid, post: self,
required: true)
end
# Ejecuta la acción de guardado en cada atributo
def save_attributes!
attributes.map do |attr|
send(attr).save
end.all?
end
end
# rubocop:enable Metrics/ClassLength
# rubocop:enable Style/MissingRespondToMissing