5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-25 16:36:21 +00:00
panel/app/models/post.rb

454 lines
13 KiB
Ruby
Raw Permalink Normal View History

2018-01-29 22:19:10 +00:00
# frozen_string_literal: true
2019-03-26 15:32:20 +00:00
2018-01-29 22:19:10 +00:00
# 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
2023-09-13 20:52:21 +00:00
PUBLIC_ATTRIBUTES = %i[lang date uuid created_at].freeze
2020-10-04 01:32:52 +00:00
ATTR_SUFFIXES = %w[? =].freeze
attr_reader :attributes, :errors, :layout, :site, :document
# TODO: Modificar el historial de Git con callbacks en lugar de
# services. De esta forma podríamos agregar soporte para distintos
# backends.
include ActiveRecord::Callbacks
include Post::Indexable
class << self
# Obtiene el layout sin leer el Document
2020-06-16 22:21:38 +00:00
#
# TODO: Reemplazar cuando leamos el contenido del Document
# a demanda?
2020-10-04 00:32:32 +00:00
def find_layout(path)
2022-04-11 17:57:28 +00:00
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
2021-04-21 17:11:11 +00:00
rescue Errno::ENOENT => e
ExceptionNotifier.notify_exception(e, data: { path: path })
2021-04-21 17:11:11 +00:00
:post
end
2023-10-06 13:13:32 +00:00
# Genera un Post nuevo
#
2023-10-26 21:08:14 +00:00
# @todo Mergear en Post#initialize
# @params :path [String]
2023-10-06 13:13:32 +00:00
# @params :site [Site]
# @params :locale [String, Symbol]
# @params :document [Jekyll::Document]
# @params :layout [String,Symbol]
# @return [Post]
def build(**args)
2023-10-26 21:08:14 +00:00
args[:path] ||= ''
2023-10-06 13:13:32 +00:00
args[:document] ||=
begin
site = args[:site]
collection = site.collections[args[:locale].to_s]
2023-10-26 21:08:14 +00:00
Jekyll::Document.new(args[:path], site: site.jekyll, collection: collection).tap do |doc|
doc.data['date'] = Date.today.to_time if args[:path].blank?
2023-10-06 13:13:32 +00:00
end
end
args[:layout] = args[:site].layouts[args[:layout]] if args[:layout].is_a? Symbol
2023-10-06 13:13:32 +00:00
Post.new(**args)
end
end
# Redefinir el inicializador de OpenStruct
#
2023-10-06 13:13:32 +00:00
# @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)
2020-09-29 21:22:28 +00:00
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
2019-08-07 21:35:37 +00:00
document.read! unless new?
end
2020-06-16 22:21:38 +00:00
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
# XXX: Es necesario leer los layouts para poder renderizar el
# sitio
site.theme_layouts
# Payload básico con traducciones.
document.renderer.payload = {
'site' => {
'data' => site.data,
'i18n' => site.data[l],
'lang' => l,
'locale' => l
},
'page' => document.to_liquid
}
# No tener errores de Liquid
site.jekyll.config['liquid']['strict_filters'] = false
site.jekyll.config['liquid']['strict_variables'] = false
# Renderizar lo estrictamente necesario y convertir a HTML para
# poder reemplazar valores.
html = Nokogiri::HTML document.renderer.render_document
# Los archivos se cargan directamente desde el repositorio, porque
# no son públicas hasta que se publica el artículo.
html.css('img,audio,video,iframe').each do |element|
src = element.attributes['src']
next unless src&.value&.start_with? 'public/'
file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
file.value['path'] = src.value
src.value = Rails.application.routes.url_helpers.url_for(file.static_file)
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
rescue Liquid::Error => e
ExceptionNotifier.notify(e, data: { site: site.name, post: post.id })
''
end
end
2020-05-11 20:28:38 +00:00
# Devuelve una llave para poder guardar el post en una cache
def cache_key
2021-04-21 17:11:11 +00:00
"posts/#{uuid.value}"
2020-05-11 20:28:38 +00:00
end
2020-05-12 15:50:22 +00:00
def cache_version
(updated_at || modified_at).utc.to_s(:usec)
2020-05-12 15:50:22 +00:00
end
# Agregar el timestamp para saber si cambió, siguiendo el módulo
# ActiveRecord::Integration
def cache_key_with_version
2021-04-21 17:11:11 +00:00
"#{cache_key}-#{cache_version}"
2020-05-12 15:50:22 +00:00
end
2020-01-02 23:29:04 +00:00
# TODO: Convertir a UUID?
def id
path.basename
end
2020-05-12 15:50:22 +00:00
alias to_param id
# Fecha de última modificación del archivo
2020-01-02 23:29:04 +00:00
def updated_at
return if new?
2020-01-02 23:29:04 +00:00
File.mtime(path.absolute)
2019-11-06 22:35:48 +00:00
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
2019-08-07 21:35:37 +00:00
# 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
alias locale lang
# 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
2023-09-13 20:52:21 +00:00
# La fecha de creación inmodificable del post
def created_at
2023-09-13 20:53:51 +00:00
@metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at, type: :created_at, post: self, required: true)
2023-09-13 20:52:21 +00:00
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)
2020-06-16 22:21:38 +00:00
included = attributes.include? mid if !included && singleton_class.method_defined?(:attributes)
included
2018-04-27 18:48:26 +00:00
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.
2019-08-13 23:33:57 +00:00
def params
attributes.map do |attr|
public_send(attr)&.to_param
end.compact
2019-08-13 23:33:57 +00:00
end
# Genera el post con metadatos en YAML
2020-06-16 22:21:38 +00:00
#
# TODO: Cachear por un minuto
def full_content
2019-08-13 23:33:57 +00:00
body = ''
yaml = layout.attributes.map do |attr|
template = public_send attr
2018-04-27 18:48:26 +00:00
2019-08-13 23:33:57 +00:00
unless template.front_matter?
body += "\n\n" if body.present?
2019-08-13 23:33:57 +00:00
body += template.value
next
end
# Queremos mantener los Array en el resultado final para que
# siempre respondan a {% for %} en Liquid.
next if template.empty? && !template.value.is_a?(Array)
2019-08-13 23:33:57 +00:00
[attr.to_s, template.value]
2020-11-07 23:51:00 +00:00
end.compact.to_h
2018-02-22 19:01:11 +00:00
2020-01-02 23:29:04 +00:00
# TODO: Convertir a Metadata?
2019-08-07 15:10:14 +00:00
# Asegurarse que haya un layout
yaml['layout'] = layout.name.to_s
2020-01-02 23:29:04 +00:00
yaml['uuid'] = uuid.value
# Y que no se procese liquid
yaml['render_with_liquid'] = false
yaml['usuaries'] = usuaries.map(&:id).uniq
yaml['created_at'] = created_at.value
yaml['last_modified_at'] = modified_at
2019-08-07 15:10:14 +00:00
2019-08-13 23:33:57 +00:00
"#{yaml.to_yaml}---\n\n#{body}"
end
# Eliminar el artículo del repositorio y de la lista de artículos del
# sitio.
#
# TODO: Si el callback falla deberíamos recuperar el archivo.
#
# @return [Post]
def destroy
run_callbacks :destroy do
FileUtils.rm_f path.absolute
end
2018-02-22 19:01:11 +00:00
end
alias destroy! destroy
2018-02-22 19:01:11 +00:00
2019-08-07 15:10:14 +00:00
# Guarda los cambios
2020-06-16 22:21:38 +00:00
# TODO: Agregar una forma de congelar todos los valores y solo guardar
# uno, para no incorporar modificaciones
2019-08-22 01:09:29 +00:00
# rubocop:disable Metrics/CyclomaticComplexity
def save(validate: true)
return false if validate && !valid?
2019-08-08 18:28:23 +00:00
# Salir si tenemos que cambiar el nombre del archivo y no pudimos
2020-10-04 01:32:52 +00:00
return false if !new? && path.changed? && !update_path!
2021-03-25 22:17:34 +00:00
# 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
run_callbacks :save do
return false unless save_attributes!
return false unless write
end
2018-01-29 22:19:10 +00:00
# Vuelve a leer el post para tomar los cambios
document.reset
2019-08-08 18:28:23 +00:00
read
2019-08-07 15:10:14 +00:00
written?
2018-01-29 22:19:10 +00:00
end
2019-08-22 01:09:29 +00:00
# rubocop:enable Metrics/CyclomaticComplexity
2019-03-26 15:32:20 +00:00
alias save! save
2018-01-29 22:19:10 +00:00
# Actualiza la ruta del documento y lo lee
2019-08-07 21:35:37 +00:00
def read
return unless written?
document.path = path.absolute
document.read!
2019-08-08 19:26:47 +00:00
end
def new?
document.path.blank?
2018-01-29 22:19:10 +00:00
end
2019-08-07 21:35:37 +00:00
def written?
File.exist? path.absolute
end
2019-08-08 18:28:23 +00:00
# Actualizar la ubicación del archivo si cambió de lugar y si no
# existe el destino
def update_path!
!File.exist?(path.absolute) &&
2020-10-04 01:32:52 +00:00
FileUtils.mv(path.value_was, path.absolute) &&
2019-08-08 18:28:23 +00:00
document.path = path.absolute
2019-08-07 21:35:37 +00:00
end
2018-01-29 22:19:10 +00:00
# Detecta si el artículo es válido para guardar
def valid?
@errors = {}
2018-02-02 22:20:31 +00:00
2021-02-24 16:05:51 +00:00
attributes.each do |attr|
errors[attr] = self[attr].errors unless self[attr].valid?
end
errors.blank?
end
2018-01-29 22:19:10 +00:00
# Guarda los cambios en el archivo destino
def write
2019-08-07 15:10:14 +00:00
return true if persisted?
2018-01-29 22:19:10 +00:00
2019-08-08 18:28:23 +00:00
Site::Writer.new(site: site, file: path.absolute,
content: full_content).save
2019-08-07 15:10:14 +00:00
end
2018-02-02 22:20:31 +00:00
2019-08-07 15:10:14 +00:00
# Verifica si hace falta escribir cambios
2020-06-16 22:21:38 +00:00
#
# 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.
2019-08-07 15:10:14 +00:00
def persisted?
2019-08-08 18:28:23 +00:00
File.exist?(path.absolute) && full_content == File.read(path.absolute)
2018-02-02 22:20:31 +00:00
end
2019-08-16 23:12:22 +00:00
def destroyed?
!File.exist?(path.absolute)
end
2019-08-13 23:33:57 +00:00
def update_attributes(hashable)
2021-02-24 16:05:51 +00:00
hashable.to_hash.each do |attr, value|
next unless self[attr].writable?
2021-02-24 16:05:51 +00:00
self[attr].value = value
2019-08-13 23:33:57 +00:00
end
save
end
alias update update_attributes
2019-08-13 23:33:57 +00:00
# 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
2021-02-24 16:05:51 +00:00
@usuaries ||= document_usuaries.empty? ? [] : Usuarie.where(id: document_usuaries).to_a
end
2019-08-07 15:10:14 +00:00
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
2021-02-24 16:05:51 +00:00
@document_usuaries ||= document.data.fetch('usuaries', [])
end
2021-02-24 16:05:51 +00:00
# Ejecuta la acción de guardado en cada atributo.
2019-08-22 01:09:29 +00:00
def save_attributes!
attributes.map do |attr|
self[attr].save
2019-08-22 01:09:29 +00:00
end.all?
end
2018-01-29 22:19:10 +00:00
end