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/indexed_post.rb b/app/models/indexed_post.rb index 184cd05f..02679ec0 100644 --- a/app/models/indexed_post.rb +++ b/app/models/indexed_post.rb @@ -34,15 +34,64 @@ 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 + 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 17ecaee8..ac0efe52 100644 --- a/app/models/metadata_belongs_to.rb +++ b/app/models/metadata_belongs_to.rb @@ -77,13 +77,17 @@ class MetadataBelongsTo < MetadataRelatedPosts end # El Post relacionado con este artículo + # + # @return [Post,nil] def belongs_to - posts.find(value, uuid: true) if value.present? + posts.find_by(post_id: value)&.post if value.present? end # El artículo relacionado anterior + # + # @return [Post,nil] def belonged_to - posts.find(value_was, uuid: true) if value_was.present? + posts.find_by(post_id: value_was)&.post if value_was.present? end def related_posts? diff --git a/app/models/metadata_has_many.rb b/app/models/metadata_has_many.rb index 13f0dcf5..82ec333a 100644 --- a/app/models/metadata_has_many.rb +++ b/app/models/metadata_has_many.rb @@ -7,17 +7,21 @@ # apuntando a un Post, que se mantiene actualizado como el actual. class MetadataHasMany < MetadataRelatedPosts # Todos los Post relacionados + # + # @return [Array] def has_many return default_value if value.blank? - posts.where(uuid: value) + posts.where(post_id: value).map(&:post) end # La relación anterior + # + # @return [Array] def had_many return default_value if value_was.blank? - posts.where(uuid: value_was) + posts.where(post_id: value_was).map(&:post) end def inverse? 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..577e3c85 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(locale: locale).first.order + 1) end def save diff --git a/app/models/metadata_related_posts.rb b/app/models/metadata_related_posts.rb index 42d1381b..f513b4fb 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,21 +25,21 @@ 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] def filter layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {} end diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 823443d2..9d04bcf6 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 4b6f10ac..1c535fe0 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -35,14 +35,39 @@ class Post :post end + + # Genera un Post nuevo + # + # @params :site [Site] + # @params :locale [String, Symbol] + # @params :document [Jekyll::Document] + # @params :layout [String,Symbol] + # @return [Post] + def build(**args) + args[:document] ||= + begin + site = args[:site] + collection = site.collections[args[:locale].to_s] + + Jekyll::Document.new('', site: site.jekyll, collection: collection).tap do |doc| + doc.data['date'] = Date.today.to_time + end + end + + Post.new(**args) + end + end + + # @return [IndexedPost,nil] + def indexed_post + site.indexed_posts.find_by(locale: lang.value, path: id) 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) @@ -291,8 +316,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..9629efd0 100644 --- a/app/models/post/indexable.rb +++ b/app/models/post/indexable.rb @@ -40,16 +40,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].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 63c54050..0139d8ed 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -216,17 +216,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 - Site.one_at_a_time.synchronize do - jekyll.reader.read_collections - end - - @read = true - end - jekyll.collections end @@ -235,55 +228,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 } @@ -320,24 +264,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? @@ -452,7 +378,6 @@ class Site < ApplicationRecord @incompatible_layouts = nil @jekyll = nil @config = nil - @posts = nil @docs = nil end diff --git a/app/services/post_service.rb b/app/services/post_service.rb index 4631a9a4..0d989871 100644 --- a/app/services/post_service.rb +++ b/app/services/post_service.rb @@ -7,8 +7,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do # # @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 @@ -29,8 +28,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do 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) + self.post = Post.build(site: site, locale: locale, layout: layouts) # Los artículos anónimos siempre son borradores params[:draft] = true @@ -74,7 +72,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(locale: locale).where(post_id: reorder.keys).map(&:post) files = posts.map do |post| next unless post.attribute? :order @@ -118,12 +116,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] + site.layouts[ + (params.dig(:post, :layout) || params[:layout]).to_sym + ] end # Actualiza los artículos relacionados según los métodos que los diff --git a/app/services/site_service.rb b/app/services/site_service.rb index 762c8728..010558d6 100644 --- a/app/services/site_service.rb +++ b/app/services/site_service.rb @@ -145,7 +145,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/_related_posts.haml b/app/views/posts/attribute_ro/_related_posts.haml index c43b589e..6014e160 100644 --- a/app/views/posts/attribute_ro/_related_posts.haml +++ b/app/views/posts/attribute_ro/_related_posts.haml @@ -3,7 +3,7 @@ %td %ul{ dir: dir, lang: locale } - metadata.value.each do |v| - - p = site.posts(lang: post.lang.value).find(v, uuid: true) + - p = site.indexed_posts(locale: post.lang.value).find_by(post_id: v)&.post -# XXX: Ignorar todos los posts no encontrados (ej: fueron borrados o el uuid cambió) 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/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb index 44e38c26..7d1eab9e 100644 --- a/config/initializers/core_extensions.rb +++ b/config/initializers/core_extensions.rb @@ -125,8 +125,8 @@ module Jekyll unless spec I18n.with_locale(locale) do - raise ArgumentError, I18n.t('activerecord.errors.models.site.attributes.design_id.missing_gem', theme: name) - rescue ArgumentError => e + raise Jekyll::Errors::InvalidThemeName, I18n.t('activerecord.errors.models.site.attributes.design_id.missing_gem', theme: name) + rescue Jekyll::Errors::InvalidThemeName => e ExceptionNotifier.notify_exception(e, data: { theme: name, site: File.basename(site.source) }) raise end diff --git a/db/seeds/designs.yml b/db/seeds/designs.yml index 56655322..d1ae458e 100644 --- a/db/seeds/designs.yml +++ b/db/seeds/designs.yml @@ -18,7 +18,7 @@ - name_en: 'Minima' name_es: 'Mínima' gem: 'sutty-minima' - url: 'https://0xacab.org/sutty/jekyll/minima' + url: 'https://minima.sutty.nl/' description_en: "Sutty Minima is based on [Minima](https://jekyll.github.io/minima/), a blog-focused theme for Jekyll." description_es: 'Sutty Mínima es una plantilla para blogs basada en [Mínima](https://jekyll.github.io/minima/).' license: 'https://0xacab.org/sutty/jekyll/minima/-/blob/master/LICENSE.txt'