From efe401d7400249f7c0c5e2e5ddcec79c71038518 Mon Sep 17 00:00:00 2001 From: f Date: Tue, 6 Aug 2019 20:17:29 -0300 Subject: [PATCH] =?UTF-8?q?WIP:=20refactorizaci=C3=B3n=20de=20Post?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El objetivo es aprovechar metaprogramación para tener un modelo similar a ActiveRecord pero con otras propiedades, como atributos dinámicos a partir de plantillas. Estamos eliminando un montón de código, con lo que no todo puede funcionar. --- app/models/metadata_template.rb | 33 +- app/models/post.rb | 541 ++++++++------------------- app/models/post_relation.rb | 25 ++ app/models/site.rb | 260 ++++++------- app/models/site/repository.rb | 5 + app/policies/collaboration_policy.rb | 4 +- app/views/sites/index.haml | 2 +- config/locales/en.yml | 13 + config/locales/es.yml | 13 + test/models/post_test.rb | 89 +++++ test/models/site_test.rb | 10 + 11 files changed, 449 insertions(+), 546 deletions(-) create mode 100644 app/models/post_relation.rb create mode 100644 test/models/post_test.rb diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 8d13daa..9802ec7 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true # Representa la plantilla de un campo en los metadatos del artículo +# +# TODO: Validar el tipo de valor pasado a value= según el :type MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, - :value, :help, :required, + :value, :help, :required, :errors, keyword_init: true) do # El valor por defecto def default_value @@ -12,11 +14,36 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, # Valores posibles, busca todos los valores actuales en otros # artículos del mismo sitio def values - site.everything_of(name.to_s) + site.everything_of(name) end # Valor actual o por defecto def value - super || document.data.dig(name.to_s, default_value) + self[:value] || document.data.fetch(name.to_s, default_value) + end + + # Detecta si el valor está vacío + def empty? + value.blank? + end + + # Comprueba si el metadato es válido + def valid? + validate + end + + def validate + self.errors = [] + + errors << I18n.t("metadata.#{type}.cant_be_empty") unless can_be_empty? + + errors.empty? + end + + private + + # Si es obligatorio no puede estar vacío + def can_be_empty? + true unless required && empty? end end diff --git a/app/models/post.rb b/app/models/post.rb index f4c732a..07ee8a5 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -3,125 +3,120 @@ require 'jekyll/utils' # Esta clase representa un post en un sitio jekyll e incluye métodos -# para modificarlos y crear nuevos +# 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, :collection +# rubocop:disable Metrics/ClassLength +class Post < OpenStruct + # Atributos por defecto + # XXX: Volver document opcional cuando estemos creando + DEFAULT_ATTRIBUTES = %i[site document layout].freeze + # Otros atributos que no vienen en los metadatos + ATTRIBUTES = %i[content lang date slug attributes errors].freeze - 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 ext tags date slug post pre].freeze - DEFAULT_PARAMS = [:title, :date, :content, :slug, :cover, - :layout, :permalink, :dir, - { lang: {} }, { tags: [] }, { categories: [] }].freeze + # 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 + # + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + def initialize(**args) + default_attributes_missing(args) + super(args) - def inspect - "#" + # Genera un método con todos los atributos disponibles + self.attributes = DEFAULT_ATTRIBUTES + + ATTRIBUTES + + layout.metadata.keys.map(&:to_sym) + + # El contenido + self.content = document.content + self.date = document.date + self.slug = document.data['slug'] + + # 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. + layout.metadata.each_pair do |name, template| + send "#{name}=".to_sym, + MetadataFactory.build(document: document, + site: site, + name: name, + type: template['type'], + label: template['label'], + help: template['help'], + required: template['required']) + end + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + + # 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 - # 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) + # 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... + # + # rubocop:disable Style/MethodMissingSuper + def method_missing(mid, *args) + unless attribute? mid + raise NoMethodError, I18n.t('exceptions.post.no_method', + method: mid) end - unless post.nil? || post.is_a?(Jekyll::Document) - raise ArgumentError, - I18n.t('errors.argument_error', argument: :post, - class: Jekyll::Document) - end + super(mid, *args) + end + # rubocop:enable Style/MethodMissingSuper - @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 - @collection = if @site.i18n? - @lang = lang || I18n.locale.to_s - else - 'posts' - end - - # sincronizar los datos del document - if new? - @front_matter = front_matter_from_template - update_attributes front_matter + # Detecta si es un atributo válido o no, a partir de la tabla de la + # plantilla + def attribute?(mid) + if singleton_class.method_defined? :attributes + attributes.include? attribute_name(mid) else - load_front_matter! - merge_with_front_matter! front_matter.stringify_keys + (DEFAULT_ATTRIBUTES + ATTRIBUTES).include? attribute_name(mid) end end - # Limpiar los errores - def reset_errors! - @errors = {} + # Genera el post con metadatos en YAML + def full_content + yaml = layout.metadata.keys.map(&:to_sym).map do |metadata| + template = send(metadata) + + { metadata.to_s => template.value } unless template.empty? + end.compact.inject(:merge) + + "#{yaml.to_yaml}---\n\n#{content}" 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 + # Eliminar el artículo del repositorio y de la lista de artículos del + # sitio + # + # XXX Commit + def destroy + FileUtils.rm_f path + + site.posts(lang: lang).delete_if do |post| + post.path == path + end + + !File.exist?(path) && !site.posts(lang: lang).include?(self) end + alias destroy! destroy # Guarda los cambios. # @@ -133,60 +128,49 @@ class Post 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! + document.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 + document.path 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' + # Detecta si el artículo es válido para guardar + def valid? + validate + errors.blank? end - alias slug id - alias to_s id + + # Requisitos para que el post sea válido + def validate + self.errors = {} + + layout.metadata.keys.map(&:to_sym).each do |metadata| + template = send(metadata) + + errors[metadata] = template.errors unless template.valid? + end + end + alias validate! validate + + # Permite ordenar los posts + def <=>(other) + @post <=> other.post + end + + # **************** + # A PARTIR DE ACA ESTAMOS CONSIDERANDO CUALES METODOS QUEDAN Y CUALES + # NO + # **************** def basename_changed? @post.try(:basename) != basename_from_front_matter @@ -196,108 +180,6 @@ class Post 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}", 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({}, :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) @@ -309,87 +191,14 @@ class Post 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) - 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 - @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_' - - # XXX: Esto está acá hasta que convirtamos todo en plantillas - if key == 'title' && content.is_a?(String) - contents = { - 'value' => 'text', - 'label' => I18n.t('posts.title'), - 'required' => true - } - end - - 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(&:to_param)).sort_by do |s| + @template_params ||= template_fields.map(&:to_param).sort_by do |s| s.is_a?(Symbol) ? 0 : 1 end end - def template - @template ||= template_from_layout - end - - # Eliminar el artículo del repositorio y de la lista de artículos del - # sitio - def destroy - FileUtils.rm_f path - - site.posts_for(collection).delete_if do |post| - post.path == path - end - - true - end - private # Completa el front_matter a partir de las variables de otro post que @@ -406,30 +215,18 @@ class Post 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! +# 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 +# unless @site.collections[@collection].include? self +# @site.collections[@collection] << self +# @site.collections[@collection].sort! +# end +# 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 @@ -441,7 +238,7 @@ class Post Rails.logger.info I18n.t('posts.logger.rm', path: path) FileUtils.rm @post.path - replace_post! +# replace_post! end # Reemplaza el post en el sitio por uno nuevo @@ -478,25 +275,14 @@ class Post 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? @@ -508,15 +294,9 @@ class Post 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") + content.try(:delete!, "\r") end # Guarda los cambios en el archivo destino @@ -543,20 +323,6 @@ class Post 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| @errors[k] = if @errors.key?(k) @@ -570,28 +336,11 @@ class Post 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! - %i[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 + date ||= Time.now end def slugify_title! - @front_matter['slug'] = Jekyll::Utils.slugify(title) if slug.blank? + self.slug = Jekyll::Utils.slugify(title) if slug.blank? end # Agregar al final de la cola si no especificamos un orden @@ -627,4 +376,12 @@ class Post end end end + + private + + # Obtiene el nombre del atributo sin + def attribute_name(attr) + attr.to_s.split('=').first.to_sym + end end +# rubocop:enable Metrics/ClassLength diff --git a/app/models/post_relation.rb b/app/models/post_relation.rb new file mode 100644 index 0000000..67cc923 --- /dev/null +++ b/app/models/post_relation.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# La relación de un sitio con sus artículos, esto nos permite generar +# artículos como si estuviésemos usando ActiveRecord. +class PostRelation < Array + # No necesitamos cambiar el sitio + attr_reader :site + + def initialize(site:) + @site = site + # Proseguimos la inicialización sin valores por defecto + super() + end + + # Genera un artículo nuevo con los parámetros que le pasemos y lo suma + # al array + def build(**args) + self << Post.new(site: site, **args) + end + + # Intenta guardar todos y devuelve true si pudo + def save_all + map(&:save).all? + end +end diff --git a/app/models/site.rb b/app/models/site.rb index d566cd3..776a4b9 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -31,17 +31,18 @@ class Site < ApplicationRecord before_destroy :remove_directories! # Carga el sitio Jekyll una vez que se inicializa el modelo o después # de crearlo - after_initialize :load_jekyll! - after_create :load_jekyll! + after_initialize :load_jekyll + after_create :load_jekyll # Cambiar el nombre del directorio before_update :update_name! # Guardar la configuración si hubo cambios after_save :sync_attributes_with_config! - attr_accessor :jekyll, :collections - accepts_nested_attributes_for :deploys, allow_destroy: true + # El sitio en Jekyll + attr_accessor :jekyll + # No permitir HTML en estos atributos def title=(title) super(title.strip_tags) @@ -56,11 +57,6 @@ class Site < ApplicationRecord @repository ||= Site::Repository.new path end - # Trae los cambios del skel y verifica que haya cambios - def needs_pull? - !repository.commits.empty? - end - # TODO: Mover esta consulta a la base de datos para no traer un montón # de cosas a la memoria def invitade?(usuarie) @@ -71,179 +67,138 @@ class Site < ApplicationRecord usuaries.pluck(:id).include? usuarie.id end + # Este sitio acepta invitades? + def invitades? + config.fetch('invitades', false) + end + # Traer la ruta del sitio - # - # Equivale a _sites + nombre def path File.join(Site.site_path, name) end - def old_path + # La ruta anterior + def path_was File.join(Site.site_path, name_was) end - # Este sitio acepta invitadxs? - def invitadxs? - config.fetch('invitadxs', false) - end - def cover "/covers/#{name}.png" end - # Determina si el sitio está en varios idiomas - def i18n? - !translations.empty? - end - # Define si el sitio tiene un glosario def glossary? config.fetch('glossary', false) end # Obtiene la lista de traducciones actuales - def translations - config.fetch('i18n', []) - end - - # Devuelve el idioma por defecto del sitio # - # TODO: volver elegante - def default_lang - # Si está traducido, intentamos saber si podemos trabajar en el - # idioma actual de la plataforma. - if i18n? - i18n = I18n.locale.to_s - if translations.include? i18n - # Podemos trabajar en el idioma actual - i18n - else - # Sino, trabajamos con el primer idioma - translations.first - end - else - # Si el sitio no está traducido, estamos trabajando con posts en - # cualquier idioma - # - # XXX: no será un dirty hack? - 'posts' - end + # Siempre tiene que tener algo porque las traducciones están + # incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan + # sus sitios. + def translations + config.fetch('translations', I18n.available_locales.map(&:to_s)) end - def layouts - @layouts ||= @jekyll.layouts.keys.sort - end - - def name_with_i18n(lang) - [name, lang].join('/') + # Devuelve el idioma por defecto del sitio, el primero de la lista. + def default_language + translations.first end + alias default_lang default_language + # Lee el sitio y todos los artículos def read @jekyll.read end - # Fuerza relectura del sitio, eliminando el sitio actual en favor de - # un sitio nuevo, leído desde cero - def read! - @jekyll = Site.load_jekyll(@jekyll.path) - - @jekyll.read - end - + # Trae los datos del directorio _data dentro del sitio + # + # XXX: Leer directamente sin pasar por Jekyll def data - if @jekyll.data.empty? - read - Rails.logger.info 'Leyendo data' - end + read if @jekyll.data.empty? @jekyll.data end + # Traer las colecciones. Todos los artículos van a estar dentro de + # colecciones. + def collections + read if @jekyll.collections.empty? + + @jekyll.collections + end + + # Traer la configuración de forma modificable def config @config ||= Site::Config.new(self) end - # TODO: Cambiar a Site::Config apenas empecemos a testear esto - def collections_names - @jekyll.config['collections'].keys - end + # Los posts en el idioma actual o en uno en particular + # + # @param lang: [String|Symbol] traer los artículos de este idioma + def posts(lang: nil) + @posts ||= {} + lang ||= I18n.locale - # Los posts de este sitio, si el sitio está traducido, trae los posts - # del idioma actual, porque _posts va a estar vacío - def posts - @posts ||= posts_for('posts') + return @posts[lang] if @posts[lang].present? + + @posts[lang] = PostRelation.new site: self + + # No fallar si no existe colección para este idioma + # XXX: queremos fallar silenciosamente? + (collections[lang.to_s].try(:docs) || []).each do |doc| + layout = layouts[doc.data['layout'].to_sym] + + @posts[lang].build(document: doc, layout: layout, lang: lang) + end + + @posts[lang] end # Obtiene todas las plantillas de artículos - def templates - @templates ||= posts_for('templates') || [] - end - - # Obtiene todos los posts de una colección determinada # - # Devuelve nil si la colección no existe - def posts_for(collection) - return unless collections_names.include? collection - - # Si pedimos 'posts' pero estamos en un sitio traducido, traemos el - # idioma actual - collection = default_lang if collection == 'posts' && i18n? - - @collections ||= {} - c = @collections[collection] - return c if c - - Rails.logger.info "Procesando #{collection}" - - col = @jekyll.collections[collection].docs - if col.empty? - @jekyll.read - # Queremos saber cuantas veces releemos los articulos - Rails.logger.info 'Leyendo articulos' - end - - # Los convertimos a una clase intermedia capaz de acceder a sus - # datos y modificarlos - @collections[collection] = col.map do |post| - Post.new(site: self, post: post, lang: collection) - end - end - - def categories(lang: nil) - everything_of :categories, lang: lang - end - - def tags(lang: nil) - everything_of :tags, lang: lang + # @return { post: Layout } + def layouts + @layouts ||= data.fetch('layouts', {}).map do |name, metadata| + { name.to_sym => Layout.new(site: self, + name: name.to_sym, + metadata: metadata) } + end.inject(:merge) end + # Trae todos los valores disponibles para un campo + # + # TODO: Traer recursivamente, si el campo contiene Hash + # + # @return Array def everything_of(attr, lang: nil) - collection = lang || 'posts' - - return [] unless collections_names.include? collection - - posts_for(collection).map do |p| - p.get_front_matter attr + posts(lang: lang).map do |p| + # XXX: Tener cuidado con los métodos que no existan + p.send(attr).try :value end.flatten.uniq.compact end + # Poner en la cola de compilación def enqueue! !enqueued? && update_attribute(:status, 'enqueued') end + # Está en la cola de compilación? def enqueued? status == 'enqueued' end # Verifica si los posts están ordenados - def ordered?(collection = 'posts') - posts_for(collection).map(&:order).all? + def ordered?(lang: nil) + posts(lang: lang).map(&:order).all? end # Reordena la colección usando la posición informada # # new_order es un hash cuya key es la posición actual del post y el # valor la posición nueva + # + # TODO: Refactorizar y testear def reorder_collection(collection, new_order) # Tenemos que pasar el mismo orden return if new_order.values.map(&:to_i).sort != new_order.keys.map(&:to_i).sort @@ -275,50 +230,65 @@ class Site < ApplicationRecord alias reorder_posts! reorder_collection! # Obtener una ruta disponible para Sutty + # + # TODO: Refactorizar y testear def get_url_for_sutty(path) # Remover los puntos para que no nos envíen a ../../ File.join('/', 'sites', id, path.gsub('..', '')) end - # El directorio donde se almacenan los sitios - def self.site_path - File.join(Rails.root, '_sites') - end - + # Cargar el sitio Jekyll + # # TODO: En lugar de leer todo junto de una vez, extraer la carga de # documentos de Jekyll hacia Sutty para que podamos leer los datos que # necesitamos. - def self.load_jekyll(path) + def load_jekyll + return unless name.present? && File.directory?(path) + + Dir.chdir(path) do + @jekyll = Jekyll::Site.new(jekyll_config) + end + end + + def jekyll_config # Pasamos destination porque configuration() toma el directorio - # actual y se mezclan :/ + # actual # # Especificamos `safe` para no cargar los _plugins, que interfieren - # entre sitios incluso - config = ::Jekyll.configuration('source' => path, - 'destination' => File.join(path, '_site'), - 'safe' => true, - 'watch' => false, - 'quiet' => true) + # entre sitios incluso. + # + # excerpt_separator está vacío para no incorporar el Excerpt en los + # metadatos de Document + configuration = + ::Jekyll.configuration('source' => path, + 'destination' => File.join(path, '_site'), + 'safe' => true, 'watch' => false, + 'quiet' => true, 'excerpt_separator' => '') # No necesitamos cargar plugins en este momento %w[plugins gems theme].each do |unneeded| - config[unneeded] = [] if config.key? unneeded + configuration[unneeded] = [] if configuration.key? unneeded end # Si estamos usando nuestro propio plugin de i18n, los posts están # en "colecciones" - i18n = config.dig('i18n') - i18n&.each do |i| - config['collections'][i] = {} + translations.each do |i| + configuration['collections'][i] = {} end - Jekyll::Site.new(config) + configuration end + # Devuelve el dominio actual def self.domain ENV.fetch('SUTTY', 'sutty.nl') end + # El directorio donde se almacenan los sitios + def self.site_path + File.join(Rails.root, '_sites') + end + private # Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada @@ -329,15 +299,6 @@ class Site < ApplicationRecord Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path end - # Carga el sitio Jekyll - def load_jekyll! - return unless name.present? && File.directory?(path) - - Dir.chdir(path) do - @jekyll ||= Site.load_jekyll(Dir.pwd) - end - end - # Elimina el directorio del sitio def remove_directories! FileUtils.rm_rf path @@ -346,11 +307,14 @@ class Site < ApplicationRecord def update_name! return unless name_changed? - FileUtils.mv old_path, path + FileUtils.mv path_was, path end # Sincroniza algunos atributos del sitio con su configuración y # guarda los cambios + # + # TODO: Guardar la configuración también, quizás aprovechando algún + # método de ActiveRecord para que lance un salvado recursivo. def sync_attributes_with_config! config.theme = design.gem unless design_id_changed? config.description = description unless description_changed? diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb index f59ad21..729dad5 100644 --- a/app/models/site/repository.rb +++ b/app/models/site/repository.rb @@ -79,6 +79,11 @@ class Site walker.each.to_a end + # Hay commits sin aplicar? + def needs_pull? + commits.empty? + end + # Guarda los cambios en git, de a un archivo por vez # rubocop:disable Metrics/AbcSize def commit(file:, usuarie:, message:) diff --git a/app/policies/collaboration_policy.rb b/app/policies/collaboration_policy.rb index 2a43247..a386f53 100644 --- a/app/policies/collaboration_policy.rb +++ b/app/policies/collaboration_policy.rb @@ -3,10 +3,10 @@ # Política de aceptación de colaboradorxs CollaborationPolicy = Struct.new(:usuarie, :collaboration) do def collaborate? - collaboration.site.invitadxs? + collaboration.site.invitades? end def accept_collaboration? - collaboration.site.invitadxs? + collaboration.site.invitades? end end diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index 464a616..3bbd28c 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -77,7 +77,7 @@ = fa_icon 'building' = t('sites.enqueue') - - if policy(site).pull? && site.needs_pull? + - if policy(site).pull? && site.repository.needs_pull? = render 'layouts/btn_with_tooltip', tooltip: t('help.sites.pull'), text: t('.pull'), diff --git a/config/locales/en.yml b/config/locales/en.yml index 10c1126..193c056 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,17 @@ en: + metadata: + array: + cant_be_empty: 'This field cannot be empty' + string: + cant_be_empty: 'This field cannot be empty' + image: + cant_be_empty: 'This field cannot be empty' + exceptions: + post: + site_missing: 'Needs an instance of Site' + layout_missing: 'Needs an instance of Layout' + document_missing: 'Needs an instance of Jekyll::Document' + no_method: '%{method} not allowed' es: Castillian Spanish en: English seconds: '%{seconds} seconds' diff --git a/config/locales/es.yml b/config/locales/es.yml index 9f6d46c..e0de4d2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,17 @@ es: + metadata: + array: + cant_be_empty: 'El campo no puede estar vacío' + string: + cant_be_empty: 'El campo no puede estar vacío' + image: + cant_be_empty: 'El campo no puede estar vacío' + exceptions: + post: + site_missing: 'Necesita una instancia de Site' + layout_missing: 'Necesita una instancia de Layout' + document_missing: 'Necesita una instancia de Jekyll::Document' + no_method: '%{method} no está permitido' es: Castellano en: Inglés seconds: '%{seconds} segundos' diff --git a/test/models/post_test.rb b/test/models/post_test.rb new file mode 100644 index 0000000..320e493 --- /dev/null +++ b/test/models/post_test.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class PostTest < ActiveSupport::TestCase + setup do + # Trabajamos con el sitio de sutty porque tiene artículos + # + # TODO: Cambiar a skel cuando publiquemos los códigos y privacidad + @site = create :site, name: 'sutty.nl' + @site.read + @post = @site.posts.sample + end + + teardown do + # @site.destroy + end + + test 'se puede acceder a los valores' do + assert @site.posts.size.positive? + + assert @post.categories.values.size.positive? + assert @post.tags.values.size.positive? + assert @post.title.size.positive? + assert @post.content.size.positive? + end + + test 'no se puede setear cualquier atributo' do + assert_raise NoMethodError do + @post.verdura = 'verdura' + end + end + + test 'se pueden eliminar' do + # TODO: cuando esté con git, solo aplicar git reset + tmp = File.join(Rails.root, 'tmp', 'eliminar.md') + FileUtils.cp @post.path, tmp + + assert @post.destroy + assert_not File.exist?(@post.path) + assert_not @site.posts.include?(@post) + + FileUtils.mv tmp, @post.path + end + + test 'se puede ver el contenido completo' do + tmp = Tempfile.new + + begin + tmp.write(@post.full_content) + tmp.close + + collection = Jekyll::Collection.new(@site.jekyll, I18n.locale.to_s) + document = Jekyll::Document.new(tmp.path, site: @site.jekyll, + collection: collection) + document.read + document.data['categories'].try(:delete_if) do |x| + x == 'tmp' + end + + # Queremos saber si todos los atributos del post terminaron en el + # archivo + @post.attributes.each do |attr| + template = @post.send(attr) + # ignorar atributos que no son datos + next unless template.is_a? MetadataTemplate + + if template.empty? + assert_not document.data[attr.to_s].present? + else + assert_equal template.value, document.data[attr.to_s] + end + end + ensure + tmp.unlink + end + end + + test 'se pueden validar' do + assert @post.valid? + + # XXX: si usamos nil va a traer el valor original del documento, no + # queremos tener esa información sincronizada... + @post.title.value = '' + + assert_not @post.valid? + end + + test 'se pueden guardar los cambios' do + end +end diff --git a/test/models/site_test.rb b/test/models/site_test.rb index 438ef82..5d8a511 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -61,6 +61,7 @@ class SiteTest < ActiveSupport::TestCase test 'se puede leer un sitio' do site = create :site, name: 'sutty.nl' + site.read assert site.valid? assert !site.posts.empty? @@ -85,4 +86,13 @@ class SiteTest < ActiveSupport::TestCase assert_equal 'hola', site.description assert_equal 'hola', site.title end + + test 'el sitio tiene artículos en distintos idiomas' do + site = create :site, name: 'sutty.nl' + site.read + + I18n.available_locales.each do |locale| + assert site.posts(lang: locale).size.positive? + end + end end