5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-25 04:56:23 +00:00

Merge branch 'rails' of 0xacab.org:sutty/sutty into deploy-rsync

This commit is contained in:
f 2022-12-28 15:45:12 -03:00
commit b0848dbf40
31 changed files with 383 additions and 541 deletions

View file

@ -67,7 +67,11 @@
.editor-content {
min-height: 480px;
p, h1, h2, h3, h4, h5, h6, ul, li, figcaption { outline: #ccc solid thin; }
p, h1, h2, h3, h4, h5, h6, ul, li, blockquote, figcaption { outline: #ccc solid thin; }
blockquote {
border-left: #555 solid .25em;
padding: .75em;
}
strong, em, del, u, sub, sup, small { background: #0002; }
a { background: #13fefe50; }
[data-editor-selected] { outline: #f206f9 solid thick; }

View file

@ -0,0 +1,29 @@
# frozen_string_literal: true
module ActiveStorage
# Modifica la creación de un blob antes de subir el archivo para que
# incluya el JekyllService adecuado.
module DirectUploadsControllerDecorator
extend ActiveSupport::Concern
included do
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(service_name: session[:service_name], **blob_args)
render json: direct_upload_json(blob)
end
private
# Normalizar los caracteres unicode en los nombres de archivos
# para que puedan propagarse correctamente a través de todo el
# stack.
def blob_args
params.require(:blob).permit(:filename, :byte_size, :checksum, :content_type, metadata: {}).to_h.symbolize_keys.tap do |ba|
ba[:filename] = ba[:filename].unicode_normalize
end
end
end
end
end
ActiveStorage::DirectUploadsController.include ActiveStorage::DirectUploadsControllerDecorator

View file

@ -0,0 +1,33 @@
# frozen_string_literal: true
module ActiveStorage
# Modificar {DiskController} para poder asociar el blob a un sitio
module DiskControllerDecorator
extend ActiveSupport::Concern
included do
# Asociar el archivo subido al sitio correspondiente. Cada sitio
# tiene su propio servicio de subida de archivos.
def update
if (token = decode_verified_token)
if acceptable_content?(token)
named_disk_service(token[:service_name]).upload token[:key], request.body, checksum: token[:checksum]
blob = ActiveStorage::Blob.find_by_key token[:key]
site = Site.find_by_name token[:service_name]
site.static_files.attach(blob)
else
head :unprocessable_entity
end
else
head :not_found
end
rescue ActiveStorage::IntegrityError
head :unprocessable_entity
end
end
end
end
ActiveStorage::DiskController.include ActiveStorage::DiskControllerDecorator

View file

@ -6,19 +6,22 @@ module Api
class CspReportsController < BaseController
skip_forgery_protection
# No queremos indicar que algo salió mal
rescue_from ActionController::ParameterMissing, with: :csp_report_created
# Crea un reporte de CSP intercambiando los guiones medios por
# bajos
#
# TODO: Aplicar rate_limit
def create
csp = CspReport.new(csp_report_params.to_h.map do |k, v|
[k.tr('-', '_'), v]
end.to_h)
csp = CspReport.new(csp_report_params.to_h.transform_keys do |k|
k.tr('-', '_')
end)
csp.id = SecureRandom.uuid
csp.save
render json: {}, status: :created
csp_report_created
end
private
@ -39,6 +42,10 @@ module Api
:'column-number',
:'source-file')
end
def csp_report_created
render json: {}, status: :created
end
end
end
end

View file

@ -6,6 +6,7 @@ class PostsController < ApplicationController
rescue_from Pundit::NilPolicyError, with: :page_not_found
before_action :authenticate_usuarie!
before_action :service_for_direct_upload, only: %i[new edit]
# TODO: Traer los comunes desde ApplicationController
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
@ -166,4 +167,9 @@ class PostsController < ApplicationController
def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
end
# Recuerda el nombre del servicio de subida de archivos
def service_for_direct_upload
session[:service_name] = site.name.to_sym
end
end

View file

@ -21,11 +21,12 @@ function makeBlock(tag: string): EditorBlock {
}
export const li: EditorBlock = makeBlock("li");
const paragraph: EditorBlock = makeBlock("p");
// XXX: si agregás algo acá, agregalo a blockNames
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
export const blocks: { [propName: string]: EditorBlock } = {
paragraph: makeBlock("p"),
paragraph,
h1: makeBlock("h1"),
h2: makeBlock("h2"),
h3: makeBlock("h3"),
@ -42,6 +43,11 @@ export const blocks: { [propName: string]: EditorBlock } = {
allowedChildren: ["li"],
handleEmpty: li,
},
blockquote: {
...makeBlock("blockquote"),
allowedChildren: blockNames,
handleEmpty: paragraph,
},
};
export function setupButtons(editor: Editor): void {

View file

@ -137,8 +137,10 @@ export function setupAuxiliaryToolbar(editor: Editor): void {
"click",
(event) => {
const files = editor.toolbar.auxiliary.multimedia.fileEl.files;
if (!files || !files.length)
throw new Error("no hay archivos para subir");
if (!files || !files.length) {
console.info("no hay archivos para subir");
return;
}
const file = files[0];
const selectedEl = editor.contentEl.querySelector<HTMLElement>(

View file

@ -20,7 +20,6 @@ function makeParentBlock(
};
}
// TODO: añadir blockquote
// XXX: si agregás algo acá, probablemente le quieras hacer un botón
// en app/views/posts/attributes/_content.haml
export const parentBlocks: { [propName: string]: EditorNode } = {

View file

@ -10,6 +10,7 @@ export const blockNames = [
"h6",
"unordered_list",
"ordered_list",
"blockquote",
];
export const markNames = [
"bold",

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module ActionDispatch
module Http
# Normaliza los nombres de archivo para que se propaguen
# correctamente a través de todo el stack.
module UploadedFileDecorator
extend ActiveSupport::Concern
included do
# Devolver el nombre de archivo con caracteres unicode
# normalizados
def original_filename
@original_filename.unicode_normalize
end
end
end
end
end
ActionDispatch::Http::UploadedFile.include ActionDispatch::Http::UploadedFileDecorator

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
module ActiveStorage
module Attached::Changes::CreateOneDecorator
extend ActiveSupport::Concern
included do
private
# A partir de ahora todos los archivos se suben al servicio de
# cada sitio.
def attachment_service_name
record.name.to_sym
end
end
end
end
ActiveStorage::Attached::Changes::CreateOne.include ActiveStorage::Attached::Changes::CreateOneDecorator

View file

@ -0,0 +1,82 @@
# frozen_string_literal: true
module ActiveStorage
class Service
# Sube los archivos a cada repositorio y los agrega al LFS de su
# repositorio git.
#
# @todo: Implementar LFS. No nos gusta mucho la idea porque duplica
# el espacio en disco, pero es la única forma que tenemos (hasta que
# implementemos IPFS) para poder transferir los archivos junto con el
# sitio.
class JekyllService < Service::DiskService
# Genera un servicio para un sitio determinado
#
# @param :site [Site]
# @return [ActiveStorage::Service::JekyllService]
def self.build_for_site(site:)
new(root: File.join(site.path, 'public'), public: true).tap do |js|
js.name = site.name.to_sym
end
end
# Lo mismo que en DiskService agregando el nombre de archivo en la
# firma. Esto permite que luego podamos guardar el archivo donde
# corresponde.
#
# @param :key [String]
# @param :expires_in [Integer]
# @param :content_type [String]
# @param :content_length [Integer]
# @param :checksum [String]
# @return [String]
def url_for_direct_upload(key, expires_in:, content_type:, content_length:, checksum:)
instrument :url, key: key do |payload|
verified_token_with_expiration = ActiveStorage.verifier.generate(
{
key: key,
content_type: content_type,
content_length: content_length,
checksum: checksum,
service_name: name,
filename: filename_for(key)
},
expires_in: expires_in,
purpose: :blob_token
)
generated_url = url_helpers.update_rails_disk_service_url(verified_token_with_expiration, host: current_host)
payload[:url] = generated_url
generated_url
end
end
# Mantener retrocompatibilidad con cómo gestionamos los archivos
# subidos hasta ahora.
#
# @param :key [String]
# @return [String]
def folder_for(key)
key
end
# Obtiene el nombre de archivo para esta key
#
# @param :key [String]
# @return [String]
def filename_for(key)
ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first
end
# Crea una ruta para la llave con un nombre conocido.
#
# @param :key [String]
# @return [String]
def path_for(key)
File.join root, folder_for(key), filename_for(key)
end
end
end
end

View file

@ -0,0 +1,31 @@
# frozen_string_literal: true
module ActiveStorage
class Service
# Modificaciones a ActiveStorage::Service::Registry
module RegistryDecorator
extend ActiveSupport::Concern
included do
# El mismo comportamiento que #fetch con el agregado de generar
# un {JekyllService} para cada sitio.
def fetch(name)
services.fetch(name.to_sym) do |key|
if configurations.include?(key)
services[key] = configurator.build(key)
elsif (site = Site.find_by_name(key))
services[key] = ActiveStorage::Service::JekyllService.build_for_site(site: site)
elsif block_given?
yield key
else
raise KeyError, "Missing configuration for the #{key} Active Storage service. " \
"Configurations available for the #{configurations.keys.to_sentence} services."
end
end
end
end
end
end
end
ActiveStorage::Service::Registry.include ActiveStorage::Service::RegistryDecorator

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
module ActiveStorage
# Modificaciones a ActiveStorage::Blob
module BlobDecorator
extend ActiveSupport::Concern
included do
# Permitir que llegue el nombre de archivo al servicio de subida de
# archivos.
#
# @return [Hash]
def service_metadata
if forcibly_serve_as_binary?
{ content_type: ActiveStorage.binary_content_type, disposition: :attachment, filename: filename }
elsif !allowed_inline?
{ content_type: content_type, disposition: :attachment, filename: filename }
else
{ content_type: content_type, filename: filename }
end
end
end
end
end
ActiveStorage::Blob.include ActiveStorage::BlobDecorator

View file

@ -13,12 +13,18 @@ class MetadataFile < MetadataTemplate
value == default_value
end
# No hay valores sugeridos para archivos subidos.
#
# XXX: Esto ayuda a deserializar en {Site#everything_of}
def values; end
def validate
super
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid?
errors << I18n.t("metadata.#{type}.path_required") if path_missing?
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
errors << I18n.t("metadata.#{type}.attachment_missing") if path? && !static_file
errors.compact!
errors.empty?
@ -34,12 +40,6 @@ class MetadataFile < MetadataTemplate
value['path'].is_a?(String)
end
# Determina si la ruta es opcional pero deja pasar si la ruta se
# especifica
def path_optional?
!required && !path?
end
# Asociar la imagen subida al sitio y obtener la ruta
#
# XXX: Si evitamos guardar cambios con changed? no tenemos forma de
@ -48,13 +48,7 @@ class MetadataFile < MetadataTemplate
# repetida.
def save
value['description'] = sanitize value['description']
if path?
hardlink
value['path'] = relative_destination_path
else
value['path'] = nil
end
value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil
true
end
@ -71,101 +65,88 @@ class MetadataFile < MetadataTemplate
# XXX: La última opción provoca archivos duplicados, pero es lo mejor
# que tenemos hasta que resolvamos https://0xacab.org/sutty/sutty/-/issues/213
#
# @return [ActiveStorage::Attachment]
# @todo encontrar una forma de obtener el attachment sin tener que
# recurrir al último subido.
#
# @return [ActiveStorage::Attachment,nil]
def static_file
return unless path?
@static_file ||=
case value['path']
when ActionDispatch::Http::UploadedFile
site.static_files.last if site.static_files.attach(value['path'])
when String
if (blob = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first)
site.static_files.find_by(blob_id: blob)
elsif site.static_files.attach(io: path.open, filename: path.basename)
site.static_files.last
if (blob_id = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first)
site.static_files.find_by(blob_id: blob_id)
elsif path? && pathname.exist? && site.static_files.attach(io: pathname.open, filename: pathname.basename)
site.static_files.last.tap do |s|
s.blob.update(key: key_from_path)
end
end
end
end
# Obtiene la ruta absoluta al archivo
#
# @return [Pathname]
def pathname
raise NoMethodError unless uploaded?
@pathname ||= Pathname.new(File.join(site.path, value['path']))
end
# Obtiene la key del attachment a partir de la ruta
#
# @return [String]
def key_from_path
path.dirname.basename.to_s
pathname.dirname.basename.to_s
end
def path?
value['path'].present?
end
def description?
value['description'].present?
end
private
def filemagic
@filemagic ||= FileMagic.new(FileMagic::MAGIC_MIME)
end
# Obtener la ruta al archivo relativa al sitio
#
# @return [Pathname]
def path
@path ||= Pathname.new(File.join(site.path, value['path']))
end
def file
return unless path?
@file ||=
case value['path']
when ActionDispatch::Http::UploadedFile then value['path'].tempfile.path
when String then File.join(site.path, value['path'])
end
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.
#
# XXX: Asumimos que el archivo destino no existe porque siempre
# contiene una key única.
#
# @return [Boolean]
def hardlink
return if hardlink?
return if File.exist? destination_path
FileUtils.mkdir_p(File.dirname(destination_path))
FileUtils.ln(uploaded_path, destination_path).zero?
end
def hardlink?
File.stat(uploaded_path).ino == File.stat(destination_path).ino
rescue Errno::ENOENT
false
end
# Obtener la ruta al archivo
# https://stackoverflow.com/a/53908358
def uploaded_relative_path
ActiveStorage::Blob.service.path_for(static_file.key)
end
# @return [String]
def uploaded_path
Rails.root.join uploaded_relative_path
end
# La ruta del archivo mantiene el nombre original pero contiene el
# nombre interno y único del archivo para poder relacionarlo con el
# archivo subido en Sutty.
#
# @return [String]
def relative_destination_path
@relative_destination_path ||= File.join('public', static_file.key, static_file.filename.to_s)
end
# @return [String]
def destination_path
@destination_path ||= File.join(site.path, relative_destination_path)
Pathname.new(static_file_path)
end
# Agrega el nombre de archivo a la ruta para tener retrocompatibilidad
#
# @return [Pathname]
def destination_path_with_filename
destination_path.realpath
# Si el archivo no llegara a existir, en lugar de hacer fallar todo,
# devolvemos la ruta original, que puede ser el archivo que no existe
# o vacía si se está subiendo uno.
rescue Errno::ENOENT => e
ExceptionNotifier.notify_exception(e)
value['path']
end
def relative_destination_path_with_filename
destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath)
end
def static_file_path
case static_file.blob.service.name
when :local
File.join(site.path, 'public', static_file.key, static_file.filename.to_s)
else
static_file.blob.service.path_for(static_file.key)
end
end
# No hay archivo pero se lo describió
def no_file_for_description?
value['description'].present? && value['path'].blank?
!path? && description?
end
end

View file

@ -5,7 +5,7 @@ class MetadataImage < MetadataFile
def validate
super
errors << I18n.t('metadata.image.not_an_image') unless image?
errors << I18n.t('metadata.image.not_an_image') if path? && !image?
errors.compact!
errors.empty?
@ -13,8 +13,6 @@ class MetadataImage < MetadataFile
# Determina si es una imagen
def image?
return true unless file
filemagic.file(file).starts_with? 'image/'
static_file&.blob&.send(:web_image?)
end
end

View file

@ -12,6 +12,6 @@ class MetadataMarkdown < MetadataText
# markdown y se eliminan autolinks. Mejor es habilitar la generación
# SAFE de CommonMark en la configuración del sitio.
def sanitize(string)
string
string.unicode_normalize
end
end

View file

@ -25,6 +25,6 @@ class MetadataMarkdownContent < MetadataText
# markdown y se eliminan autolinks. Mejor es deshabilitar la
# generación SAFE de CommonMark en la configuración del sitio.
def sanitize(string)
string.tr("\r", '')
string.tr("\r", '').unicode_normalize
end
end

View file

@ -19,7 +19,7 @@ class MetadataPermalink < MetadataString
# puntos suspensivos, la primera / para que siempre sea relativa y
# agregamos una / al final si la ruta no tiene extensión.
def sanitize(value)
value = value.strip.gsub('..', '/').gsub('./', '').squeeze('/')
value = value.strip.unicode_normalize.gsub('..', '/').gsub('./', '').squeeze('/')
value = value[1..-1] if value.start_with? '/'
value += '/' if File.extname(value).blank?

View file

@ -17,7 +17,7 @@ class MetadataString < MetadataTemplate
def sanitize(string)
return '' if string.blank?
sanitizer.sanitize(string.strip,
sanitizer.sanitize(string.strip.unicode_normalize,
tags: [],
attributes: []).strip.html_safe
end

View file

@ -184,9 +184,12 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
return if string.nil?
return string unless string.is_a? String
sanitizer.sanitize(string.tr("\r", ''),
tags: allowed_tags,
attributes: allowed_attributes).strip.html_safe
sanitizer
.sanitize(string.tr("\r", '').unicode_normalize,
tags: allowed_tags,
attributes: allowed_attributes)
.strip
.html_safe
end
def sanitizer
@ -199,7 +202,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
end
def allowed_tags
@allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure
@allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure blockquote
figcaption a sub sup small].freeze
end

View file

@ -1,360 +0,0 @@
# frozen_string_literal: true
# Representa los distintos tipos de campos que pueden venir de una
# plantilla compleja
class Post
class TemplateField
attr_reader :post, :contents, :key
STRING_VALUES = %w[string text url number email password date year
image video audio document].freeze
# Tipo de valores que son archivos
FILE_TYPES = %w[image video audio document].freeze
def initialize(post, key, contents)
@post = post
@key = key
@contents = contents
end
def title
contents.dig('title') if complex?
end
def subtitle
contents.dig('subtitle') if complex?
end
# Obtiene el valor
def value
complex? ? contents.dig('value') : contents
end
def max
return 0 if simple?
contents.fetch('max', 0)
end
def min
return 0 if simple?
contents.fetch('min', 0)
end
# TODO: volver elegante!
def type
return @type if @type
if image?
@type = 'image'
elsif email?
@type = 'email'
elsif url?
@type = 'url'
elsif number?
@type = 'number'
elsif password?
@type = 'password'
elsif date?
@type = 'date'
elsif year?
@type = 'year'
elsif text_area?
@type = 'text_area'
elsif check_box_group?
@type = 'check_box_group'
elsif radio_group?
@type = 'radio_group'
elsif string?
@type = 'text'
# TODO: volver a hacer funcionar esto y ahorranos los multiple:
# false
elsif string? && contents.split('/', 2).count == 2
@type = 'select'
elsif nested?
@type = 'table'
elsif array?
@type = 'select'
elsif boolean?
@type = 'check_box'
end
@type
end
# Devuelve los valores vacíos según el tipo
def empty_value
if string?
''
elsif nested?
# TODO: devolver las keys también
{}
elsif array?
[]
elsif boolean?
false
end
end
def cols
complex? && contents.dig('cols')
end
def align
complex? && contents.dig('align')
end
# El campo es requerido si es complejo y se especifica que lo sea
def required?
complex? && contents.dig('required')
end
def boolean?
value.is_a?(FalseClass) || value.is_a?(TrueClass)
end
def string?
value.is_a? String
end
def text_area?
value == 'text'
end
def url?
value == 'url'
end
def email?
value == 'email' || value == 'mail'
end
alias mail? email?
def date?
value == 'date'
end
def password?
value == 'password'
end
def number?
value == 'number'
end
def year?
value == 'year'
end
def file?
string? && FILE_TYPES.include?(value)
end
def image?
array? ? value.first == 'image' : value == 'image'
end
# Si la plantilla es simple no está admitiendo Hashes como valores
def simple?
!complex?
end
def complex?
contents.is_a? Hash
end
# XXX Retrocompatibilidad
def to_s
key
end
# Convierte el campo en un parámetro
def to_param
if nested?
{ key.to_sym => {} }
elsif array? && multiple?
{ key.to_sym => [] }
else
key.to_sym
end
end
# Convierte la plantilla en el formato de front_matter
def to_front_matter
{ key => empty_value }
end
def check_box_group?
array? && (complex? && contents.fetch('checkbox', false))
end
def radio_group?
array? && (complex? && contents.fetch('radio', false))
end
def array?
value.is_a? Array
end
# TODO: detectar cuando es complejo y tomar el valor de :multiple
def multiple?
# si la plantilla es simple, es multiple cuando tenemos un array
return array? if simple?
array? && contents.fetch('multiple', true)
end
# Detecta si el valor es una tabla de campos
def nested?
value.is_a?(Hash) || (array? && value.first.is_a?(Hash))
end
# Un campo acepta valores abiertos si no es un array con múltiples
# elementos
def open?
# Todos los valores simples son abiertos
return true unless complex?
return false unless array?
# La cosa se complejiza cuando tenemos valores complejos
#
# Si tenemos una lista cerrada de valores, necesitamos saber si el
# campo es abierto o cerrado. Si la lista tiene varios elementos,
# es una lista cerrada, opcionalmente abierta. Si la lista tiene
# un elemento, quiere decir que estamos autocompletando desde otro
# lado.
contents.fetch('open', value.count < 2)
end
def closed?
!open?
end
# Determina si los valores del campo serán públicos después
#
# XXX Esto es solo una indicación, el theme Jekyll tiene que
# respetarlos por su lado luego
def public?
# Todos los campos son públicos a menos que se indique lo
# contrario
simple? || contents.fetch('public', true)
end
def private?
!public?
end
def human
h = key.humanize
h
end
def label
h = (complex? && contents.dig('label')) || human
h += ' *' if required?
h
end
def help
complex? && contents.dig('help')
end
def nested_fields
return unless nested?
v = value
v = value.first if array?
@nested_fields ||= v.map do |k, sv|
Post::TemplateField.new post, k, sv
end
end
# Obtiene los valores posibles para el campo de la plantilla
def values
return 'false' if value == false
return 'true' if value == true
# XXX por alguna razón `value` no refiere a value() :/
return '' if STRING_VALUES.include? value
# Las listas cerradas no necesitan mayor procesamiento
return value if array? && closed? && value.count > 1
# Y las vacías tampoco
return value if array? && value.empty?
# Ahorrarnos el trabajo
return @values if @values
# Duplicar el valor para no tener efectos secundarios luego (?)
value = self.value.dup
# Para obtener los valores posibles, hay que procesar la string y
# convertirla a parametros
# Si es una array de un solo elemento, es un indicador de que
# tenemos que rellenarla con los valores que indica.
#
# El primer valor es el que trae la string de autocompletado
values = array? ? value.shift : value
# Si el valor es un array con más de un elemento, queremos usar
# esas opciones. Pero si además es abierto, queremos traer los
# valores cargados anteriormente.
# Procesamos el valor, buscando : como separador de campos que
# queremos encontrar y luego los unimos
_value = (values&.split(':', 2) || []).map do |v|
# Tenemos hasta tres niveles de búsqueda
collection, attr, subattr = v.split('/', 3)
if collection == 'site'
# TODO: puede ser peligroso permitir acceder a cualquier
# atributo de site? No estamos trayendo nada fuera de
# lo normal
post.site.send(attr.to_sym)
# Si hay un subatributo, tenemos que averiguar todos los
# valores dentro de el
# TODO volver elegante!
# TODO volver recursivo!
elsif subattr
post.site.everything_of(attr, lang: collection)
.compact
.map { |sv| sv[subattr] }
.flatten
.compact
.uniq
else
post.site.everything_of(attr, lang: collection).compact
end
end
# Si el valor es abierto, sumar los valores auto-completados a
# lo pre-cargados.
#
# En este punto _value es un array de 1 o 2 arrays, si es de uno,
# value tambien tiene que serlo. Si es de 2, hay que unir cada
# una
if open?
if _value.count == 1
_value = [(_value.first + value).uniq]
elsif _value.count == 2
_value = _value.each_with_index.map do |v, i|
v + value.fetch(i, [])
end
end
end
# Crea un array de arrays, útil para los select
# [ [ 1, a ], [ 2, b ] ]
# aunque si no hay un : en el autocompletado, el array queda
# [ [ 1, 1 ], [ 2, 2 ] ]
values = _value.empty? ? [] : _value.last.zip(_value.first)
# En última instancia, traer el valor por defecto y ahorrarnos
# volver a procesar
@values = values
end
end
end

View file

@ -57,7 +57,7 @@ class Site < ApplicationRecord
# Carga el sitio Jekyll una vez que se inicializa el modelo o después
# de crearlo
after_initialize :load_jekyll
after_create :load_jekyll, :static_file_migration!
after_create :load_jekyll
# Cambiar el nombre del directorio
before_update :update_name!
before_save :add_private_key_if_missing!
@ -166,8 +166,10 @@ class Site < ApplicationRecord
end
# Similar a site.i18n en jekyll-locales
#
# @return [Hash]
def i18n
data[I18n.locale.to_s]
data[I18n.locale.to_s] || {}
end
# Devuelve el idioma por defecto del sitio, el primero de la lista.
@ -470,15 +472,10 @@ class Site < ApplicationRecord
config.theme = design.gem unless design.no_theme?
config.description = description
config.title = title
config.url = url
config.url = url(slash: false)
config.hostname = hostname
end
# Migra los archivos a Sutty
def static_file_migration!
Site::StaticFileMigration.new(site: self).migrate!
end
# Valida si el sitio tiene al menos una forma de alojamiento asociada
# y es la local
#

View file

@ -1,52 +0,0 @@
# frozen_string_literal: true
class Site
# Obtiene todos los archivos relacionados en artículos del sitio y los
# sube a Sutty.
class StaticFileMigration
# Tipos de metadatos que contienen archivos
STATIC_TYPES = %i[file image].freeze
attr_reader :site
def initialize(site:)
@site = site
end
def migrate!
modified = site.docs.map do |doc|
next unless STATIC_TYPES.map do |field|
next unless doc.attribute? field
next unless doc[field].path?
next unless doc[field].static_file
true
end.any?
log.write "#{doc.path.relative};no se pudo guardar\n" unless doc.save(validate: false)
doc.path.absolute
end.compact
log.close
return if modified.empty?
# TODO: Hacer la migración desde el servicio de creación de sitios?
site.repository.commit(file: modified,
message: I18n.t('sites.static_file_migration'),
usuarie: author)
end
private
def author
@author ||= GitAuthor.new email: "sutty@#{Site.domain}",
name: 'Sutty'
end
def log
@log ||= File.open(File.join(site.path, 'migration.csv'), 'w')
end
end
end

View file

@ -95,6 +95,9 @@
%button.btn{ type: 'button', title: t('editor.right'), data: { editor_button: 'parentBlock-right' } }>
%i.fa.fa-fw.fa-align-right>
%span.sr-only>= t('editor.right')
%button.btn{ type: 'button', title: t('editor.blockquote'), data: { editor_button: 'block-blockquote' } }>
%i.fa.fa-fw.fa-quote-left>
%span.sr-only>= t('editor.blockquote')
-# HAML cringe
.editor-auxiliary-toolbar.mt-1.scrollbar-black{ data: { editor_auxiliary_toolbar: '' } }

View file

@ -1,7 +1,5 @@
.form-group
- if metadata.static_file
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
- case metadata.static_file.blob.content_type
- when %r{\Avideo/}
= video_tag url_for(metadata.static_file),
@ -14,13 +12,17 @@
- else
= link_to t('posts.attribute_ro.file.download'),
url_for(metadata.static_file)
.custom-control.custom-switch
= check_box_tag "#{base}[#{attribute}][path]", '', false, id: "#{base}_#{attribute}_destroy", class: 'custom-control-input'
= label_tag "#{base}_#{attribute}_destroy", t('posts.attributes.file.destroy'), class: 'custom-control-label'
-# Mantener el valor si no enviamos ninguna imagen
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
-# Los archivos requeridos solo se pueden reemplazar
- unless metadata.required
.custom-control.custom-switch
= check_box_tag "#{base}[#{attribute}][path]", '', false, id: "#{base}_#{attribute}_destroy", class: 'custom-control-input'
= label_tag "#{base}_#{attribute}_destroy", t('posts.attributes.file.destroy'), class: 'custom-control-label'
.custom-file
= file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata),
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}",
data: { preview: "#{attribute}-preview" })
= label_tag "#{base}_#{attribute}_path",
@ -30,7 +32,7 @@
.form-group
= label_tag "#{base}_#{attribute}_description",
post_label_t(attribute, :description, post: post)
post_label_t(attribute, :description, post: post, required: false)
= text_field(*field_name_for(base, attribute, :description),
value: metadata.value['description'],
dir: dir, lang: locale,

View file

@ -1,5 +1,5 @@
.form-group
- if metadata.uploaded?
- if metadata.static_file
= image_tag url_for(metadata.static_file),
alt: metadata.value['description'],
class: 'img-fluid',
@ -37,4 +37,3 @@
**field_options(attribute, metadata, required: false))
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :description], metadata: metadata

View file

@ -38,6 +38,12 @@ module Sutty
config.active_storage.variant_processor = :vips
config.to_prepare do
Dir.glob(File.join(File.dirname(__FILE__), '..', 'app', '**', '*_decorator.rb')).sort.each do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
end
config.after_initialize do
ActiveStorage::DirectUploadsController.include ActiveStorage::AuthenticatedDirectUploadsController

View file

@ -1,7 +0,0 @@
# frozen_string_literal: true
# TODO: Estamos procesando el análisis de los archivos en el momento
# porque queremos obtener la ruta del archivo en el momento y no
# después. Necesitaríamos poder generar el vínculo en el
# repositorio a destiempo, modificando el Job de ActiveStorage
ActiveStorage::AnalyzeJob.queue_adapter = :inline

View file

@ -41,10 +41,12 @@ en:
not_an_image: 'Not an image'
path_required: 'Missing image for upload'
no_file_for_description: "Description with no associated image"
attachment_missing: "I couldn't save the image :("
file:
site_invalid: 'The file cannot be stored if the site configuration is not valid'
path_required: "Missing file for upload"
no_file_for_description: "Description with no associated file"
attachment_missing: "I couldn't save the file :("
event:
zone_missing: 'Inexistent timezone'
date_missing: 'Event date is required'
@ -572,6 +574,7 @@ en:
left: Left
right: Right
center: Center
blockquote: Quote
color: Color
text-color: Text color
multimedia: Media

View file

@ -41,10 +41,12 @@ es:
not_an_image: 'No es una imagen'
path_required: 'Se necesita una imagen'
no_file_for_description: 'Se envió una descripción sin imagen asociada'
attachment_missing: 'no pude guardar el archivo :('
file:
site_invalid: 'El archivo no se puede almacenar si la configuración del sitio no es válida'
path_required: 'Se necesita un archivo'
no_file_for_description: 'se envió una descripción sin archivo asociado'
attachment_missing: 'no pude guardar el archivo :('
event:
zone_missing: 'El huso horario no es correcto'
date_missing: 'La fecha es obligatoria'
@ -580,6 +582,7 @@ es:
left: Izquierda
right: Derecha
center: Centro
blockquote: Cita
color: Color
text-color: Color del texto
multimedia: Multimedia