Merge branch 'rails' into void/editor

This commit is contained in:
f 2021-02-19 14:07:11 -03:00
commit 0b86702bdb
38 changed files with 378 additions and 357 deletions

View file

@ -5,8 +5,11 @@
# tiempo buscando soporte para musl
if ENV['RAILS_ENV'] == 'production'
source 'https://gems.sutty.nl'
ruby '2.7.2'
else
source 'https://rubygems.org'
# Cambiar en Dockerfile también
ruby '2.7.1'
end
git_source(:github) do |repo_name|
@ -14,8 +17,6 @@ git_source(:github) do |repo_name|
"https://github.com/#{repo_name}.git"
end
# Cambiar en Dockerfile también
ruby '2.7.2'
gem 'dotenv-rails', require: 'dotenv/rails-now'
@ -71,6 +72,7 @@ gem 'redis', require: %w[redis redis/connection/hiredis]
gem 'redis-rails'
gem 'rubyzip'
gem 'rugged'
gem 'concurrent-ruby-ext'
gem 'sucker_punch'
gem 'symbol-fstring', require: 'fstring/all'
gem 'terminal-table'

View file

@ -10,60 +10,60 @@ GEM
remote: https://rubygems.org/
remote: https://gems.sutty.nl/
specs:
actioncable (6.1.1)
actionpack (= 6.1.1)
activesupport (= 6.1.1)
actioncable (6.1.2.1)
actionpack (= 6.1.2.1)
activesupport (= 6.1.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.1)
actionpack (= 6.1.1)
activejob (= 6.1.1)
activerecord (= 6.1.1)
activestorage (= 6.1.1)
activesupport (= 6.1.1)
actionmailbox (6.1.2.1)
actionpack (= 6.1.2.1)
activejob (= 6.1.2.1)
activerecord (= 6.1.2.1)
activestorage (= 6.1.2.1)
activesupport (= 6.1.2.1)
mail (>= 2.7.1)
actionmailer (6.1.1)
actionpack (= 6.1.1)
actionview (= 6.1.1)
activejob (= 6.1.1)
activesupport (= 6.1.1)
actionmailer (6.1.2.1)
actionpack (= 6.1.2.1)
actionview (= 6.1.2.1)
activejob (= 6.1.2.1)
activesupport (= 6.1.2.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.1)
actionview (= 6.1.1)
activesupport (= 6.1.1)
actionpack (6.1.2.1)
actionview (= 6.1.2.1)
activesupport (= 6.1.2.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.1)
actionpack (= 6.1.1)
activerecord (= 6.1.1)
activestorage (= 6.1.1)
activesupport (= 6.1.1)
actiontext (6.1.2.1)
actionpack (= 6.1.2.1)
activerecord (= 6.1.2.1)
activestorage (= 6.1.2.1)
activesupport (= 6.1.2.1)
nokogiri (>= 1.8.5)
actionview (6.1.1)
activesupport (= 6.1.1)
actionview (6.1.2.1)
activesupport (= 6.1.2.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.1)
activesupport (= 6.1.1)
activejob (6.1.2.1)
activesupport (= 6.1.2.1)
globalid (>= 0.3.6)
activemodel (6.1.1)
activesupport (= 6.1.1)
activerecord (6.1.1)
activemodel (= 6.1.1)
activesupport (= 6.1.1)
activestorage (6.1.1)
actionpack (= 6.1.1)
activejob (= 6.1.1)
activerecord (= 6.1.1)
activesupport (= 6.1.1)
activemodel (6.1.2.1)
activesupport (= 6.1.2.1)
activerecord (6.1.2.1)
activemodel (= 6.1.2.1)
activesupport (= 6.1.2.1)
activestorage (6.1.2.1)
actionpack (= 6.1.2.1)
activejob (= 6.1.2.1)
activerecord (= 6.1.2.1)
activesupport (= 6.1.2.1)
marcel (~> 0.3.1)
mimemagic (~> 0.3.2)
activesupport (6.1.1)
activesupport (6.1.2.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -86,7 +86,7 @@ GEM
bcrypt (3.1.16)
benchmark-ips (2.8.4)
bindex (0.8.1)
blazer (2.4.1)
blazer (2.4.2)
activerecord (>= 5)
chartkick (>= 3.2)
railties (>= 5)
@ -108,18 +108,20 @@ GEM
childprocess (3.0.0)
coderay (1.1.3)
colorator (1.1.0)
commonmarker (0.21.1)
commonmarker (0.21.2)
ruby-enum (~> 0.5)
concurrent-ruby (1.1.8)
concurrent-ruby-ext (1.1.8)
concurrent-ruby (= 1.1.8)
crass (1.0.6)
database_cleaner (2.0.0)
database_cleaner (2.0.1)
database_cleaner-active_record (~> 2.0.0)
database_cleaner-active_record (2.0.0)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.0)
database_cleaner-core (2.0.1)
dead_end (1.1.4)
derailed_benchmarks (2.0.0)
derailed_benchmarks (2.0.1)
benchmark-ips (~> 2)
dead_end
get_process_mem (~> 0)
@ -127,6 +129,7 @@ GEM
memory_profiler (>= 0, < 2)
mini_histogram (>= 0.3.0)
rack (>= 1)
rack-test
rake (> 10, < 14)
ruby-statistics (>= 2.1)
thor (>= 0.19, < 2)
@ -287,7 +290,7 @@ GEM
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2)
lockbox (0.6.1)
lockbox (0.6.2)
lograge (0.11.2)
actionpack (>= 4)
activesupport (>= 4)
@ -313,7 +316,7 @@ GEM
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.14.3)
mobility (1.0.5)
mobility (1.1.0)
i18n (>= 0.6.10, < 2)
request_store (~> 1.0)
netaddr (2.0.4)
@ -331,7 +334,7 @@ GEM
popper_js (1.16.0)
prometheus_exporter (0.7.0)
webrick
pry (0.13.1)
pry (0.14.0)
coderay (~> 1.1)
method_source (~> 1.0)
public_suffix (4.0.6)
@ -360,20 +363,20 @@ GEM
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
jekyll-turbolinks (~> 0)
rails (6.1.1)
actioncable (= 6.1.1)
actionmailbox (= 6.1.1)
actionmailer (= 6.1.1)
actionpack (= 6.1.1)
actiontext (= 6.1.1)
actionview (= 6.1.1)
activejob (= 6.1.1)
activemodel (= 6.1.1)
activerecord (= 6.1.1)
activestorage (= 6.1.1)
activesupport (= 6.1.1)
rails (6.1.2.1)
actioncable (= 6.1.2.1)
actionmailbox (= 6.1.2.1)
actionmailer (= 6.1.2.1)
actionpack (= 6.1.2.1)
actiontext (= 6.1.2.1)
actionview (= 6.1.2.1)
activejob (= 6.1.2.1)
activemodel (= 6.1.2.1)
activerecord (= 6.1.2.1)
activestorage (= 6.1.2.1)
activesupport (= 6.1.2.1)
bundler (>= 1.15.0)
railties (= 6.1.1)
railties (= 6.1.2.1)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
@ -385,9 +388,9 @@ GEM
railties (>= 6.0.0, < 7)
rails_warden (0.6.0)
warden (>= 1.2.0)
railties (6.1.1)
actionpack (= 6.1.1)
activesupport (= 6.1.1)
railties (6.1.2.1)
actionpack (= 6.1.2.1)
activesupport (= 6.1.2.1)
method_source
rake (>= 0.8.7)
thor (~> 1.0)
@ -457,7 +460,7 @@ GEM
i18n
ruby-filemagic (0.7.2)
ruby-progressbar (1.11.0)
ruby-statistics (2.1.2)
ruby-statistics (2.1.3)
ruby-vips (2.0.17)
ffi (~> 1.9)
ruby_dep (1.5.0)
@ -537,7 +540,7 @@ GEM
unicode-display_width (~> 1.1, >= 1.1.1)
thor (1.1.0)
tilt (2.0.10)
timecop (0.9.2)
timecop (0.9.4)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
@ -583,6 +586,7 @@ DEPENDENCIES
brakeman
capybara (~> 2.13)
commonmarker
concurrent-ruby-ext
database_cleaner
derailed_benchmarks
devise
@ -660,7 +664,7 @@ DEPENDENCIES
yaml_db!
RUBY VERSION
ruby 2.7.2p137
ruby 2.7.1p83
BUNDLED WITH
2.1.4

