5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2025-03-14 21:08:18 +00:00

Merge branch 'issue-7537' into 'rails'

usar más la base de datos #7537

See merge request sutty/sutty!212
This commit is contained in:
fauno 2025-01-25 04:57:11 +00:00
commit dd9d2a9d57
29 changed files with 410 additions and 574 deletions

View file

@ -86,7 +86,9 @@ class ApplicationController < ActionController::Base
end end
def site def site
@site ||= find_site @site ||= find_site.tap do |s|
s.reindex_changes!
end
end end
protected protected

View file

@ -55,9 +55,11 @@ class PostsController < ApplicationController
def new def new
authorize Post 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 end
def create def create
@ -161,7 +163,7 @@ class PostsController < ApplicationController
end end
def post 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 end
# Recuerda el nombre del servicio de subida de archivos # Recuerda el nombre del servicio de subida de archivos

View file

@ -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

View file

@ -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

View file

@ -34,15 +34,66 @@ class IndexedPost < ApplicationRecord
scope :in_category, ->(category) { where("front_matter->'categories' ? :category", category: category.to_s) } 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) } 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 belongs_to :site
# Encuentra el post original # La ubicación del Post en el disco
# #
# @return [nil,Post] # @return [String]
def post def full_path
return if post_id.blank? @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 end
# Convertir locale a direccionario de PG # Convertir locale a direccionario de PG

View file

@ -3,6 +3,8 @@
# Almacena el UUID de otro Post y actualiza el valor en el Post # Almacena el UUID de otro Post y actualiza el valor en el Post
# relacionado. # relacionado.
class MetadataBelongsTo < MetadataRelatedPosts class MetadataBelongsTo < MetadataRelatedPosts
include Metadata::InverseConcern
# TODO: Convertir algunos tipos de valores en módulos para poder # TODO: Convertir algunos tipos de valores en módulos para poder
# implementar varios tipos de campo sin repetir código # implementar varios tipos de campo sin repetir código
# #
@ -20,82 +22,12 @@ class MetadataBelongsTo < MetadataRelatedPosts
document.data[name.to_s] document.data[name.to_s]
end 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 def indexable_values
belongs_to&.title&.value posts.find_by_post_uuid(value).try(:title)
end end
private private
def post_exists?
return true if sanitize(value).blank?
sanitize(value).present? && belongs_to.present?
end
def sanitize(uuid) def sanitize(uuid)
uuid.to_s.gsub(/[^a-f0-9\-]/i, '') uuid.to_s.gsub(/[^a-f0-9\-]/i, '')
end end

View file

@ -1,46 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
# Establece una relación de muchos a muchos artículos. Cada campo es un # Establece una relación de muchos a muchos artículos
# 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.
class MetadataHasAndBelongsToMany < MetadataHasMany 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 end

View file

@ -6,55 +6,5 @@
# Localmente tenemos un Array de UUIDs. Remotamente tenemos una String # Localmente tenemos un Array de UUIDs. Remotamente tenemos una String
# apuntando a un Post, que se mantiene actualizado como el actual. # apuntando a un Post, que se mantiene actualizado como el actual.
class MetadataHasMany < MetadataRelatedPosts class MetadataHasMany < MetadataRelatedPosts
# Todos los Post relacionados include Metadata::InverseConcern
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
end end

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
class MetadataHasOne < MetadataBelongsTo; end

View file

