diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 117be995..a96d1ec0 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -86,7 +86,9 @@ class ApplicationController < ActionController::Base end def site - @site ||= find_site + @site ||= find_site.tap do |s| + s.reindex_changes! + end end protected diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 057c3068..9d8c42a4 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -55,9 +55,11 @@ class PostsController < ApplicationController def new authorize Post - @post = site.posts(lang: locale).build(layout: params[:layout]) - breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), '' + layout = site.layouts[params[:layout].to_sym] + @post = Post.build(locale: locale, layout: layout, site: site) + + breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: layout.humanized_name.downcase), '' end def create @@ -161,7 +163,7 @@ class PostsController < ApplicationController end def post - @post ||= site.posts(lang: locale).find(params[:post_id] || params[:id]) + @post ||= site.indexed_posts.find_by!(locale: locale, path: params[:post_id] || params[:id]).post end # Recuerda el nombre del servicio de subida de archivos diff --git a/app/models/concerns/metadata/inverse_concern.rb b/app/models/concerns/metadata/inverse_concern.rb new file mode 100644 index 00000000..aa300fa7 --- /dev/null +++ b/app/models/concerns/metadata/inverse_concern.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Metadata + module InverseConcern + extend ActiveSupport::Concern + + included do + # Hay una relación inversa? + # + # @return [Boolean] + def inverse? + inverse.present? + end + + # La relación inversa + # + # @return [Nil,Symbol] + def inverse + @inverse ||= layout.metadata.dig(name, 'inverse')&.to_sym + end + end + end +end diff --git a/app/models/deploy_reindex.rb b/app/models/deploy_reindex.rb deleted file mode 100644 index f3eb3d23..00000000 --- a/app/models/deploy_reindex.rb +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# Reindexa los artículos al terminar la compilación -class DeployReindex < Deploy - def deploy(**) - time_start - - site.reset - - Site.transaction do - site.indexed_posts.destroy_all - site.index_posts! - end - - time_stop - - build_stats.create action: 'reindex', - log: 'Reindex', - seconds: time_spent_in_seconds, - bytes: size, - status: true - site.touch - end - - def size - 0 - end - - def limit - 1 - end - - def hostname; end - - def url; end - - def destination; end -end diff --git a/app/models/indexed_post.rb b/app/models/indexed_post.rb index 184cd05f..992c07c8 100644 --- a/app/models/indexed_post.rb +++ b/app/models/indexed_post.rb @@ -34,15 +34,66 @@ class IndexedPost < ApplicationRecord scope :in_category, ->(category) { where("front_matter->'categories' ? :category", category: category.to_s) } scope :by_usuarie, ->(usuarie) { where("front_matter->'usuaries' @> :usuarie::jsonb", usuarie: usuarie.to_s) } + # Trae todos los valores únicos para un atributo + # + # @param :attribute [String,Symbol] + # @return [Array] + scope :everything_of, ->(attribute) do + where('front_matter ? :attribute', attribute: attribute) + .pluck( + Arel.sql( + ActiveRecord::Base::sanitize_sql(['front_matter -> :attribute', attribute: attribute]) + ) + ) + .flatten.uniq + end + + validates_presence_of :layout, :path, :locale + belongs_to :site - # Encuentra el post original + # La ubicación del Post en el disco # - # @return [nil,Post] - def post - return if post_id.blank? + # @return [String] + def full_path + @full_path ||= File.join(site.path, "_#{locale}", "#{path}.markdown") + end - @post ||= site.posts(lang: locale).find(post_id, uuid: true) + # La colección + # + # @return [Jekyll::Collection] + def collection + site.collections[locale.to_s] + end + + # Obtiene el documento + # + # @return [Jekyll::Document] + def document + @document ||= Jekyll::Document.new(full_path, site: site.jekyll, collection: collection) + end + + # El Post + # + # @todo Decidir qué pasa si el archivo ya no existe + # @return [Post] + def post + @post ||= Post.new(document: document, site: site, layout: schema) + end + + # Devuelve el esquema de datos + # + # @todo Renombrar + # @return [Layout] + def schema + site.layouts[layout.to_sym] + end + + # Existe físicamente? + # + # @return [Boolean] + def exist? + File.exist?(full_path) end # Convertir locale a direccionario de PG diff --git a/app/models/metadata_belongs_to.rb b/app/models/metadata_belongs_to.rb index be1fa670..93a1f710 100644 --- a/app/models/metadata_belongs_to.rb +++ b/app/models/metadata_belongs_to.rb @@ -3,6 +3,8 @@ # Almacena el UUID de otro Post y actualiza el valor en el Post # relacionado. class MetadataBelongsTo < MetadataRelatedPosts + include Metadata::InverseConcern + # TODO: Convertir algunos tipos de valores en módulos para poder # implementar varios tipos de campo sin repetir código # @@ -20,82 +22,12 @@ class MetadataBelongsTo < MetadataRelatedPosts document.data[name.to_s] end - def validate - super - - errors << I18n.t('metadata.belongs_to.missing_post') unless post_exists? - - errors.empty? - end - - # Guardar y guardar la relación inversa también, eliminando la - # relación anterior si existía. - def save - super - - # Si no hay relación inversa, no hacer nada más - return true unless changed? - return true unless inverse? - - # Si estamos cambiando la relación, tenemos que eliminar la relación - # anterior - if belonged_to.present? - belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej| - rej == post.uuid.value - end - end - - # No duplicar las relaciones - belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included? - - true - end - - # El Post actual está incluido en la relación inversa? - def included? - belongs_to[inverse].value.include?(post.uuid.value) - end - - # Hay una relación inversa y el artículo existe? - def inverse? - inverse.present? - end - - # El campo que es la relación inversa de este - def inverse - @inverse ||= layout.metadata.dig(name, 'inverse')&.to_sym - end - - # El Post relacionado con este artículo - def belongs_to - posts.find(value, uuid: true) if value.present? - end - - # El artículo relacionado anterior - def belonged_to - posts.find(value_was, uuid: true) if value_was.present? - end - - def related_posts? - true - end - - def related_methods - @related_methods ||= %i[belongs_to belonged_to].freeze - end - def indexable_values - belongs_to&.title&.value + posts.find_by_post_uuid(value).try(:title) end private - def post_exists? - return true if sanitize(value).blank? - - sanitize(value).present? && belongs_to.present? - end - def sanitize(uuid) uuid.to_s.gsub(/[^a-f0-9\-]/i, '') end diff --git a/app/models/metadata_has_and_belongs_to_many.rb b/app/models/metadata_has_and_belongs_to_many.rb index 2c4f3d43..2c1b0f96 100644 --- a/app/models/metadata_has_and_belongs_to_many.rb +++ b/app/models/metadata_has_and_belongs_to_many.rb @@ -1,46 +1,5 @@ # frozen_string_literal: true -# Establece una relación de muchos a muchos artículos. Cada campo es un -# Array de UUID que se mantienen sincronizados. -# -# Por ejemplo: -# -# Un libro puede tener muches autores y une autore muchos libros. La -# relación has_many tiene que traer todes les autores relacionades con -# el libro actual. La relación belongs_to tiene que traer todes les -# autores que tienen este libro. La relación es bidireccional, no hay -# diferencia entre has_many y belongs_to. +# Establece una relación de muchos a muchos artículos class MetadataHasAndBelongsToMany < MetadataHasMany - # Mantiene la relación inversa si existe. - # - # La relación belongs_to se mantiene actualizada en la modificación - # actual. Lo que buscamos es mantener sincronizada esa relación. - # - # Buscamos en belongs_to la relación local, si se eliminó hay que - # quitarla de la relación remota, sino hay que agregarla. - # - def save - # XXX: No usamos super - self[:value] = sanitize value - - return true unless changed? - return true unless inverse? - - # XXX: Usamos asignación para aprovechar value= que setea el valor - # anterior en @value_was - (had_many - has_many).each do |remove| - remove[inverse].value = remove[inverse].value.reject do |rej| - rej == post.uuid.value - end - end - - (has_many - had_many).each do |add| - next unless add[inverse] - next if add[inverse].value.include? post.uuid.value - - add[inverse].value = (add[inverse].value.dup << post.uuid.value) - end - - true - end end diff --git a/app/models/metadata_has_many.rb b/app/models/metadata_has_many.rb index 13f0dcf5..c5f01f7c 100644 --- a/app/models/metadata_has_many.rb +++ b/app/models/metadata_has_many.rb @@ -6,55 +6,5 @@ # Localmente tenemos un Array de UUIDs. Remotamente tenemos una String # apuntando a un Post, que se mantiene actualizado como el actual. class MetadataHasMany < MetadataRelatedPosts - # Todos los Post relacionados - def has_many - return default_value if value.blank? - - posts.where(uuid: value) - end - - # La relación anterior - def had_many - return default_value if value_was.blank? - - posts.where(uuid: value_was) - end - - def inverse? - inverse.present? - end - - # La relación inversa - # - # @return [Nil,Symbol] - def inverse - @inverse ||= layout.metadata.dig(name, 'inverse')&.to_sym - end - - # Actualizar las relaciones inversas. Hay que buscar la diferencia - # entre had y has_many. - def save - super - - return true unless changed? - return true unless inverse? - - (had_many - has_many).each do |remove| - remove[inverse]&.value = remove[inverse].default_value - end - - (has_many - had_many).each do |add| - add[inverse]&.value = post.uuid.value - end - - true - end - - def related_posts? - true - end - - def related_methods - @related_methods ||= %i[has_many had_many].freeze - end + include Metadata::InverseConcern end diff --git a/app/models/metadata_has_one.rb b/app/models/metadata_has_one.rb new file mode 100644 index 00000000..0cadb1d9 --- /dev/null +++ b/app/models/metadata_has_one.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +class MetadataHasOne < MetadataBelongsTo; end diff --git a/app/models/metadata_locales.rb b/app/models/metadata_locales.rb index 37b50286..b4ddf485 100644 --- a/app/models/metadata_locales.rb +++ b/app/models/metadata_locales.rb @@ -6,11 +6,16 @@ class MetadataLocales < MetadataHasAndBelongsToMany # # @return { lang: { title: uuid } } def values - @values ||= site.locales.map do |locale| - [locale, posts.where(lang: locale).map do |post| - [title(post), post.uuid.value] - end.to_h] - end.to_h + @values ||= other_locales.to_h do |other_locale| + [ + other_locale, + posts.where(locale: other_locale).pluck(:title, :layout, :post_id).to_h do |row| + row.tap do |value| + value[0] = "#{value[0]} (#{site.layouts[value.delete_at(1)].humanized_name})" + end + end + ] + end end # Siempre hay una relación inversa @@ -33,17 +38,13 @@ class MetadataLocales < MetadataHasAndBelongsToMany # # @return [Array] def other_locales - site.locales.reject do |locale| - locale == post.lang.value.to_sym - end + @other_locales ||= site.locales - [locale] end # Obtiene todos los posts de los otros locales con el mismo layout # - # @return [PostRelation] + # @return [IndexedPost::ActiveRecord_AssociationRelation] def posts - other_locales.map do |locale| - site.posts(lang: locale).where(layout: post.layout.value) - end.reduce(&:concat) || PostRelation.new(site: site, lang: 'any') + site.indexed_posts(locale: other_locales).where(layout: post.layout.value).where.not(post_id: post.uuid.value) end end diff --git a/app/models/metadata_order.rb b/app/models/metadata_order.rb index 1b33a388..f63a7d49 100644 --- a/app/models/metadata_order.rb +++ b/app/models/metadata_order.rb @@ -5,7 +5,7 @@ class MetadataOrder < MetadataTemplate # El valor según la posición del post en la relación ordenada por # fecha, a fecha más alta, posición más alta def default_value - super || site.posts(lang: lang).sort_by(:date).index(post) + super || ((site.indexed_posts.where(locale: locale).first&.order || 0) + 1) end def save diff --git a/app/models/metadata_related_posts.rb b/app/models/metadata_related_posts.rb index 42d1381b..6d52096e 100644 --- a/app/models/metadata_related_posts.rb +++ b/app/models/metadata_related_posts.rb @@ -3,14 +3,16 @@ # Devuelve una lista de títulos y UUID de todos los posts del mismo # idioma que el actual, para usar con input-map.js class MetadataRelatedPosts < MetadataArray - # Genera un Hash de { title | slug => uuid } y excluye el Post actual + # Genera un Hash de { title (schema) => uuid } para usar en + # options_for_select + # # @return [Hash] def values - @values ||= posts.map do |p| - next if p.uuid.value == post.uuid.value - - [title(p), p.uuid.value] - end.compact.to_h + @values ||= posts.pluck(:title, :created_at, :layout, :post_id).to_h do |row| + row.tap do |value| + value[0] = "#{value[0]} #{value.delete_at(1).strftime('%F')} (#{site.layouts[value.delete_at(1)].humanized_name})" + end + end end # Las relaciones nunca son privadas @@ -23,23 +25,23 @@ class MetadataRelatedPosts < MetadataArray end def indexable_values - posts.where(uuid: value).map(&:title).map(&:value) + posts.where(post_id: value).pluck(:title) end private - # Obtiene todos los posts y opcionalmente los filtra + # Obtiene todos los posts menos el actual y opcionalmente los filtra + # + # @return [IndexedPost::ActiveRecord_AssociationRelation] def posts - site.posts(lang: lang).where(**filter) + site.indexed_posts.where(locale: locale).where.not(post_id: post.uuid.value).where(filter) end - def title(post) - "#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})" - end - - # Encuentra el filtro + # Encuentra el filtro desde el esquema del atributo + # + # @return [Hash,nil] def filter - layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {} + layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys end def sanitize(uuid) diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 78989e15..a95f7e12 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -62,20 +62,28 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, end # Trae el idioma actual del sitio o del panel + # + # @deprecated Empezar a usar locale # @return [String] def lang - @lang ||= post&.lang&.value || I18n.locale + @lang ||= post&.lang&.value || I18n.locale.to_s end - # El valor por defecto + alias_method :locale, :lang + + # El valor por defecto desde el esquema de datos + # + # @return [any] def default_value - layout.metadata.dig(name, 'default', lang.to_s) + layout.metadata.dig(name, 'default', lang) end # Valores posibles, busca todos los valores actuales en otros # artículos del mismo sitio + # + # @return [Array] def values - site.everything_of(name, lang: lang) + site.indexed_posts.everything_of(name) end # Valor actual o por defecto. Al memoizarlo podemos modificarlo diff --git a/app/models/post.rb b/app/models/post.rb index 8885897f..6307e259 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -30,15 +30,44 @@ class Post # a demanda? def find_layout(path) File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym + rescue Errno::ENOENT => e + ExceptionNotifier.notify_exception(e, data: { path: path }) + + :post + end + + # Genera un Post nuevo + # + # @todo Mergear en Post#initialize + # @params :path [String] + # @params :site [Site] + # @params :locale [String, Symbol] + # @params :document [Jekyll::Document] + # @params :layout [String,Symbol] + # @return [Post] + def build(**args) + args[:path] ||= '' + args[:document] ||= + begin + site = args[:site] + collection = site.collections[args[:locale].to_s] + + Jekyll::Document.new(args[:path], site: site.jekyll, collection: collection).tap do |doc| + doc.data['date'] = Date.today.to_time if args[:path].blank? + end + end + + args[:layout] = args[:site].layouts[args[:layout]] if args[:layout].is_a? Symbol + + Post.new(**args) 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 - # + # @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) @@ -289,8 +318,6 @@ class Post def destroy run_callbacks :destroy do FileUtils.rm_f path.absolute - - site.delete_post self end end alias destroy! destroy diff --git a/app/models/post/indexable.rb b/app/models/post/indexable.rb index 4e46d7b2..38a98c2b 100644 --- a/app/models/post/indexable.rb +++ b/app/models/post/indexable.rb @@ -6,9 +6,11 @@ class Post extend ActiveSupport::Concern included do - # Indexa o reindexa el Post - after_save :index! - after_destroy :remove_from_index! + + # @return [IndexedPost,nil] + def indexed_post + site.indexed_posts.find_by_post_id(uuid.value) + end # Devuelve una versión indexable del Post # @@ -40,16 +42,19 @@ class Post private - # Los metadatos que se almacenan como objetos JSON. Empezamos con - # las categorías porque se usan para filtrar en el listado de - # artículos. + # Los metadatos que se almacenan como objetos JSON. # # @return [Hash] def indexable_front_matter {}.tap do |ifm| ifm[:usuaries] = usuaries.map(&:id) ifm[:draft] = attribute?(:draft) ? draft.value : false - ifm[:categories] = categories.indexable_values if attribute? :categories + + indexable_attributes.select do |attr| + self[attr].front_matter? + end.each do |attr| + ifm[attr] = self[attr].try(:indexable_values) + end end end diff --git a/app/models/post_relation.rb b/app/models/post_relation.rb deleted file mode 100644 index 531d3cc4..00000000 --- a/app/models/post_relation.rb +++ /dev/null @@ -1,156 +0,0 @@ -# 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, :lang - - def initialize(site:, lang:) - @site = site - @lang = lang - # 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) - args[:lang] = lang - args[:document] ||= build_document(collection: args[:lang]) - args[:layout] = build_layout(args[:layout]) - - post = Post.new(site: site, **args) - - self << post - post - end - - def create(**args) - post = build(**args) - post.save - post - end - - alias sort_by_generic sort_by - alias sort_by_generic! sort_by! - - # Permite ordenar los artículos por sus atributos - # - # XXX: Prestar atención cuando estamos mezclando artículos con - # diferentes tipos de atributos. - def sort_by(*attrs) - sort_by_generic do |post| - attrs.map do |attr| - # TODO: detectar el tipo de atributo faltante y obtener el valor - # por defecto para hacer la comparación - if post.attributes.include? attr - post.public_send(attr).value - else - 0 - end - end - end - end - - def sort_by!(*attrs) - replace sort_by(*attrs) - end - - alias find_generic find - - # Encontrar un post por su UUID - def find(id, uuid: false) - find_generic do |p| - if uuid - p.uuid.value == id - else - p.id == id - end - end - end - - # Encuentra el primer post por el valor de los atributos - # - # @param [Hash] - # @return [Post] - def find_by(**args) - find_generic do |post| - args.map do |attr, value| - post.attribute?(attr) && - post.public_send(attr).value == value - end.all? - end - end - - # Encuentra todos los Post que cumplan las condiciones - # - # TODO: Implementar caché - # - # @param [Hash] Mapa de atributo => valor. Valor puede ser un Array - # de valores - # @return [PostRelation] - def where(**args) - return self if args.empty? - - begin - PostRelation.new(site: site, lang: lang).concat(select do |post| - result = args.map do |attr, value| - next unless post.attribute?(attr) - - attribute = post[attr] - - # TODO: Si el valor del atributo también es un Array deberíamos - # cruzar ambas. - case value - when Array then value.include? attribute.value - else - case attribute.value - when Array then attribute.value.include? value - else attribute.value == value - end - end - end.compact - - # Un Array vacío devuelve true para all? - result.present? && result.all? - end) - end - end - - # Como Array#select devolviendo una relación - # - # @return [PostRelation] - alias array_select select - def select(&block) - PostRelation.new(site: site, lang: lang).concat array_select(&block) - end - - # Intenta guardar todos y devuelve true si pudo - def save_all(validate: true) - map do |post| - post.save(validate: validate) - end.all? - end - - private - - def build_layout(layout = nil) - return layout if layout.is_a? Layout - - site.layouts[layout&.to_sym || :post] - end - - # Devuelve una colección Jekyll que hace pasar el documento - def build_collection(label:) - Jekyll::Collection.new(site.jekyll, label.to_s) - end - - # Un documento borrador con algunas propiedades por defecto - def build_document(collection:) - col = build_collection(label: collection) - doc = Jekyll::Document.new('', site: site.jekyll, collection: col) - doc.data['date'] = Date.today.to_time - doc - end -end diff --git a/app/models/site.rb b/app/models/site.rb index ea49e147..94ce71d8 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -230,15 +230,10 @@ class Site < ApplicationRecord jekyll.data end - # Traer las colecciones. Todos los artículos van a estar dentro de - # colecciones. + # Trae las colecciones desde el sitio, sin leer su contenido + # + # @return [Hash] def collections - unless @read - jekyll.reader.read_collections - - @read = true - end - jekyll.collections end @@ -247,55 +242,6 @@ class Site < ApplicationRecord @config ||= Site::Config.new(self) 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) - # Traemos los posts del idioma actual por defecto o el que haya - lang ||= locales.include?(I18n.locale) ? I18n.locale : default_locale - lang = lang.to_sym - - # Crea un Struct dinámico con los valores de los locales, si - # llegamos a pasar un idioma que no existe vamos a tener una - # excepción NoMethodError - @posts ||= Struct.new(*locales).new - - return @posts[lang] unless @posts[lang].blank? - - @posts[lang] = PostRelation.new site: self, lang: lang - - # No fallar si no existe colección para este idioma - # XXX: queremos fallar silenciosamente? - (collections[lang.to_s]&.docs || []).each do |doc| - layout = layouts[Post.find_layout(doc.path)] - - @posts[lang].build(document: doc, layout: layout, lang: lang) - rescue TypeError => e - ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path }) - end - - @posts[lang] - end - - # Todos los Post del sitio para poder buscar en todos. - # - # @return PostRelation - def docs - @docs ||= PostRelation.new(site: self, lang: :docs).push(locales.flat_map do |locale| - posts(lang: locale) - end).flatten! - end - - # Elimina un artículo de la colección - def delete_post(post) - lang = post.lang.value - - collections[lang.to_s].docs.delete(post.document) && - posts(lang: lang).delete(post) - - post - end - # Obtiene todas las plantillas de artículos # # @return [Hash] { post: Layout } @@ -334,24 +280,6 @@ class Site < ApplicationRecord jekyll.reader.read_layouts end - # Trae todos los valores disponibles para un campo - # - # TODO: Traer recursivamente, si el campo contiene Hash - # - # TODO: Mover a PostRelation#pluck - # - # @param attr [Symbol|String] El atributo a buscar - # @return Array - def everything_of(attr, lang: nil) - Rails.cache.fetch("#{cache_key_with_version}/everything_of/#{lang}/#{attr}", expires_in: 1.hour) do - attr = attr.to_sym - - posts(lang: lang).flat_map do |p| - p[attr].value if p.attribute? attr - end.uniq.compact - end - end - # Poner en la cola de compilación def enqueue! update(status: 'enqueued') if waiting? @@ -468,7 +396,6 @@ class Site < ApplicationRecord @incompatible_layouts = nil @jekyll = nil @config = nil - @posts = nil @docs = nil end diff --git a/app/models/site/index.rb b/app/models/site/index.rb index cfa4030a..213ef0bb 100644 --- a/app/models/site/index.rb +++ b/app/models/site/index.rb @@ -16,7 +16,12 @@ class Site def index_posts! Site.transaction do - docs.each(&:index!) + jekyll.read + jekyll.documents.each do |doc| + doc.read! + + Post.build(document: doc, site: self, layout: doc['layout'].to_sym).index! + end update(last_indexed_commit: repository.head_commit.oid) end @@ -99,9 +104,10 @@ class Site indexable_posts.select do |delta| MODIFIED_STATUSES.include? delta.status end.each do |delta| - locale, path = locale_and_path_from(delta.new_file[:path]) + locale, _ = locale_and_path_from(delta.new_file[:path]) + full_path = File.join(self.path, delta.new_file[:path]) - posts(lang: locale).find(path).index! + Post.build(path: full_path, site: self, layout: Post.find_layout(full_path), locale: locale).index! end end diff --git a/app/services/post_service.rb b/app/services/post_service.rb index 4631a9a4..6f42bb22 100644 --- a/app/services/post_service.rb +++ b/app/services/post_service.rb @@ -3,12 +3,11 @@ # Este servicio se encarga de crear artículos y guardarlos en git, # asignándoselos a une usuarie PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do - # Crea un artículo nuevo + # Crea un artículo nuevo y modificar las asociaciones # # @return Post def create - self.post = site.posts(lang: locale) - .build(layout: layout) + self.post = Post.build(site: site, locale: locale, layout: layout) post.usuaries << usuarie params[:post][:draft] = true if site.invitade? usuarie @@ -16,42 +15,13 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do post.slug.value = p[:slug] if p[:slug].present? end - commit(action: :created, add: update_related_posts) if post.update(post_params) - - update_site_license! - - # Devolver el post aunque no se haya salvado para poder rescatar los - # errores - post - end - - # Crear un post anónimo, con opciones más limitadas. No usamos post. - def create_anonymous - # XXX: Confiamos en el parámetro de idioma porque estamos - # verificándolos en Site#posts - self.post = site.posts(lang: locale) - .build(layout: layout) - # Los artículos anónimos siempre son borradores - params[:draft] = true - - commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params) - post - end - - def update - post.usuaries << usuarie - params[:post][:draft] = true if site.invitade? usuarie - - # Eliminar ("mover") el archivo si cambió de ubicación. if post.update(post_params) - rm = [] - rm << post.path.value_was if post.path.changed? + added_paths << post.path.value - # Es importante que el artículo se guarde primero y luego los - # relacionados. - commit(action: :updated, add: update_related_posts, rm: rm) + # Recorrer todas las asociaciones y agregarse donde corresponda + update_associations(post) - update_site_license! + commit(action: :created, add: added_paths) end # Devolver el post aunque no se haya salvado para poder rescatar los @@ -59,6 +29,45 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do post end + # Crear un post anónimo, con opciones más limitadas. No usamos post. + # + # @todo Permitir asociaciones? + def create_anonymous + # XXX: Confiamos en el parámetro de idioma porque estamos + # verificándolos en Site#posts + self.post = Post.build(site: site, locale: locale, layout: layouts) + # Los artículos anónimos siempre son borradores + params[:draft] = true + + commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params) + post + end + + # Al actualizar, modificamos un post pre-existente, todas las + # relaciones anteriores y las relaciones actuales. + def update + post.usuaries << usuarie + params[:post][:draft] = true if site.invitade? usuarie + + if post.update(post_params) + # Eliminar ("mover") el archivo si cambió de ubicación. + rm = [] + rm << post.path.value_was if post.path.changed? + + added_paths << post.path.value + + # Recorrer todas las asociaciones y agregarse donde corresponda + update_associations(post) + + commit(action: :updated, add: added_paths, rm: rm) + end + + # Devolver el post aunque no se haya salvado para poder rescatar los + # errores + post + end + + # @todo Eliminar relaciones def destroy post.destroy! @@ -74,7 +83,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do # { uuid => 2, uuid => 1, uuid => 0 } def reorder reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i) - posts = site.posts(lang: locale).where(uuid: reorder.keys) + posts = site.indexed_posts.where(locale: locale, post_id: reorder.keys).map(&:post) files = posts.map do |post| next unless post.attribute? :order @@ -90,8 +99,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do return if files.empty? # TODO: Implementar transacciones! - posts.save_all(validate: false) && - commit(action: :reorder, add: files) + posts.map do |post| + post.save(validate: false) + end + + commit(action: :reorder, add: files) end private @@ -118,36 +130,16 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do end end + # @return [Symbol] def locale params.dig(:post, :lang)&.to_sym || I18n.locale end + # @return [Layout] def layout - params.dig(:post, :layout) || params[:layout] - end - - # Actualiza los artículos relacionados según los métodos que los - # metadatos declaren. - # - # Este método se asegura que todos los artículos se guardan una sola - # vez. - # - # @return [Array] Lista de archivos modificados - def update_related_posts - posts = Set.new - - post.attributes.each do |a| - post[a].related_methods.each do |m| - next unless post[a].respond_to? m - - # La respuesta puede ser una PostRelation también - posts.merge [post[a].public_send(m)].flatten.compact - end - end - - posts.map do |p| - p.path.absolute if p.save(validate: false) - end.compact << post.path.absolute + site.layouts[ + (params.dig(:post, :layout) || params[:layout]).to_sym + ] end # Si les usuaries modifican o crean una licencia, considerarla @@ -157,4 +149,128 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do site.update licencia: Licencia.find_by_icons('custom') end end + + # @return [Set] + def associated_posts_to_save + @associated_posts_to_save ||= Set.new + end + + # @return [Set] + def added_paths + @added_paths ||= Set.new + end + + # Recolectar campos asociados que no estén vacíos + # + # @param [Post] + # @return [Array] + def association_attributes(post) + post.attributes.select do |attribute| + post[attribute].try(:inverse?) + end + end + + # @param :post_ids [Array] + # @return [Association] + def associated_posts(post_ids) + site.indexed_posts.where(post_id: post_ids).map(&:post) + end + + # Modificar las asociaciones en cascada, manteniendo reciprocidad + # y guardando los archivos correspondientes. + # + # HABTM, Locales: si se rompe de un lado se elimina en el otro y lo + # mismo si se agrega. + # + # HasMany: la relación es de uno a muchos. Al quitar uno, se elimina + # la relación inversa. Al agregar uno, se elimina su relación + # anterior en el tercer Post y se actualiza con la nueva. + # + # BelongsTo: la inversa de HasMany. Al cambiarla, se quita de la + # relación anterior y se agrega en la nueva. + # + # @param :post [Post] + # @return [nil] + def update_associations(post) + association_attributes(post).each do |attribute| + metadata = post[attribute] + + next unless metadata.changed? + + inverse_attribute = post[attribute].inverse + value_was = metadata.value_was.dup + value = metadata.value.dup + + case metadata.type + when 'has_and_belongs_to_many', 'locales' + associated_posts(value_was - value).each do |remove_post| + remove_relation_from(remove_post[inverse_attribute], post.uuid.value) + end + + associated_posts(value - value_was).each do |add_post| + add_relation_to(add_post[inverse_attribute], post.uuid.value) + end + when 'has_many' + associated_posts(value_was - value).each do |remove_post| + remove_relation_from(remove_post[inverse_attribute], '') + end + + associated_posts(value - value_was).each do |add_post| + associated_posts(add_post[inverse_attribute].value_was).each do |remove_post| + remove_relation_from(remove_post[attribute], add_post.uuid.value) + end + + add_relation_to(add_post[inverse_attribute], post.uuid.value) + end + when 'belongs_to', 'has_one' + if value_was.present? + associated_posts(value_was).each do |remove_post| + remove_relation_from(remove_post[inverse_attribute], post.uuid.value) + end + end + + associated_posts(value).each do |add_post| + add_relation_to(add_post[inverse_attribute], post.uuid.value) + end + end + end + + associated_posts_to_save.each do |associated_post| + next unless associated_post.save(validate: false) + + added_paths << associated_post.path.value + end + + nil + end + + # @todo por qué no podemos usar nil para deshabilitar un valor? + # @param :metadata [MetadataTemplate] + # @param :value [String] + # @return [nil] + def remove_relation_from(metadata, value) + case metadata.value + when Array then metadata.value.delete(value) + when String then metadata.value = '' + end + + associated_posts_to_save << metadata.post + nil + end + + # @todo El validador ya debería eliminar valores duplicados + # @param :metadata [MetadataTemplate] + # @param :value [String] + # @return [nil] + def add_relation_to(metadata, value) + case metadata.value + when Array + metadata.value << value + metadata.value.uniq! + when String then metadata.value = value + end + + associated_posts_to_save << metadata.post + nil + end end diff --git a/app/services/site_service.rb b/app/services/site_service.rb index 36868c51..e63c6519 100644 --- a/app/services/site_service.rb +++ b/app/services/site_service.rb @@ -82,16 +82,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do commit_config(action: :tor) end - # Trae cambios desde la rama remota y reindexa los artículos. + # Trae cambios desde la rama remota # # @return [Boolean] def merge - result = site.repository.merge(usuarie) - - # TODO: Implementar callbacks - site.try(:index_posts!) if result - - result.present? + site.repository.merge(usuarie).present? end private @@ -146,7 +141,7 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do return true if site.licencia.custom? with_all_locales do |locale| - post = site.posts(lang: locale).find_by(layout: 'license') + post = site.indexed_posts(locale: locale).find_by(layout: 'license')&.post change_licencia(post: post) if post end.compact.map(&:valid?).all? diff --git a/app/views/posts/attribute_ro/_belongs_to.haml b/app/views/posts/attribute_ro/_belongs_to.haml index c7e06be8..7410e921 100644 --- a/app/views/posts/attribute_ro/_belongs_to.haml +++ b/app/views/posts/attribute_ro/_belongs_to.haml @@ -1,6 +1,6 @@ %tr{ id: attribute } %th= post_label_t(attribute, post: post) %td{ dir: dir, lang: locale } - - p = metadata.belongs_to + - p = site.indexed_posts.find_by_post_id(metadata.value) - if p - = link_to p.title.value, site_post_path(site, p.id) + = link_to p.title, site_post_path(site, p.path) diff --git a/app/views/posts/attribute_ro/_has_and_belongs_to_many.haml b/app/views/posts/attribute_ro/_has_and_belongs_to_many.haml index d6b51a7a..29c0816f 100644 --- a/app/views/posts/attribute_ro/_has_and_belongs_to_many.haml +++ b/app/views/posts/attribute_ro/_has_and_belongs_to_many.haml @@ -2,5 +2,5 @@ %th= post_label_t(attribute, post: post) %td %ul{ dir: dir, lang: locale } - - metadata.has_many.each do |p| - %li= link_to p.title.value, site_post_path(site, p.id) + - site.indexed_posts.where(post_id: metadata.value).find_each do |p| + %li= link_to p.title, site_post_path(site, p.path) diff --git a/app/views/posts/attribute_ro/_has_many.haml b/app/views/posts/attribute_ro/_has_many.haml index d6b51a7a..29c0816f 100644 --- a/app/views/posts/attribute_ro/_has_many.haml +++ b/app/views/posts/attribute_ro/_has_many.haml @@ -2,5 +2,5 @@ %th= post_label_t(attribute, post: post) %td %ul{ dir: dir, lang: locale } - - metadata.has_many.each do |p| - %li= link_to p.title.value, site_post_path(site, p.id) + - site.indexed_posts.where(post_id: metadata.value).find_each do |p| + %li= link_to p.title, site_post_path(site, p.path) diff --git a/app/views/posts/attribute_ro/_has_one.haml b/app/views/posts/attribute_ro/_has_one.haml new file mode 100644 index 00000000..fafccd34 --- /dev/null +++ b/app/views/posts/attribute_ro/_has_one.haml @@ -0,0 +1,6 @@ +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td{ dir: dir, lang: locale } + - p = site.indexed_posts.find_by_post_id(metadata.value) + - if p + = link_to p.title, site_post_path(site, p.post_id) diff --git a/app/views/posts/attribute_ro/_related_posts.haml b/app/views/posts/attribute_ro/_related_posts.haml index c43b589e..aa4aff2e 100644 --- a/app/views/posts/attribute_ro/_related_posts.haml +++ b/app/views/posts/attribute_ro/_related_posts.haml @@ -2,10 +2,9 @@ %th= post_label_t(attribute, post: post) %td %ul{ dir: dir, lang: locale } - - metadata.value.each do |v| - - p = site.posts(lang: post.lang.value).find(v, uuid: true) + - site.indexed_posts.where(locale: post.lang.value, post_id: metadata.value).find_each do |p| -# XXX: Ignorar todos los posts no encontrados (ej: fueron borrados o el uuid cambió) - next unless p - %li= link_to p.title.value, site_post_path(site, p.id) + %li= link_to p.title, site_post_path(site, p.path) diff --git a/app/views/posts/attributes/_has_one.haml b/app/views/posts/attributes/_has_one.haml new file mode 100644 index 00000000..b0d21f35 --- /dev/null +++ b/app/views/posts/attributes/_has_one.haml @@ -0,0 +1,7 @@ +.form-group + = label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post) + = select_tag(plain_field_name_for(base, attribute), + options_for_select(metadata.values, metadata.value), + **field_options(attribute, metadata), include_blank: t('.empty')) + = render 'posts/attribute_feedback', + post: post, attribute: attribute, metadata: metadata diff --git a/app/views/posts/attributes/_locales.haml b/app/views/posts/attributes/_locales.haml index 4978f6b4..05592fbd 100644 --- a/app/views/posts/attributes/_locales.haml +++ b/app/views/posts/attributes/_locales.haml @@ -6,7 +6,6 @@ post: post, attribute: attribute, metadata: metadata - site.locales.each do |locale| - - next if post.lang.value == locale - locale_t = t("locales.#{locale}.name", default: locale.to_s.humanize) - value = metadata.value.find do |v| - metadata.values[locale].values.include? v diff --git a/db/migrate/20210504224343_create_indexed_posts.rb b/db/migrate/20210504224343_create_indexed_posts.rb index 9cf21538..a88db1f3 100644 --- a/db/migrate/20210504224343_create_indexed_posts.rb +++ b/db/migrate/20210504224343_create_indexed_posts.rb @@ -27,7 +27,7 @@ class CreateIndexedPosts < ActiveRecord::Migration[6.1] # Queremos mostrar el título por separado t.string :title, default: '' # También vamos a mostrar las categorías - t.jsonb :front_matter, default: '{}' + t.jsonb :front_matter, default: {} t.string :content, default: '' t.tsvector :indexed_content diff --git a/db/migrate/20231026211607_deprecate_deploy_reindex.rb b/db/migrate/20231026211607_deprecate_deploy_reindex.rb new file mode 100644 index 00000000..945d01b4 --- /dev/null +++ b/db/migrate/20231026211607_deprecate_deploy_reindex.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Ya no es necesario reindexar por la fuerza +class DeprecateDeployReindex < ActiveRecord::Migration[6.1] + def up + Deploy.where(type: 'DeployReindex').destroy_all + end + + def down;end +end