View file

@ -9,10 +9,10 @@ assets := package.json yarn.lock $(shell find app/assets/ app/javascript/ -type
alpine_version := 3.12
public/packs/manifest.json: $(assets)
public/packs/manifest.json.br: $(assets)
PANEL_URL=https://panel.sutty.nl RAILS_ENV=production NODE_ENV=production bundle exec rake assets:precompile assets:clean
assets: public/packs/manifest.json
assets: public/packs/manifest.json.br
serve: /etc/hosts
bundle exec rails s -b "ssl://0.0.0.0:3000?key=../sutty.local/domain/$(SUTTY).key&cert=../sutty.local/domain/$(SUTTY).crt"
@ -82,7 +82,7 @@ $(dirs):
/etc/hosts: always
@echo "Chequeando si es necesario agregar el dominio local $(SUTTY)"
@grep -q " $(SUTTY)$$" $@ || echo -e "127.0.0.1 $(SUTTY)\n::1 $(SUTTY)" | sudo tee -a $@
@grep -q " api.$(SUTTY)$$" $@ || echo -e "127.0.0.1 api.$(SUTTY)\n::1 $(SUTTY)" | sudo tee -a $@
@grep -q " panel.$(SUTTY)$$" $@ || echo -e "127.0.0.1 panel.$(SUTTY)\n::1 $(SUTTY)" | sudo tee -a $@
@grep -q " api.$(SUTTY)$$" $@ || echo -e "127.0.0.1 api.$(SUTTY)\n::1 api.$(SUTTY)" | sudo tee -a $@
@grep -q " panel.$(SUTTY)$$" $@ || echo -e "127.0.0.1 panel.$(SUTTY)\n::1 panel.$(SUTTY)" | sudo tee -a $@
.PHONY: always

View file

@ -4,7 +4,7 @@
class ApplicationController < ActionController::Base
include ExceptionHandler
protect_from_forgery with: :exception
protect_from_forgery with: :exception, prepend: true
before_action :prepare_exception_notifier
before_action :configure_permitted_parameters, if: :devise_controller?

View file

@ -73,7 +73,7 @@ module ApplicationHelper
# Opciones por defecto para el campo de un formulario
def field_options(attribute, metadata, **extra)
required = metadata.required || extra[:required]
required = extra.key?(:required) ? extra[:required] : metadata.required
{
class: "form-control #{invalid(metadata.post, attribute)} #{extra[:class]}",

View file

@ -107,4 +107,13 @@ document.addEventListener('turbolinks:load', () => {
// Ocultar el area
textArea.style.display = 'none'
})
// Validar fechas en navegadores que no soportan date
document.querySelectorAll('input[type="date"]').forEach(date => {
if (date.type === 'date') return
date.addEventListener('change', event => {
date.setCustomValidity(date.validity.patternMismatch ? date.dataset.patternMismatch : '')
})
})
})