@ -6,11 +6,16 @@ class MetadataLocales < MetadataHasAndBelongsToMany
# #
# @return { lang: { title: uuid } } # @return { lang: { title: uuid } }
def values def values
@values ||= site.locales.map do |locale| @values ||= other_locales.to_h do |other_locale|
[locale, posts.where(lang: locale).map do |post| [
[title(post), post.uuid.value] other_locale,
end.to_h] posts.where(locale: other_locale).pluck(:title, :layout, :post_id).to_h do |row|
end.to_h row.tap do |value|
value[0] = "#{value[0]} (#{site.layouts[value.delete_at(1)].humanized_name})"
end
end
]
end
end end
# Siempre hay una relación inversa # Siempre hay una relación inversa
@ -33,17 +38,13 @@ class MetadataLocales < MetadataHasAndBelongsToMany
# #
# @return [Array] # @return [Array]
def other_locales def other_locales
site.locales.reject do |locale| @other_locales ||= site.locales - [locale]
locale == post.lang.value.to_sym
end
end end
# Obtiene todos los posts de los otros locales con el mismo layout # Obtiene todos los posts de los otros locales con el mismo layout
# #
# @return [PostRelation] # @return [IndexedPost::ActiveRecord_AssociationRelation]
def posts def posts
other_locales.map do |locale| site.indexed_posts(locale: other_locales).where(layout: post.layout.value).where.not(post_id: post.uuid.value)
site.posts(lang: locale).where(layout: post.layout.value)
end.reduce(&:concat) || PostRelation.new(site: site, lang: 'any')
end end
end end

View file

@ -5,7 +5,7 @@ class MetadataOrder < MetadataTemplate
# El valor según la posición del post en la relación ordenada por # 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 # fecha, a fecha más alta, posición más alta
def default_value 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 end
def save def save

View file

@ -3,14 +3,16 @@
# Devuelve una lista de títulos y UUID de todos los posts del mismo # Devuelve una lista de títulos y UUID de todos los posts del mismo
# idioma que el actual, para usar con input-map.js # idioma que el actual, para usar con input-map.js
class MetadataRelatedPosts < MetadataArray 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] # @return [Hash]
def values def values
@values ||= posts.map do |p| @values ||= posts.pluck(:title, :created_at, :layout, :post_id).to_h do |row|
next if p.uuid.value == post.uuid.value row.tap do |value|
value[0] = "#{value[0]} #{value.delete_at(1).strftime('%F')} (#{site.layouts[value.delete_at(1)].humanized_name})"
[title(p), p.uuid.value] end
end.compact.to_h end
end end
# Las relaciones nunca son privadas # Las relaciones nunca son privadas
@ -23,23 +25,23 @@ class MetadataRelatedPosts < MetadataArray
end end
def indexable_values def indexable_values
posts.where(uuid: value).map(&:title).map(&:value) posts.where(post_id: value).pluck(:title)
end end
private 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 def posts
site.posts(lang: lang).where(**filter) site.indexed_posts.where(locale: locale).where.not(post_id: post.uuid.value).where(filter)
end end
def title(post) # Encuentra el filtro desde el esquema del atributo
"#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})" #
end # @return [Hash,nil]
# Encuentra el filtro
def filter def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {} layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys
end end
def sanitize(uuid) def sanitize(uuid)

View file

@ -62,20 +62,28 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
end end
# Trae el idioma actual del sitio o del panel # Trae el idioma actual del sitio o del panel
#
# @deprecated Empezar a usar locale
# @return [String] # @return [String]
def lang def lang
@lang ||= post&.lang&.value || I18n.locale @lang ||= post&.lang&.value || I18n.locale.to_s
end end
# El valor por defecto alias_method :locale, :lang
# El valor por defecto desde el esquema de datos
#
# @return [any]
def default_value def default_value
layout.metadata.dig(name, 'default', lang.to_s) layout.metadata.dig(name, 'default', lang)
end end
# Valores posibles, busca todos los valores actuales en otros # Valores posibles, busca todos los valores actuales en otros
# artículos del mismo sitio # artículos del mismo sitio
#
# @return [Array]
def values def values
site.everything_of(name, lang: lang) site.indexed_posts.everything_of(name)
end end
# Valor actual o por defecto. Al memoizarlo podemos modificarlo # Valor actual o por defecto. Al memoizarlo podemos modificarlo

View file

