sutty/app/models/post.rb

610 lines
16 KiB
Ruby
Raw 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
require 'jekyll/utils'
# Esta clase representa un post en un sitio jekyll e incluye métodos
# para modificarlos y crear nuevos
#
# Cuando estamos editando un post, instanciamos este modelo y le
# asociamos el Jekyll::Document correspondiente.
#
# Cuando estamos creando un post, no creamos su Jekyll::Document
# hasta que se guardan los datos, porque para poder guardarlo
# necesitamos su front_matter completo.
#
# El front matter está duplicado. El Post mantiene una copia de los
# datos y los sincroniza al momento de leer y de escribir el Document.
2018-01-29 22:19:10 +00:00
class Post
attr_accessor :content, :front_matter
2018-05-11 20:00:45 +00:00
attr_reader :post, :site, :errors, :old_post, :lang, :template,
2019-03-26 15:32:20 +00:00
:template_fields
2018-01-29 22:19:10 +00:00
REJECT_FROM_DATA = %w[excerpt].freeze
# datos que no tienen que terminar en el front matter
REJECT_FROM_FRONT_MATTER = %w[date slug ext].freeze
2018-05-11 20:00:45 +00:00
# datos que no traemos del template
REJECT_FROM_TEMPLATE = %w[draft categories layout ext tags date slug post pre].freeze
2018-06-21 18:15:03 +00:00
DEFAULT_PARAMS = [:title, :date, :content, :slug, :cover,
2019-03-26 15:32:20 +00:00
:layout, :permalink, :dir,
{ lang: {} }, { tags: [] }, { categories: [] }].freeze
2018-01-29 22:19:10 +00:00
def inspect
"#<Post @id=#{id} @site=#{site.name}>"
end
2018-01-29 22:19:10 +00:00
# Trabajar con posts. Si estamos creando uno nuevo, el **site** y
# el **front_matter** son necesarios, sino, **site** y **post**.
# XXX chequear que se den las condiciones
def initialize(site:, post: nil, front_matter: {}, lang: nil, template: nil)
unless site.is_a?(Site)
raise ArgumentError,
2019-03-26 15:32:20 +00:00
I18n.t('errors.argument_error', argument: :site, class: Site)
end
unless post.nil? || post.is_a?(Jekyll::Document)
raise ArgumentError,
2019-03-26 15:32:20 +00:00
I18n.t('errors.argument_error', argument: :post,
class: Jekyll::Document)
end
2018-02-02 22:20:31 +00:00
2018-01-29 22:19:10 +00:00
@site = site
@post = post
@template = template
2019-03-26 15:32:20 +00:00
# los errores tienen que ser un hash para que
2018-01-31 20:29:27 +00:00
# ActiveModel pueda traer los errores normalmente
@errors = {}
2018-01-29 22:19:10 +00:00
2018-02-23 19:20:51 +00:00
# Si el sitio está traducido, trabajamos con la colección del
# idioma, sino, con posts
2019-03-26 15:32:20 +00:00
@collection = if @site.i18n?
@lang = lang || I18n.locale.to_s
else
'posts'
end
2018-02-23 19:20:51 +00:00
# sincronizar los datos del document
if new?
@front_matter = front_matter_from_template
update_attributes front_matter
else
load_front_matter!
merge_with_front_matter! front_matter.stringify_keys
end
end
# Limpiar los errores
def reset_errors!
@errors = {}
2018-01-29 22:19:10 +00:00
end
# El post es nuevo si no hay un documento asociado
2018-01-29 22:19:10 +00:00
def new?
@post.nil? || !File.exist?(@post.try(:path))
2018-01-29 22:19:10 +00:00
end
def draft?
fetch_front_matter('draft', false)
end
def incomplete?
fetch_front_matter('incomplete', false)
end
2018-04-27 18:48:26 +00:00
# El número de orden del artículo, si no tiene uno, se le asigna la
# posición en la colección de artículos
def order
get_front_matter 'order'
end
2018-04-27 18:59:51 +00:00
def ordered?
2018-04-27 18:48:26 +00:00
!order.nil?
end
2018-02-22 19:01:11 +00:00
# Determina si fue traducido, buscando los slugs de su front_matter
# lang en otras colecciones
def translated?
2018-02-23 19:20:51 +00:00
@site.i18n? && get_front_matter('lang').present?
2018-02-22 19:01:11 +00:00
end
def translations
@translations ||= find_translations
end
def find_translations
slugs = get_front_matter('lang')
return [] unless slugs.present?
slugs.map do |lang, id|
2018-02-22 19:01:11 +00:00
next if lang == @lang
2019-03-26 15:32:20 +00:00
2018-02-22 19:01:11 +00:00
@site.posts_for(lang).find do |p|
p.id == id
end
end.compact
end
# Guarda los cambios.
#
# Recién cuando vamos a guardar creamos el Post, porque ya tenemos
# todos los datos para escribir el archivo, que es la condición
# necesaria para poder crearlo :P
2018-01-29 22:19:10 +00:00
def save
cleanup!
return false unless valid?
new_post if new?
2018-01-29 22:19:10 +00:00
return unless write
return unless detect_file_rename!
# Vuelve a leer el post para tomar los cambios
@post.read
add_post_to_site!
2018-01-29 22:19:10 +00:00
true
end
2019-03-26 15:32:20 +00:00
alias save! save
2018-01-29 22:19:10 +00:00
def title
get_front_matter 'title'
2018-01-29 22:19:10 +00:00
end
def author
get_front_matter 'author'
end
2018-01-29 22:19:10 +00:00
def date
get_front_matter 'date'
2018-01-29 22:19:10 +00:00
end
def date_as_string
date.strftime('%F')
end
2018-01-29 22:19:10 +00:00
def tags
get_front_matter('tags') || []
2018-01-29 22:19:10 +00:00
end
def categories
get_front_matter('categories') || []
2018-01-29 22:19:10 +00:00
end
2019-03-26 15:32:20 +00:00
alias category categories
2018-01-29 22:19:10 +00:00
# Devuelve la ruta del post, si se cambió alguno de los datos,
# generamos una ruta nueva para tener siempre la ruta actualizada.
2018-01-29 22:19:10 +00:00
def path
2018-02-23 19:20:51 +00:00
if basename_changed?
File.join(@site.path, "_#{@collection}", basename_from_front_matter)
else
@post.try(:path)
end
2018-01-29 22:19:10 +00:00
end
2019-03-26 15:32:20 +00:00
# TODO: los slugs se pueden repetir, el identificador real sería
# fecha+slug, pero se ve feo en las urls?
2018-01-29 22:19:10 +00:00
def id
get_front_matter 'slug'
2018-01-29 22:19:10 +00:00
end
2019-03-26 15:32:20 +00:00
alias slug id
alias to_s id
2018-01-29 22:19:10 +00:00
def basename_changed?
@post.try(:basename) != basename_from_front_matter
2018-01-30 15:20:19 +00:00
end
def slug_changed?
new? || @post.data.dig('slug') != slug
end
# Trae el contenido del post, si no lo seteamos ya. O sea que si solo
# enviamos actualizaciones al front matter, debería traer el contenido
# del post sin cambios
2018-01-30 15:20:19 +00:00
def content
2018-05-14 19:27:29 +00:00
@content ||= @post.try(:content) || template.try(:content)
2018-02-02 22:20:31 +00:00
end
# Determina si el post lleva contenido o es solo front_matter
def content?
2018-07-23 19:48:34 +00:00
has_field? :content
end
def has_field?(field)
if template
2019-03-26 15:32:20 +00:00
template.fetch_front_matter("has_#{field}", true)
else
true
end
end
2018-02-02 22:20:31 +00:00
# imita Model.update_attributes de ActiveRecord
2018-09-11 16:17:02 +00:00
# TODO Todo esto es malísimo, necesitamos una forma genérica de
# convertir params a objetos ruby (o que lo haga YAML directamente,
# ya que estamos). Tal vez separar en varios pasos, uno que arregle
# los hashes con indices numericos y los convierta a arrays, otro que
# convierta los params en hashes, otro que convierta los archivos
# temporales en una subida de archivos, etc.
2018-02-02 22:20:31 +00:00
def update_attributes(attrs)
2018-06-25 18:05:35 +00:00
# convertir los hashes en arrays si los campos son anidados
# usamos to_hash por todos lados porque sino son
2018-06-25 18:05:35 +00:00
# HashWithIndifferentAccess
2019-03-26 15:32:20 +00:00
_attrs = attrs.to_hash.map do |k, v|
2018-06-25 18:05:35 +00:00
t = template_fields.find { |t| t.key == k }
2018-07-02 20:45:32 +00:00
if t
# Subir la imagen!
2018-07-02 22:07:06 +00:00
# TODO pasar a su propio método
2018-07-02 20:45:32 +00:00
if t.image?
begin
i = Post::ImageUploader.new(site)
2018-07-20 16:34:33 +00:00
if t.multiple?
v = v.map do |tmp|
i.store! tmp.tempfile
i.url
end
else
i.store! v.tempfile
v = i.url
end
rescue CarrierWave::ProcessingError, CarrierWave::IntegrityError => e
2018-07-02 20:45:32 +00:00
v = e.message
end
end
if t.nested?
v = t.array? ? v.map(&:to_hash) : v.to_hash
2018-07-02 20:45:32 +00:00
end
2018-06-27 22:26:33 +00:00
end
2018-07-02 20:45:32 +00:00
2018-09-11 15:51:44 +00:00
if v.is_a? ActionController::Parameters
{ k => v.to_hash }
else
{ k => v }
end
2019-03-26 15:32:20 +00:00
end.reduce({}, :merge).stringify_keys
2018-06-25 18:05:35 +00:00
# el cuerpo se maneja por separado
@content = _attrs.delete('content') if _attrs.key? 'content'
merge_with_front_matter! _attrs
end
# Requisitos para que el post sea válido
2018-07-02 20:45:32 +00:00
# TODO verificar que el id sea único
# TODO validar los parametros de la plantilla
def validate
add_error validate: I18n.t('posts.errors.date') unless date.is_a? Time
add_error validate: I18n.t('posts.errors.title') if title.blank?
2018-07-23 15:40:37 +00:00
add_error validate: I18n.t('posts.errors.slug_with_path') if slug.try(:include?, '/')
2018-07-02 20:45:32 +00:00
# XXX este es un principio de validación de plantillas, aunque no es
# recursivo
2018-12-14 15:12:17 +00:00
return if fetch_front_matter('incomplete', false)
2019-03-26 15:32:20 +00:00
2018-07-02 20:45:32 +00:00
template_fields.each do |tf|
errors = [get_front_matter(tf.key)].flatten.compact
if tf.image? && errors.map { |i| File.exist?(File.join(site.path, i)) }.none?
add_error Hash[tf.key.to_sym, errors]
2018-07-02 20:45:32 +00:00
end
end
end
def valid?
reset_errors!
validate
@errors.empty?
end
# Permite ordenar los posts
def <=>(other)
@post <=> other.post
2018-01-30 15:20:19 +00:00
end
2018-07-02 22:07:06 +00:00
# Detecta si un valor es un archivo
def url?(name)
path = get_front_matter(name)
2018-07-20 18:17:34 +00:00
return false unless path.is_a?(String) || path.is_a?(Array)
2019-03-26 15:32:20 +00:00
2018-07-02 22:07:06 +00:00
# El primer valor es '' porque la URL empieza con /
2018-07-20 18:17:34 +00:00
[path].flatten.map do |p|
p.split('/').second == 'public'
end.all?
2018-07-02 22:07:06 +00:00
end
def image?(name)
return false unless url? name
2019-03-26 15:32:20 +00:00
# TODO: no chequear por la extensión
%(gif jpg jpeg png).include? get_front_matter(name).gsub(/.*\./, '')
2018-07-02 22:07:06 +00:00
end
# Obtiene metadatos de forma recursiva
# TODO devolver un valor por defecto en base al template?
2018-02-08 14:05:05 +00:00
def get_front_matter(name)
2019-03-26 15:32:20 +00:00
name = if name.is_a? Array
# Convertir los indices numericos a integers
name.map { |i| /[0-9]+/.match?(i) ? i.to_i : i }
else
# XXX retrocompatibilidad
name.to_s
end
2018-07-02 22:07:06 +00:00
@front_matter.dig(*name)
2018-02-08 14:05:05 +00:00
end
# Como get_front_matter pero con un valor por defecto
def fetch_front_matter(name, default)
2019-02-16 17:19:55 +00:00
r = get_front_matter(name)
# Solo cuando es nulo, sino devolvemos el default si el valor es
# false
r.nil? ? default : r
end
2018-05-14 19:27:29 +00:00
# Trae el template a partir del layout
def template_from_layout
@site.templates.find do |t|
t.get_front_matter('slug') == get_front_matter('layout')
end
end
2019-03-26 15:32:20 +00:00
# TODO: convertir a hash para que sea más fácil buscar uno
2018-05-11 20:00:45 +00:00
def template_fields
2018-05-17 19:06:08 +00:00
return [] unless template
2019-03-26 15:32:20 +00:00
@template_fields ||= template.front_matter.map do |key, contents|
next if REJECT_FROM_TEMPLATE.include? key
2018-07-23 19:48:34 +00:00
next if key.start_with? 'has_'
Post::TemplateField.new(self, key, contents)
end.compact
2018-05-11 20:00:45 +00:00
end
2018-06-21 18:15:03 +00:00
# devuelve las plantillas como strong params, primero los valores
# simples, luego los arrays y al final los hashes
def template_params
2019-03-26 15:32:20 +00:00
@template_params ||= (DEFAULT_PARAMS + template_fields.map(&:to_param)).sort_by do |s|
2018-06-21 18:15:03 +00:00
s.is_a?(Symbol) ? 0 : 1
2018-05-11 20:00:45 +00:00
end
2018-05-14 19:27:29 +00:00
end
def template
@template ||= template_from_layout
2018-05-11 20:00:45 +00:00
end
2018-01-29 22:19:10 +00:00
private
2018-06-21 17:33:45 +00:00
# Completa el front_matter a partir de las variables de otro post que
# le sirve de plantilla
def front_matter_from_template
2018-05-17 19:06:08 +00:00
# XXX: Llamamos a @template en lugar de template porque sino
# entramos en una race condition
return {} unless @template
2019-03-26 15:32:20 +00:00
ft = template_fields.map(&:to_front_matter).reduce({}, :merge)
2018-05-11 15:12:23 +00:00
# Convertimos el slug en layout
2018-06-21 17:33:45 +00:00
ft['layout'] = template.slug
2018-05-11 15:12:23 +00:00
ft
end
# Genera un post nuevo y lo agrega a la colección del sitio.
2018-01-29 22:19:10 +00:00
def new_post
2018-02-23 19:20:51 +00:00
opts = { site: @site.jekyll, collection: @site.jekyll.collections[@collection] }
2018-02-02 22:20:31 +00:00
@post = Jekyll::Document.new(path, opts)
end
# Solo agregar el post al sitio una vez que lo guardamos
#
# TODO no sería la forma correcta de hacerlo en Rails
def add_post_to_site!
2018-02-23 19:20:51 +00:00
@site.jekyll.collections[@collection].docs << @post
@site.jekyll.collections[@collection].docs.sort!
2018-01-29 22:19:10 +00:00
2018-02-23 19:20:51 +00:00
unless @site.collections[@collection].include? self
@site.collections[@collection] << self
@site.collections[@collection].sort!
end
2018-01-29 22:19:10 +00:00
end
# Los define, asegurandose que las llaves siempre son strings, para no
# tener incompatibilidades con jekyll
def set_front_matter(name, value)
@front_matter[name.to_s] = value
2018-01-29 22:19:10 +00:00
end
# Cambiar el nombre del archivo si cambió el título o la fecha.
# Como Jekyll no tiene métodos para modificar un Document, lo
# engañamos eliminando la instancia de @post y recargando otra.
def detect_file_rename!
return true unless basename_changed?
# No eliminamos el archivo a menos que ya exista el reemplazo!
return false unless File.exist? path
2018-01-29 22:19:10 +00:00
Rails.logger.info I18n.t('posts.logger.rm', path: path)
FileUtils.rm @post.path
replace_post!
2018-01-29 22:19:10 +00:00
end
# Reemplaza el post en el sitio por uno nuevo
def replace_post!
2018-02-23 19:20:51 +00:00
@old_post = @site.jekyll.collections[@lang].docs.delete @post
2018-01-29 22:19:10 +00:00
new_post
end
# Obtiene el nombre del archivo a partir de los datos que le
# pasemos
def basename_from_front_matter
2019-03-26 15:32:20 +00:00
ext = get_front_matter('ext') || '.markdown'
2018-01-29 22:19:10 +00:00
"#{date_as_string}-#{slug}#{ext}"
2018-01-29 22:19:10 +00:00
end
# Toma los datos del front matter local y los mueve a los datos
# que van a ir al post. Si hay símbolos se convierten a cadenas,
2018-02-02 22:20:31 +00:00
# porque Jekyll trabaja con cadenas. Se excluyen otros datos que no
# van en el frontmatter
def merge_with_front_matter!(params)
@front_matter.merge! Hash[params.to_hash.map do |k, v|
[k, v] unless REJECT_FROM_DATA.include? k
2018-02-02 22:20:31 +00:00
end.compact]
2018-01-29 22:19:10 +00:00
end
# Carga una copia de los datos del post original excluyendo datos
# que no nos interesan
def load_front_matter!
@front_matter = @post.data.reject do |key, _|
2018-01-29 22:19:10 +00:00
REJECT_FROM_DATA.include? key
end
end
def cleanup!
things_to_arrays!
default_date_is_today!
date_to_time!
clean_content!
slugify_title!
remove_empty_front_matter!
update_lang_front_matter!
update_translations!
put_in_order!
2018-09-05 20:25:47 +00:00
create_glossary!
end
# Setea el propio idioma en el front_matter de slugs
def update_lang_front_matter!
return unless translated?
@front_matter['lang'][@lang] = slug
end
# Busca las traducciones y actualiza el frontmatter si es necesario
def update_translations!
return unless translated?
return unless slug_changed?
find_translations.each do |post|
post.update_attributes(lang: get_front_matter('lang'))
post.save
end
end
def remove_empty_front_matter!
2019-03-26 15:32:20 +00:00
@front_matter.delete_if do |_k, v|
v.blank?
end
end
2018-01-29 22:19:10 +00:00
# Aplica limpiezas básicas del contenido
def clean_content!
@content.try(:delete!, "\r")
2018-01-29 22:19:10 +00:00
end
# Guarda los cambios en el archivo destino
def write
r = File.open(path, File::RDWR | File::CREAT, 0o640) do |f|
2018-01-29 22:19:10 +00:00
# Bloquear el archivo para que no sea accedido por otro
# proceso u otra editora
f.flock(File::LOCK_EX)
# Empezar por el principio
f.rewind
# Escribir
f.write(full_content)
# Eliminar el resto
f.flush
f.truncate(f.pos)
end
return true if r.zero?
2018-02-02 22:20:31 +00:00
add_error file: I18n.t('posts.errors.file')
2018-01-29 22:19:10 +00:00
false
end
# Genera el post con front matter, menos los campos que no necesitamos
# que estén en el front matter.
#
# El contenido se toma de `content` en lugar de `@content`, para poder
# obtener el contenido por defecto si es que no lo enviamos
# modificaciones, como en `update_translations!`
2018-01-29 22:19:10 +00:00
def full_content
2019-03-26 15:32:20 +00:00
yaml = @front_matter.reject do |k, _|
REJECT_FROM_FRONT_MATTER.include? k
end
"#{yaml.to_yaml}---\n\n#{content}"
2018-01-29 22:19:10 +00:00
end
2018-02-02 22:20:31 +00:00
def add_error(hash)
2019-03-26 15:32:20 +00:00
hash.each_pair do |k, i|
@errors[k] = if @errors.key?(k)
[@errors[k], i]
else
i
end
2018-02-02 22:20:31 +00:00
end
@errors
end
def default_date_is_today!
set_front_matter('date', Time.now) unless date
end
def date_to_time!
unless @front_matter.dig(:date).is_a? Time
@front_matter['date'] = @front_matter.dig('date').try(:to_time) || Time.now
end
end
# XXX es necesario ahora que tenemos select2?
def things_to_arrays!
2019-03-26 15:32:20 +00:00
%i[tags categories].each do |c|
thing = @front_matter.dig(c.to_s)
next if thing.blank?
next if thing.is_a? Array
2019-03-26 15:32:20 +00:00
@front_matter[c.to_s] = thing.split(',').map(&:strip)
end
end
def slugify_title!
2019-03-26 15:32:20 +00:00
@front_matter['slug'] = Jekyll::Utils.slugify(title) if slug.blank?
end
# Agregar al final de la cola si no especificamos un orden
#
# TODO si el artículo tiene una fecha que lo coloca en medio de
# la colección en lugar de al final, deberíamos reordenar?
def put_in_order!
return unless order.nil?
@front_matter['order'] = @site.posts_for(@collection).count
end
2018-09-05 20:25:47 +00:00
# Crea el artículo de glosario para cada categoría o tag
def create_glossary!
return unless site.glossary?
2019-03-26 15:32:20 +00:00
%i[tags categories].each do |i|
send(i).each do |c|
# TODO: no hardcodear, hacer _configurable
2018-09-05 20:25:47 +00:00
next if c == 'Glossary'
next if site.posts.find do |p|
p.title == c
end
glossary = Post.new(site: site, lang: lang)
2019-03-26 15:32:20 +00:00
glossary.update_attributes(
2018-09-05 20:25:47 +00:00
title: c,
layout: 'glossary',
categories: 'Glossary'
2019-03-26 15:32:20 +00:00
)
2018-09-05 20:25:47 +00:00
glossary.save!
end
end
end
2018-01-29 22:19:10 +00:00
end