View file

@ -22,15 +22,13 @@ class MaintenanceJob < ApplicationJob
# XXX: Parece que [0] es más rápido que []#first
Usuarie.all.pluck(:email, :lang).each do |u|
begin
MaintenanceMailer.with(maintenance: maintenance,
email: u[0],
lang: u[1]).public_send(mailer).deliver_now
rescue Net::SMTPServerBusy => e
# Algunas direcciones no son válidas, no queremos detener el
# envío pero sí enterarnos cuáles son
ExceptionNotifier.notify_exception e
end
MaintenanceMailer.with(maintenance: maintenance,
email: u[0],
lang: u[1]).public_send(mailer).deliver_now
rescue Net::SMTPServerBusy => e
# Algunas direcciones no son válidas, no queremos detener el
# envío pero sí enterarnos cuáles son
ExceptionNotifier.notify_exception(e, data: { maintenance_id: maintenance_id, email: u[0] })
end
end
end

View file

@ -9,6 +9,10 @@ Layout = Struct.new(:site, :name, :meta, :metadata, keyword_init: true) do
name.to_s
end
def attributes
@attributes ||= metadata.keys.map(&:to_sym)
end
# Busca la traducción del Layout en el sitio o intenta humanizarlo
# según Rails.
#

View file

@ -7,15 +7,22 @@ class MetadataArray < MetadataTemplate
super || []
end
# Los Arrays no se pueden cifrar todavía
# TODO: Cifrar y decifrar arrays
def private?
false
end
private
# TODO: Sanitizar otros valores
# XXX: Por qué eliminamos el punto del final?
def sanitize(values)
values.map do |v|
if v.is_a? String
super(v).sub(/\.\z/, '')
else
v
case v
when String then super(v).sub(/\.\z/, '')
else v
end
end
end.select(&:present?)
end
end

View file

@ -3,6 +3,13 @@
# Almacena el UUID de otro Post y actualiza el valor en el Post
# relacionado.
class MetadataBelongsTo < MetadataRelatedPosts
def value_was=(new_value)
@belongs_to = nil
@belonged_to = nil
super(new_value)
end
# TODO: Convertir algunos tipos de valores en módulos para poder
# implementar varios tipos de campo sin repetir código
#
@ -23,36 +30,36 @@ class MetadataBelongsTo < MetadataRelatedPosts
# Guardar y guardar la relación inversa también, eliminando la
# relación anterior si existía.
#
# XXX: Esto es un poco enclenque, porque habría que guardar tres
# archivos en lugar de uno solo e indicarle al artículo que tiene uno
# o muchos que busque los datos actualizados filtrando. Pero también
# nos ahorra recursos en la búsqueda al cachear la información. En
# una relación HABTM también vamos a hacer lo mismo.
def save
return super unless inverse? && !included?
super
# Evitar que se cambie el orden de la relación
belonged_to&.dig(inverse)&.value&.delete post.uuid.value if belonged_to != belongs_to
# Si no hay relación inversa, no hacer nada más
return true unless changed?
return true unless inverse?
belongs_to[inverse].value << post.uuid.value unless belongs_to[inverse].value.include? post.uuid.value
# Si estamos cambiando la relación, tenemos que eliminar la relación
# anterior
belonged_to[inverse].value.delete post.uuid.value if changed? && belonged_to.present?
# No duplicar las relaciones
belongs_to[inverse].value << 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
belongs_to[inverse].value.include?(post.uuid.value)
end
# Hay una relación inversa y el artículo existe?
def inverse?
inverse.present? && belongs_to.present?
inverse.present?
end
# El campo que es la relación inversa de este
def inverse
layout.metadata.dig name, 'inverse'
@inverse ||= layout.metadata.dig(name, 'inverse')&.to_sym
end
# El Post relacionado con este artículo
@ -62,15 +69,14 @@ class MetadataBelongsTo < MetadataRelatedPosts
def belongs_to
return if value.blank?
@belongs_to ||= {}
@belongs_to[value] ||= posts.find(value, uuid: true)
@belongs_to ||= posts.find(value, uuid: true)
end
# El anterior artículo relacionado
# El artículo relacionado anterior
def belonged_to
return if document.data[name.to_s].blank?
return if value_was.blank?
@belonged_to ||= posts.find(document.data[name.to_s], uuid: true)
@belonged_to ||= posts.find(value_was, uuid: true)
end
def related_posts?
@ -83,11 +89,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
private
def sanitize(uuid)
uuid.gsub(/[^a-f0-9\-]/, '')
def post_exists?
return true if sanitize(value).blank?
sanitize(value).present? && belongs_to.present?
end
def post_exists?
!value.blank? && posts.find(sanitize(value), uuid: true)
def sanitize(uuid)
uuid.to_s.gsub(/[^a-f0-9\-]/i, '')
end
end