@ -30,15 +30,44 @@ class Post
# a demanda? # a demanda?
def find_layout(path) def find_layout(path)
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym 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
end end
# Redefinir el inicializador de OpenStruct # Redefinir el inicializador de OpenStruct
# #
# @param site: [Site] el sitio en Sutty # @param :site [Site] el sitio en Sutty
# @param document: [Jekyll::Document] el documento leído por Jekyll # @param :document [Jekyll::Document] el documento leído por Jekyll
# @param layout: [Layout] la plantilla # @param :layout [Layout] la plantilla
#
def initialize(**args) def initialize(**args)
default_attributes_missing(**args) default_attributes_missing(**args)
@ -289,8 +318,6 @@ class Post
def destroy def destroy
run_callbacks :destroy do run_callbacks :destroy do
FileUtils.rm_f path.absolute FileUtils.rm_f path.absolute
site.delete_post self
end end
end end
alias destroy! destroy alias destroy! destroy

View file

@ -6,9 +6,11 @@ class Post
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
# Indexa o reindexa el Post
after_save :index! # @return [IndexedPost,nil]
after_destroy :remove_from_index! def indexed_post
site.indexed_posts.find_by_post_id(uuid.value)
end
# Devuelve una versión indexable del Post # Devuelve una versión indexable del Post
# #
@ -40,16 +42,19 @@ class Post
private private
# Los metadatos que se almacenan como objetos JSON. Empezamos con # Los metadatos que se almacenan como objetos JSON.
# las categorías porque se usan para filtrar en el listado de
# artículos.
# #
# @return [Hash] # @return [Hash]
def indexable_front_matter def indexable_front_matter
{}.tap do |ifm| {}.tap do |ifm|
ifm[:usuaries] = usuaries.map(&:id) ifm[:usuaries] = usuaries.map(&:id)
ifm[:draft] = attribute?(:draft) ? draft.value : false 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
end end

View file

@ -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

View file

@ -230,15 +230,10 @@ class Site < ApplicationRecord
jekyll.data jekyll.data
end end
# Traer las colecciones. Todos los artículos van a estar dentro de # Trae las colecciones desde el sitio, sin leer su contenido
# colecciones. #
# @return [Hash]
def collections def collections
unless @read
jekyll.reader.read_collections
@read = true
end
jekyll.collections jekyll.collections
end end
@ -247,55 +242,6 @@ class Site < ApplicationRecord
@config ||= Site::Config.new(self) @config ||= Site::Config.new(self)
end 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 # Obtiene todas las plantillas de artículos
# #
# @return [Hash] { post: Layout } # @return [Hash] { post: Layout }
@ -334,24 +280,6 @@ class Site < ApplicationRecord
jekyll.reader.read_layouts jekyll.reader.read_layouts
end 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 # Poner en la cola de compilación
def enqueue! def enqueue!
update(status: 'enqueued') if waiting? update(status: 'enqueued') if waiting?
@ -468,7 +396,6 @@ class Site < ApplicationRecord
@incompatible_layouts = nil @incompatible_layouts = nil
@jekyll = nil @jekyll = nil
@config = nil @config = nil
@posts = nil
@docs = nil @docs = nil
end end

View file

@ -16,7 +16,12 @@ class Site
def index_posts! def index_posts!
Site.transaction do 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) update(last_indexed_commit: repository.head_commit.oid)
end end
@ -99,9 +104,10 @@ class Site
indexable_posts.select do |delta| indexable_posts.select do |delta|
MODIFIED_STATUSES.include? delta.status MODIFIED_STATUSES.include? delta.status
end.each do |delta| 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
end end

View file

