# frozen_string_literal: true 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. class Post attr_accessor :content, :front_matter attr_reader :post, :site, :errors, :old_post, :lang, :template, :template_fields 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 # datos que no traemos del template REJECT_FROM_TEMPLATE = %w[draft categories layout title ext tags date slug post pre].freeze DEFAULT_PARAMS = [:title, :date, :content, :slug, :cover, :layout, :permalink, :dir, { lang: {} }, { tags: [] }, { categories: [] }] def inspect "#" end # 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, I18n.t('errors.argument_error', argument: :site, class: Site) end unless post.nil? || post.is_a?(Jekyll::Document) raise ArgumentError, I18n.t('errors.argument_error', argument: :post, class: Jekyll::Document) end @site = site @post = post @template = template # los errores tienen que ser un hash para que # ActiveModel pueda traer los errores normalmente @errors = {} # Si el sitio está traducido, trabajamos con la colección del # idioma, sino, con posts if @site.i18n? @collection = @lang = lang || I18n.locale.to_s else @collection = 'posts' end # 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 = {} end # El post es nuevo si no hay un documento asociado def new? @post.nil? || !File.exist?(@post.try(:path)) end def draft? fetch_front_matter('draft', false) end def incomplete? fetch_front_matter('incomplete', false) end # 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 def ordered? !order.nil? end # Determina si fue traducido, buscando los slugs de su front_matter # lang en otras colecciones def translated? @site.i18n? && get_front_matter('lang').present? end def translations @translations ||= find_translations end def find_translations slugs = get_front_matter('lang') return [] unless slugs.present? slugs.map do |lang, id| next if lang == @lang @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 def save cleanup! return false unless valid? new_post if new? return unless write return unless detect_file_rename! # Vuelve a leer el post para tomar los cambios @post.read add_post_to_site! true end alias :save! :save def title get_front_matter 'title' end def author get_front_matter 'author' end def date get_front_matter 'date' end def date_as_string date.strftime('%F') end def tags get_front_matter('tags') || [] end def categories get_front_matter('categories') || [] end alias :category :categories # Devuelve la ruta del post, si se cambió alguno de los datos, # generamos una ruta nueva para tener siempre la ruta actualizada. def path if basename_changed? File.join(@site.path, "_#{@collection}", basename_from_front_matter) else @post.try(:path) end end # TODO los slugs se pueden repetir, el identificador real sería # fecha+slug, pero se ve feo en las urls? def id get_front_matter 'slug' end alias :slug :id alias :to_s :id def basename_changed? @post.try(:basename) != basename_from_front_matter 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 def content @content ||= @post.try(:content) || template.try(:content) end # Determina si el post lleva contenido o es solo front_matter def content? has_field? :content end def has_field?(field) if template template.fetch_front_matter("has_#{field.to_s}", true) else true end end # imita Model.update_attributes de ActiveRecord # 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. def update_attributes(attrs) # convertir los hashes en arrays si los campos son anidados # usamos to_hash por todos lados porque sino son # HashWithIndifferentAccess _attrs = attrs.to_hash.map do |k,v| t = template_fields.find { |t| t.key == k } if t # Subir la imagen! # TODO pasar a su propio método if t.image? begin i = Post::ImageUploader.new(site) 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 v = e.message end end if t.nested? v = t.array? ? v.map(&:to_hash) : v.to_hash end end if v.is_a? ActionController::Parameters { k => v.to_hash } else { k => v } end end.reduce(Hash.new, :merge).stringify_keys # 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 # 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? add_error validate: I18n.t('posts.errors.slug_with_path') if slug.try(:include?, '/') # XXX este es un principio de validación de plantillas, aunque no es # recursivo return if fetch_front_matter('incomplete', false) 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] end end end def valid? reset_errors! validate @errors.empty? end # Permite ordenar los posts def <=>(other) @post <=> other.post end # Detecta si un valor es un archivo def url?(name) path = get_front_matter(name) return false unless path.is_a?(String) || path.is_a?(Array) # El primer valor es '' porque la URL empieza con / [path].flatten.map do |p| p.split('/').second == 'public' end.all? end def image?(name) return false unless url? name # TODO no chequear por la extensión %[gif jpg jpeg png].include? get_front_matter(name).gsub(/.*\./, '') end # Obtiene metadatos de forma recursiva # TODO devolver un valor por defecto en base al template? def get_front_matter(name) if name.is_a? Array # Convertir los indices numericos a integers name = name.map { |i| i =~ /[0-9]+/ ? i.to_i : i } else # XXX retrocompatibilidad name = name.to_s end @front_matter.dig(*name) end # Como get_front_matter pero con un valor por defecto def fetch_front_matter(name, default) r = get_front_matter(name) # Solo cuando es nulo, sino devolvemos el default si el valor es # false r.nil? ? default : r end # 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 # TODO convertir a hash para que sea más fácil buscar uno def template_fields return [] unless template @template_fields ||= template.front_matter.map do |key, contents| next if REJECT_FROM_TEMPLATE.include? key next if key.start_with? 'has_' Post::TemplateField.new(self, key, contents) end.compact end # devuelve las plantillas como strong params, primero los valores # simples, luego los arrays y al final los hashes def template_params @template_params ||= (DEFAULT_PARAMS + template_fields.map do |k| k.to_param end).sort_by do |s| s.is_a?(Symbol) ? 0 : 1 end end def template @template ||= template_from_layout end private # Completa el front_matter a partir de las variables de otro post que # le sirve de plantilla def front_matter_from_template # XXX: Llamamos a @template en lugar de template porque sino # entramos en una race condition return {} unless @template ft = template_fields.map(&:to_front_matter).reduce(Hash.new, :merge) # Convertimos el slug en layout ft['layout'] = template.slug ft end # Genera un post nuevo y lo agrega a la colección del sitio. def new_post opts = { site: @site.jekyll, collection: @site.jekyll.collections[@collection] } @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! @site.jekyll.collections[@collection].docs << @post @site.jekyll.collections[@collection].docs.sort! unless @site.collections[@collection].include? self @site.collections[@collection] << self @site.collections[@collection].sort! end 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 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 Rails.logger.info I18n.t('posts.logger.rm', path: path) FileUtils.rm @post.path replace_post! end # Reemplaza el post en el sitio por uno nuevo def replace_post! @old_post = @site.jekyll.collections[@lang].docs.delete @post new_post end # Obtiene el nombre del archivo a partir de los datos que le # pasemos def basename_from_front_matter ext = get_front_matter('ext') || '.markdown' "#{date_as_string}-#{slug}#{ext}" 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, # 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 end.compact] 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, _| 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! 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! @front_matter.delete_if do |k,v| v.blank? end end # Aplica limpiezas básicas del contenido def clean_content! @content.try(:delete!, "\r") end # Guarda los cambios en el archivo destino def write r = File.open(path, File::RDWR | File::CREAT, 0o640) do |f| # 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? add_error file: I18n.t('posts.errors.file') 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!` def full_content yaml = @front_matter.reject do |k,_| REJECT_FROM_FRONT_MATTER.include? k end "#{yaml.to_yaml}---\n\n#{content}" end def add_error(hash) hash.each_pair do |k,i| if @errors.key?(k) @errors[k] = [@errors[k], i] else @errors[k] = i end 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! [:tags,:categories].each do |c| thing = @front_matter.dig(c.to_s) next if thing.blank? next if thing.is_a? Array @front_matter[c.to_s] = thing.split(',').map(&:strip) end end def slugify_title! if slug.blank? @front_matter['slug'] = Jekyll::Utils.slugify(title) end 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 # Crea el artículo de glosario para cada categoría o tag def create_glossary! return unless site.glossary? [:tags,:categories].each do |i| self.send(i).each do |c| # TODO no hardcodear, hacer _configurable next if c == 'Glossary' next if site.posts.find do |p| p.title == c end glossary = Post.new(site: site, lang: lang) glossary.update_attributes({ title: c, layout: 'glossary', categories: 'Glossary' }) glossary.save! end end end end