View file

@ -7,10 +7,14 @@ class MetadataDocumentDate < MetadataTemplate
Date.today.to_time
end
def value_from_document
document.date
end
# El valor puede ser un Date, Time o una String en el formato
# "yyyy-mm-dd"
def value
return (self[:value] = document.date || default_value) if self[:value].nil?
return (self[:value] = value_from_document || default_value) if self[:value].nil?
self[:value] = Date.iso8601(self[:value]).to_time if self[:value].is_a? String

View file

@ -2,9 +2,14 @@
# Devuelve metadatos de cierto tipo
class MetadataFactory
def self.build(**args)
@@factory_cache ||= {}
@@factory_cache[args[:type]] ||= ('Metadata' + args[:type].to_s.camelcase).constantize
@@factory_cache[args[:type]].new(args)
class << self
def build(**args)
classify(args[:type]).new(**args)
end
def classify(type)
@factory_cache ||= {}
@factory_cache[type] ||= ('Metadata' + type.to_s.camelcase).constantize
end
end
end

View file

@ -73,10 +73,14 @@ class MetadataFile < MetadataTemplate
end
end
def key_from_path
path.dirname.basename.to_s
end
private
def path?
!value['path'].blank?
value['path'].present?
end
def filemagic
@ -100,10 +104,6 @@ class MetadataFile < MetadataTemplate
end
end
def key_from_path
path.dirname.basename.to_s
end
# Hacemos un link duro para colocar el archivo dentro del repositorio
# y no duplicar el espacio que ocupan. Esto requiere que ambos
# directorios estén dentro del mismo punto de montaje.

View file

@ -10,40 +10,7 @@
# 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 < MetadataBelongsTo
def default_value
[]
end
# Posts a los que pertenece. Memoizamos por value para obtener
# siempre la última relación.
#
# Buscamos todos los Post contenidos en el valor actual. No
# garantizamos el orden.
#
# @return [PostRelation] Posts
def belongs_to
@belongs_to ||= {}
@belongs_to[value.hash.to_s] ||= posts.where(uuid: value)
end
# Devuelve la lista de Posts relacionados con este buscándolos en la
# relación inversa. #save debería mantenerlos sincronizados.
#
# @return [PostRelation]
def has_many
@has_many ||= {}
@has_many[value.hash.to_s] ||= posts.where(inverse.to_sym => post.uuid.value)
end
alias had_many has_many
# Posts a los que pertenecía
#
# @return [PostRelation] Posts
def belonged_to
@belonged_to ||= posts.where(uuid: document.data.fetch(name.to_s, []))
end
class MetadataHasAndBelongsToMany < MetadataHasMany
# Mantiene la relación inversa si existe.
#
# La relación belongs_to se mantiene actualizada en la modificación
@ -52,39 +19,33 @@ class MetadataHasAndBelongsToMany < MetadataBelongsTo
# 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
return true unless changed?
# XXX: No usamos super
self[:value] = sanitize value
return true unless inverse? && !included?
return true unless changed?
return true unless inverse?
(belonged_to - belongs_to).each do |p|
p[inverse].value.delete post.uuid.value
(had_many - has_many).each do |remove|
remove[inverse]&.value&.delete post.uuid.value
end
(belongs_to - belonged_to).each do |p|
p[inverse].value << post.uuid.value
(has_many - had_many).each do |add|
next unless add[inverse]
next if add[inverse].value.include? post.uuid.value
add[inverse].value << post.uuid.value
end
true
end
def sanitize(sanitizable)
sanitizable.map do |v|
super v
end
end
private
def post_exists?
return true if empty? && can_be_empty?
!belongs_to.empty?
end
# Todos los artículos relacionados incluyen a este?
def included?
belongs_to.map do |p|
p[inverse].value.include? post.uuid.value
end.all?
# Igual que en MetadataRelatedPosts
# TODO: Mover a un módulo
def sanitize(uuid)
super(uuid.map do |u|
u.to_s.gsub(/[^a-f0-9\-]/i, '')
end)
end
end

View file

@ -6,22 +6,32 @@
# 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 según la relación remota
def has_many_remote
@has_many_remote ||= posts.where(inverse => post.uuid.value)
# Invalidar la relación anterior
def value_was=(new_value)
@had_many = nil
@has_many = nil
super(new_value)
end
def validate
super
errors << I18n.t('metadata.has_many.missing_posts') unless posts_exist?
errors.empty?
end
# Todos los Post relacionados
def has_many
@has_many ||= {}
@has_many[value.hash.to_s] ||= posts.where(uuid: value)
@has_many ||= posts.where(uuid: value)
end
# La relación anterior
def had_many
return [] if document.data[name.to_s].blank?
return [] if value_was.blank?
@had_many ||= posts.where(uuid: document.data[name.to_s])
@had_many ||= posts.where(uuid: value_was)
end
def inverse?
@ -38,18 +48,17 @@ class MetadataHasMany < MetadataRelatedPosts
# Actualizar las relaciones inversas. Hay que buscar la diferencia
# entre had y has_many.
def save
super
return true unless changed?
self[:value] = sanitize value
return true unless inverse?
(had_many - has_many).each do |remove|
remove[inverse].value = remove[inverse].default_value
remove[inverse]&.value = remove[inverse].default_value
end
(has_many - had_many).each do |add|
add[inverse].value = post.uuid.value
add[inverse]&.value = post.uuid.value
end
true
@ -63,11 +72,7 @@ class MetadataHasMany < MetadataRelatedPosts
@related_methods ||= %i[has_many had_many].freeze
end
private
def sanitize(sanitizable)
sanitizable.map do |uuid|
uuid.gsub(/[^a-f0-9\-]/, '')
end
def posts_exist?
has_many.size == sanitize(value).size
end
end