@ -3,12 +3,11 @@
# Este servicio se encarga de crear artículos y guardarlos en git, # Este servicio se encarga de crear artículos y guardarlos en git,
# asignándoselos a une usuarie # asignándoselos a une usuarie
PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do 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 # @return Post
def create def create
self.post = site.posts(lang: locale) self.post = Post.build(site: site, locale: locale, layout: layout)
.build(layout: layout)
post.usuaries << usuarie post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie params[:post][:draft] = true if site.invitade? usuarie
@ -16,9 +15,14 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post.slug.value = p[:slug] if p[:slug].present? post.slug.value = p[:slug] if p[:slug].present?
end end
commit(action: :created, add: update_related_posts) if post.update(post_params) if post.update(post_params)
added_paths << post.path.value
update_site_license! # Recorrer todas las asociaciones y agregarse donde corresponda
update_associations(post)
commit(action: :created, add: added_paths)
end
# Devolver el post aunque no se haya salvado para poder rescatar los # Devolver el post aunque no se haya salvado para poder rescatar los
# errores # errores
@ -26,11 +30,12 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end end
# Crear un post anónimo, con opciones más limitadas. No usamos post. # Crear un post anónimo, con opciones más limitadas. No usamos post.
#
# @todo Permitir asociaciones?
def create_anonymous def create_anonymous
# XXX: Confiamos en el parámetro de idioma porque estamos # XXX: Confiamos en el parámetro de idioma porque estamos
# verificándolos en Site#posts # verificándolos en Site#posts
self.post = site.posts(lang: locale) self.post = Post.build(site: site, locale: locale, layout: layouts)
.build(layout: layout)
# Los artículos anónimos siempre son borradores # Los artículos anónimos siempre son borradores
params[:draft] = true params[:draft] = true
@ -38,20 +43,23 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post post
end end
# Al actualizar, modificamos un post pre-existente, todas las
# relaciones anteriores y las relaciones actuales.
def update def update
post.usuaries << usuarie post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie params[:post][:draft] = true if site.invitade? usuarie
# Eliminar ("mover") el archivo si cambió de ubicación.
if post.update(post_params) if post.update(post_params)
# Eliminar ("mover") el archivo si cambió de ubicación.
rm = [] rm = []
rm << post.path.value_was if post.path.changed? rm << post.path.value_was if post.path.changed?
# Es importante que el artículo se guarde primero y luego los added_paths << post.path.value
# relacionados.
commit(action: :updated, add: update_related_posts, rm: rm)
update_site_license! # Recorrer todas las asociaciones y agregarse donde corresponda
update_associations(post)
commit(action: :updated, add: added_paths, rm: rm)
end end
# Devolver el post aunque no se haya salvado para poder rescatar los # Devolver el post aunque no se haya salvado para poder rescatar los
@ -59,6 +67,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post post
end end
# @todo Eliminar relaciones
def destroy def destroy
post.destroy! post.destroy!
@ -74,7 +83,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# { uuid => 2, uuid => 1, uuid => 0 } # { uuid => 2, uuid => 1, uuid => 0 }
def reorder def reorder
reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i) 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| files = posts.map do |post|
next unless post.attribute? :order next unless post.attribute? :order
@ -90,7 +99,10 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
return if files.empty? return if files.empty?
# TODO: Implementar transacciones! # TODO: Implementar transacciones!
posts.save_all(validate: false) && posts.map do |post|
post.save(validate: false)
end
commit(action: :reorder, add: files) commit(action: :reorder, add: files)
end end
@ -118,36 +130,16 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end end
end end
# @return [Symbol]
def locale def locale
params.dig(:post, :lang)&.to_sym || I18n.locale params.dig(:post, :lang)&.to_sym || I18n.locale
end end
# @return [Layout]
def layout def layout
params.dig(:post, :layout) || params[:layout] site.layouts[
end (params.dig(:post, :layout) || params[:layout]).to_sym
]
# 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
end end
# Si les usuaries modifican o crean una licencia, considerarla # 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') site.update licencia: Licencia.find_by_icons('custom')
end end
end end
# @return [Set<String>]
def associated_posts_to_save
@associated_posts_to_save ||= Set.new
end
# @return [Set<String>]
def added_paths
@added_paths ||= Set.new
end
# Recolectar campos asociados que no estén vacíos
#
# @param [Post]
# @return [Array<Symbol>]
def association_attributes(post)
post.attributes.select do |attribute|
post[attribute].try(:inverse?)
end
end
# @param :post_ids [Array<String>]
# @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 end