View file

@ -6,8 +6,12 @@ class MetadataLang < MetadataTemplate
super || I18n.locale
end
def value_from_document
document.collection.label
end
def value
self[:value] ||= document.collection.label || default_value
self[:value] ||= value_from_document || default_value
end
def values

View file

@ -7,8 +7,12 @@ class MetadataPath < MetadataTemplate
File.join(site.path, "_#{lang}", "#{date}-#{slug}#{ext}")
end
# El valor no vuelve desde el documento
def value_from_document
document.path
end
def value
@value_was ||= default_value
self[:value] = default_value
end
alias absolute value

View file

@ -31,4 +31,10 @@ class MetadataRelatedPosts < MetadataArray
def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
end
def sanitize(uuid)
super(uuid.map do |u|
u.to_s.gsub(/[^a-f0-9\-]/i, '')
end)
end
end

View file

@ -25,11 +25,7 @@ require 'jekyll/utils'
class MetadataSlug < MetadataTemplate
# Trae el slug desde el título si existe o una string al azar
def default_value
if title
Jekyll::Utils.slugify(title)
else
SecureRandom.hex
end
title ? Jekyll::Utils.slugify(title) : SecureRandom.uuid
end
def value
@ -40,6 +36,9 @@ class MetadataSlug < MetadataTemplate
# Devuelve el título a menos que sea privado y no esté vacío
def title
post.title&.value&.to_s unless post.title.private? && !post.title&.value&.blank?
return if post.title&.private?
return if post.title&.value&.blank?
post.title&.value&.to_s
end
end

View file

@ -7,8 +7,6 @@
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
:value, :help, :required, :errors, :post,
:layout, keyword_init: true) do
attr_reader :value_was
# Queremos que los artículos nuevos siempre cacheen, si usamos el UUID
# siempre vamos a obtener un item nuevo.
def cache_key
@ -25,13 +23,26 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
cache_key + '-' + cache_version
end
# XXX: Deberíamos sanitizar durante la asignación?
def value=(new_value)
@value_was = value
self[:value] = new_value
end
# Siempre obtener el valor actual y solo obtenerlo del documento una
# vez.
def value_was
return @value_was if instance_variable_defined? '@value_was'
@value_was = value_from_document
end
def value_from_document
@value_from_document ||= document.data[name.to_s]
end
def changed?
!value_was.nil? && value_was != value
value_was != value
end
# Obtiene el valor del JekyllDocument
@ -59,7 +70,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# Valor actual o por defecto. Al memoizarlo podemos modificarlo
# usando otros métodos que el de asignación.
def value
self[:value] ||= if (data = document.data[name.to_s]).present?
self[:value] ||= if (data = value_from_document).present?
private? ? decrypt(data) : data
else
default_value
@ -112,6 +123,8 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# En caso de que algún campo necesite realizar acciones antes de ser
# guardado
def save
return true unless changed?
self[:value] = sanitize value
self[:value] = encrypt(value) if private?
@ -123,12 +136,12 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
end
def related_methods
raise NotImplementedError
@related_methods ||= [].freeze
end
# Determina si el campo es privado y debería ser cifrado
def private?
!!layout.metadata.dig(name, 'private')
layout.metadata.dig(name, 'private').present?
end
private
@ -172,7 +185,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
box.decrypt_str value.to_s
rescue Lockbox::DecryptionError => e
ExceptionNotifier.notify_exception(e)
ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.path.absolute, name: name })
I18n.t('lockbox.help.decryption_error')
end

View file

@ -3,9 +3,11 @@
# Esta clase representa un post en un sitio jekyll e incluye métodos
# para modificarlos y crear nuevos.
#
# rubocop:disable Metrics/ClassLength
# rubocop:disable Style/MissingRespondToMissing
class Post < OpenStruct
# * Los metadatos se tienen que cargar dinámicamente, solo usamos los
# que necesitamos
#
#
class Post
# Atributos por defecto
DEFAULT_ATTRIBUTES = %i[site document layout].freeze
# Otros atributos que no vienen en los metadatos
@ -13,6 +15,8 @@ class Post < OpenStruct
PUBLIC_ATTRIBUTES = %i[lang date uuid].freeze
ATTR_SUFFIXES = %w[? =].freeze
attr_reader :attributes, :errors, :layout, :site, :document
class << self
# Obtiene el layout sin leer el Document
#
@ -31,41 +35,20 @@ class Post < OpenStruct
#
def initialize(**args)
default_attributes_missing(**args)
super(**args)
# Genera un método con todos los atributos disponibles
self.attributes = layout.metadata.keys.map(&:to_sym) + PUBLIC_ATTRIBUTES
self.errors = {}
@layout = args[:layout]
@site = args[:site]
@document = args[:document]
@attributes = layout.attributes + PUBLIC_ATTRIBUTES
@errors = {}
@metadata = {}
# Genera un atributo por cada uno de los campos de la plantilla,
# MetadataFactory devuelve un tipo de campo por cada campo. A
# partir de ahí se pueden obtener los valores actuales y una lista
# de valores por defecto.
#
# XXX: En el primer intento de hacerlo más óptimo, movimos esta
# lógica a instanciación bajo demanda, pero no solo no logramos
# optimizar sino que aumentamos el tiempo de carga :/
layout.metadata.each_pair do |name, template|
send "#{name}=".to_sym,
MetadataFactory.build(document: document,
post: self,
site: site,
name: name.to_sym,
value: args[name.to_sym],
layout: layout,
type: template['type'],
label: template['label'],
help: template['help'],
required: template['required'])
# Inicializar valores
attributes.each do |attr|
public_send(attr)&.value = args[attr] if args.key?(attr)
end
# TODO: Llamar dinámicamente
load_lang!
load_slug!
load_date!
load_path!
load_uuid!
# XXX: No usamos Post#read porque a esta altura todavía no sabemos
# nada del Document
document.read! if File.exist? document.path
@ -108,7 +91,8 @@ class Post < OpenStruct
html.css('img').each do |img|
next if %r{\Ahttps?://} =~ img.attributes['src']
img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site, file: img.attributes['src'].value)
img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site,
file: img.attributes['src'].value)
end
# Notificar a les usuaries que están viendo una previsualización
@ -152,29 +136,65 @@ class Post < OpenStruct
@modified_at ||= Time.now
end
# Solo ejecuta la magia de OpenStruct si el campo existe en la
# plantilla
#
# XXX: Reemplazarlo por nuestro propio método, mantener todo lo demás
# compatible con OpenStruct
#
# XXX: rubocop dice que tenemos que usar super cuando ya lo estamos
# usando...
def method_missing(mid, *args)
def [](attr)
public_send attr
end
# Define metadatos a demanda
def method_missing(name, *_args)
# Limpiar el nombre del atributo, para que todos los ayudantes
# reciban el método en limpio
name = attribute_name mid
unless attribute? name
raise NoMethodError, I18n.t('exceptions.post.no_method',
method: mid)
method: name)
end
# OpenStruct
super(mid, *args)
define_singleton_method(name) do
template = layout.metadata[name.to_s]
# Devolver lo mismo que devuelve el método después de definirlo
send(mid, *args)
@metadata[name] ||=
MetadataFactory.build(document: document,
post: self,
site: site,
name: name,
layout: layout,
type: template['type'],
label: template['label'],
help: template['help'],
required: template['required'])
end
public_send name
end
# TODO: Mover a method_missing
def slug
@metadata[:slug] ||= MetadataSlug.new(document: document, site: site, layout: layout, name: :slug, type: :slug,
post: self, required: true)
end
# TODO: Mover a method_missing
def date
@metadata[:date] ||= MetadataDocumentDate.new(document: document, site: site, layout: layout, name: :date,
type: :document_date, post: self, required: true)
end
# TODO: Mover a method_missing
def path
@metadata[:path] ||= MetadataPath.new(document: document, site: site, layout: layout, name: :path, type: :path,
post: self, required: true)
end
# TODO: Mover a method_missing
def lang
@metadata[:lang] ||= MetadataLang.new(document: document, site: site, layout: layout, name: :lang, type: :lang,
post: self, required: true)
end
# TODO: Mover a method_missing
def uuid
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
post: self, required: true)
end
# Detecta si es un atributo válido o no, a partir de la tabla de la
@ -189,11 +209,14 @@ class Post < OpenStruct
included
end
# Devuelve los strong params para el layout
# Devuelve los strong params para el layout.
#
# XXX: Nos gustaría no tener que instanciar Metadata acá, pero depende
# del valor por defecto que a su vez depende de Layout.
def params
attributes.map do |attr|
send(attr).to_param
end
public_send(attr)&.to_param
end.compact
end
# Genera el post con metadatos en YAML
@ -201,8 +224,8 @@ class Post < OpenStruct
# TODO: Cachear por un minuto
def full_content
body = ''
yaml = layout.metadata.keys.map(&:to_sym).map do |metadata|
template = send(metadata)
yaml = layout.attributes.map do |attr|
template = public_send attr
unless template.front_matter?
body += "\n\n"
@ -212,7 +235,7 @@ class Post < OpenStruct
next if template.empty?
[metadata.to_s, template.value]
[attr.to_s, template.value]
end.compact.to_h
# TODO: Convertir a Metadata?
@ -281,7 +304,7 @@ class Post < OpenStruct
# Detecta si el artículo es válido para guardar
def valid?
self.errors = {}
@errors = {}
layout.metadata.keys.map(&:to_sym).each do |metadata|
template = send(metadata)
@ -339,9 +362,7 @@ class Post < OpenStruct
# Levanta un error si al construir el artículo no pasamos un atributo.
def default_attributes_missing(**args)
DEFAULT_ATTRIBUTES.each do |attr|
i18n = I18n.t("exceptions.post.#{attr}_missing")
raise ArgumentError, i18n unless args[attr].present?
raise ArgumentError, I18n.t("exceptions.post.#{attr}_missing") unless args[attr].present?
end
end
@ -349,57 +370,11 @@ class Post < OpenStruct
document.data.fetch('usuaries', [])
end
# Obtiene el nombre del atributo a partir del nombre del método
def attribute_name(attr)
# XXX: Los simbolos van al final
@attribute_name_cache ||= {}
@attribute_name_cache[attr] ||= ATTR_SUFFIXES.reduce(attr.to_s) do |a, suffix|
a.chomp suffix
end.to_sym
end
def load_slug!
self.slug = MetadataSlug.new(document: document, site: site,
layout: layout, name: :slug, type: :slug,
post: self,
required: true)
end
def load_date!
self.date = MetadataDocumentDate.new(document: document, site: site,
layout: layout, name: :date,
type: :document_date,
post: self,
required: true)
end
def load_path!
self.path = MetadataPath.new(document: document, site: site,
layout: layout, name: :path,
type: :path, post: self,
required: true)
end
def load_lang!
self.lang = MetadataLang.new(document: document, site: site,
layout: layout, name: :lang,
type: :lang, post: self,
required: true)
end
def load_uuid!
self.uuid = MetadataUuid.new(document: document, site: site,
layout: layout, name: :uuid,
type: :uuid, post: self,
required: true)
end
# Ejecuta la acción de guardado en cada atributo
# TODO: Solo guardar los que se modificaron
def save_attributes!
attributes.map do |attr|
send(attr).save
public_send(attr).save
end.all?
end
end
# rubocop:enable Metrics/ClassLength
# rubocop:enable Style/MissingRespondToMissing