View file

@ -82,16 +82,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
commit_config(action: :tor) commit_config(action: :tor)
end end
# Trae cambios desde la rama remota y reindexa los artículos. # Trae cambios desde la rama remota
# #
# @return [Boolean] # @return [Boolean]
def merge def merge
result = site.repository.merge(usuarie) site.repository.merge(usuarie).present?
# TODO: Implementar callbacks
site.try(:index_posts!) if result
result.present?
end end
private private
@ -146,7 +141,7 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
return true if site.licencia.custom? return true if site.licencia.custom?
with_all_locales do |locale| 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 change_licencia(post: post) if post
end.compact.map(&:valid?).all? end.compact.map(&:valid?).all?

View file

@ -1,6 +1,6 @@
%tr{ id: attribute } %tr{ id: attribute }
%th= post_label_t(attribute, post: post) %th= post_label_t(attribute, post: post)
%td{ dir: dir, lang: locale } %td{ dir: dir, lang: locale }
- p = metadata.belongs_to - p = site.indexed_posts.find_by_post_id(metadata.value)
- if p - if p
= link_to p.title.value, site_post_path(site, p.id) = link_to p.title, site_post_path(site, p.path)

View file

@ -2,5 +2,5 @@
%th= post_label_t(attribute, post: post) %th= post_label_t(attribute, post: post)
%td %td
%ul{ dir: dir, lang: locale } %ul{ dir: dir, lang: locale }
- metadata.has_many.each do |p| - site.indexed_posts.where(post_id: metadata.value).find_each do |p|
%li= link_to p.title.value, site_post_path(site, p.id) %li= link_to p.title, site_post_path(site, p.path)

View file

@ -2,5 +2,5 @@
%th= post_label_t(attribute, post: post) %th= post_label_t(attribute, post: post)
%td %td
%ul{ dir: dir, lang: locale } %ul{ dir: dir, lang: locale }
- metadata.has_many.each do |p| - site.indexed_posts.where(post_id: metadata.value).find_each do |p|
%li= link_to p.title.value, site_post_path(site, p.id) %li= link_to p.title, site_post_path(site, p.path)

View file

@ -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)

View file

@ -2,10 +2,9 @@
%th= post_label_t(attribute, post: post) %th= post_label_t(attribute, post: post)
%td %td
%ul{ dir: dir, lang: locale } %ul{ dir: dir, lang: locale }
- metadata.value.each do |v| - site.indexed_posts.where(locale: post.lang.value, post_id: metadata.value).find_each do |p|
- p = site.posts(lang: post.lang.value).find(v, uuid: true)
-# -#
XXX: Ignorar todos los posts no encontrados (ej: fueron XXX: Ignorar todos los posts no encontrados (ej: fueron
borrados o el uuid cambió) borrados o el uuid cambió)
- next unless p - 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)

View file

@ -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

View file

@ -6,7 +6,6 @@
post: post, attribute: attribute, metadata: metadata post: post, attribute: attribute, metadata: metadata
- site.locales.each do |locale| - site.locales.each do |locale|
- next if post.lang.value == locale
- locale_t = t("locales.#{locale}.name", default: locale.to_s.humanize) - locale_t = t("locales.#{locale}.name", default: locale.to_s.humanize)
- value = metadata.value.find do |v| - value = metadata.value.find do |v|
- metadata.values[locale].values.include? v - metadata.values[locale].values.include? v

View file

@ -27,7 +27,7 @@ class CreateIndexedPosts < ActiveRecord::Migration[6.1]
# Queremos mostrar el título por separado # Queremos mostrar el título por separado
t.string :title, default: '' t.string :title, default: ''
# También vamos a mostrar las categorías # También vamos a mostrar las categorías
t.jsonb :front_matter, default: '{}' t.jsonb :front_matter, default: {}
t.string :content, default: '' t.string :content, default: ''
t.tsvector :indexed_content t.tsvector :indexed_content

View file

@ -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