View file

@ -4,10 +4,11 @@
# artículos como si estuviésemos usando ActiveRecord.
class PostRelation < Array
# No necesitamos cambiar el sitio
attr_reader :site
attr_reader :site, :lang
def initialize(site:)
def initialize(site:, lang:)
@site = site
@lang = lang
# Proseguimos la inicialización sin valores por defecto
super()
end
@ -15,7 +16,7 @@ class PostRelation < Array
# Genera un artículo nuevo con los parámetros que le pasemos y lo suma
# al array
def build(**args)
args[:lang] ||= I18n.locale
args[:lang] = lang
args[:document] ||= build_document(collection: args[:lang])
args[:layout] = build_layout(args[:layout])
@ -94,7 +95,7 @@ class PostRelation < Array
@where ||= {}
@where[args.hash.to_s] ||= begin
PostRelation.new(site: site).concat(select do |post|
PostRelation.new(site: site, lang: lang).concat(select do |post|
result = args.map do |attr, value|
next unless post.attribute?(attr)
@ -123,7 +124,7 @@ class PostRelation < Array
# @return [PostRelation]
alias array_select select
def select(&block)
PostRelation.new(site: site).concat array_select(&block)
PostRelation.new(site: site, lang: lang).concat array_select(&block)
end
# Intenta guardar todos y devuelve true si pudo

View file

@ -225,6 +225,7 @@ class Site < ApplicationRecord
# Traemos los posts del idioma actual por defecto
lang ||= I18n.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
@ -233,7 +234,7 @@ class Site < ApplicationRecord
return @posts[lang] unless @posts[lang].blank?
@posts[lang] = PostRelation.new site: self
@posts[lang] = PostRelation.new site: self, lang: lang
# No fallar si no existe colección para este idioma
# XXX: queremos fallar silenciosamente?
@ -250,7 +251,7 @@ class Site < ApplicationRecord
#
# @return PostRelation
def docs
@docs ||= PostRelation.new(site: self).push(locales.flat_map do |locale|
@docs ||= PostRelation.new(site: self, lang: :docs).push(locales.flat_map do |locale|
posts(lang: locale)
end).flatten!
end

View file

@ -42,6 +42,7 @@ class Site
metadata = doc.public_send(field)
next if metadata.value['path'].blank?
next if ActiveStorage::Blob.find_by(key: metadata.key_from_path)
path = Pathname.new(metadata.value['path'])

View file

@ -36,6 +36,8 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie
# Es importante que el artículo se guarde primero y luego los
# relacionados.
commit(action: :updated, file: update_related_posts) if post.update(post_params)
# Devolver el post aunque no se haya salvado para poder rescatar los
@ -111,23 +113,24 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# 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
files = [post.path.absolute]
posts = Set.new
post.attributes.each do |a|
next unless post[a].related_posts?
post[a].related_methods.each do |m|
next unless post[a].respond_to? m
# La respuesta puede ser una PostRelation también
[post[a].public_send(m)].flatten.compact.uniq.each do |p|
files << p.path.absolute if p.save(validate: false)
end
posts.merge [post[a].public_send(m)].flatten.compact
end
end
files
posts.map do |p|
p.path.absolute if p.save(validate: false)
end.compact << post.path.absolute
end
end

View file

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

View file

@ -1,5 +1,6 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= hidden_field_tag "#{base}[#{attribute}][]", ''
.taggable{ dir: dir, lang: locale, data: { values: metadata.value.to_json,
name: "#{base}[#{attribute}][]", list: id_for_datalist(attribute),

View file

@ -1,13 +1,7 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= text_field base, attribute, value: metadata.value,
dir: dir, lang: locale, list: id_for_datalist(attribute),
pattern: metadata.values.values.join('|'), autocomplete: 'off',
**field_options(attribute, metadata)
= select_tag(plain_field_name_for(base, attribute),
options_for_select(metadata.values, metadata.value),
**field_options(attribute, metadata), include_blank: true)
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
-# TODO: Ocultar el UUID
%datalist{ id: id_for_datalist(attribute) }
- metadata.values.each_pair do |key, value|
%option{ value: value }= key

View file

@ -1,6 +1,7 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= date_field base, attribute, value: metadata.value.to_date.strftime('%F'),
**field_options(attribute, metadata)
**field_options(attribute, metadata), pattern: '\d{4}-\d{2}-\d{2}',
data: { 'pattern-mismatch': t('metadata.date.invalid_format') }
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata

View file

@ -1,6 +1,7 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= date_field base, attribute, value: metadata.value.strftime('%F'),
**field_options(attribute, metadata)
**field_options(attribute, metadata), pattern: '\d{4}-\d{2}-\d{2}',
data: { 'pattern-mismatch': t('metadata.date.invalid_format') }
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata

View file

@ -34,6 +34,6 @@
= text_field(*field_name_for(base, attribute, :description),
value: metadata.value['description'],
dir: dir, lang: locale,
**field_options(attribute, metadata))
**field_options(attribute, metadata, required: false))
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :description], metadata: metadata

View file

@ -1,5 +1,6 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= hidden_field_tag "#{base}[#{attribute}][]", ''
.mapable{ dir: dir, lang: locale,
data: { values: metadata.value.to_json,

View file

@ -1,5 +1,6 @@
.form-group
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
= hidden_field_tag "#{base}[#{attribute}][]", ''
.mapable{ dir: dir, lang: locale,
data: { values: metadata.value.to_json,

View file

@ -32,7 +32,7 @@
= text_field(*field_name_for(base, attribute, :description),
value: metadata.value['description'],
dir: dir, lang: locale,
**field_options(attribute, metadata))
**field_options(attribute, metadata, required: false))
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :description], metadata: metadata

View file

@ -48,6 +48,10 @@ en:
end_in_the_past: "Event end can't happen before the start"
belongs_to:
missing_post: "Couldn't find the related post"
has_many:
missing_posts: "Couldn't find some related posts"
date:
invalid_format: "It seems that your browser doesn't support dates and the date is on the incorrect format, please use yyyy-mm-dd, ie. 2021-01-31"
exceptions:
post:
site_missing: 'Needs an instance of Site'
@ -460,7 +464,7 @@ en:
label: Language
date:
label: Date
help: Publication date for this post. If you use a date in the future the post won't be published until then.
help: Date for this post. If you use a date in the future the post won't be published until you publish changes on that day.
required:
label: ' (required)'
feedback: 'This field cannot be empty!'

View file

@ -48,6 +48,10 @@ es:
end_in_the_past: 'El fin del evento no puede ser anterior al comienzo'
belongs_to:
missing_post: 'No se pudo encontrar el artículo relacionado'
has_many:
missing_posts: 'No se pudieron encontrar algunos artículos relacionados'
date:
invalid_format: 'Parece que tu navegador no soporta fechas y la fecha no está en el formato correcto, por favor usa aaaa-mm-dd, por ejemplo: 2021-01-31'
exceptions:
post:
site_missing: 'Necesita una instancia de Site'
@ -469,7 +473,7 @@ es:
label: Idioma
date:
label: Fecha
help: La fecha de publicación del artículo. Si colocas una fecha en el futuro no se publicará hasta ese día.
help: La fecha del artículo. Si colocas una fecha en el futuro no se publicará hasta que publiques cambios ese día.
required:
label: ' (requerido)'
feedback: '¡Este campo no puede estar vacío!'

View file

@ -143,21 +143,26 @@ class PostsControllerTest < ActionDispatch::IntegrationTest
end
test 'se pueden reordenar' do
lang = I18n.available_locales.sample
posts = @site.posts(lang: lang)
lang = { lang: @site.locales.sample }
(rand * 10).round.times do
@site.posts(**lang).create title: SecureRandom.hex, description: SecureRandom.hex
end
posts = @site.posts(**lang)
reorder = Hash[posts.map { |p| p.uuid.value }.shuffle.each_with_index.to_a]
post site_posts_reorder_url(@site),
headers: @authorization,
params: { post: { lang: lang, reorder: reorder } }
params: { post: { lang: lang[:lang], reorder: reorder } }
@site = Site.find @site.id
assert_equal I18n.t('post_service.reorder'),
@site.repository.rugged.head.target.message
assert_equal reorder,
Hash[@site.posts(lang: lang).map do |p|
Hash[@site.posts(**lang).map do |p|
[p.uuid.value, p.order.value]
end]
assert_equal I18n.t('post_service.reorder'),
@site.repository.rugged.head.target.message
end
end

View file

@ -95,11 +95,6 @@ class PostTest < ActiveSupport::TestCase
end
end
test 'attribute_name' do
assert_equal :hola, @post.send(:attribute_name, :hola)
assert_equal :hola, @post.send(:attribute_name, :hola?)
end
test 'se puede cambiar el slug' do
@post.title.value = SecureRandom.hex
assert_not @post.slug.changed?