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

Merge branch '16436-pipeline-fallido' into 'rails'

Draft: Resolve "Pipeline fallido"

Closes #12753, #12410, #13603, and #16436

See merge request sutty/sutty!270
This commit is contained in:
Maki 2024-06-04 18:49:20 +00:00
commit 73adccb6e7
149 changed files with 1438 additions and 1098 deletions

View file

@ -46,6 +46,7 @@ assets:
- "rails"
- "production.panel.sutty.nl"
- "panel.sutty.nl"
- "panel.testing.sutty.nl"
except:
- "schedules"
cache:

View file

@ -22,8 +22,6 @@ RUN apk add npm && npm install -g pnpm@~7 && apk del npm
COPY ./monit.conf /etc/monit.d/sutty.conf
RUN apk add npm && npm install -g pnpm && apk del npm
VOLUME "/srv"
EXPOSE 3000

View file

@ -118,9 +118,8 @@ group :development, :test do
gem 'derailed_benchmarks'
gem 'dotenv-rails'
gem 'pry'
# Adds support for Capybara system testing and selenium driver
gem 'capybara', '~> 2.13'
gem 'selenium-webdriver', '~> 4.8.0'
gem 'capybara'
gem 'selenium-webdriver'
gem 'sqlite3'
end

View file

@ -118,13 +118,15 @@ GEM
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
capybara (2.18.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
rack-test (>= 0.5.4)
xpath (>= 2.0, < 4.0)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
chartkick (5.0.2)
climate_control (1.2.0)
coderay (1.1.3)
@ -360,13 +362,14 @@ GEM
net-pop
net-smtp
marcel (1.0.2)
matrix (0.4.2)
memory_profiler (1.0.1)
mercenary (0.4.0)
method_source (1.0.0)
mini_histogram (0.3.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.5)
mini_portile2 (2.8.6)
minitest (5.21.1)
mobility (1.2.9)
i18n (>= 0.6.10, < 2)
@ -386,7 +389,7 @@ GEM
net-ssh (7.2.1)
netaddr (2.0.6)
nio4r (2.7.0-x86_64-linux-musl)
nokogiri (1.16.0-x86_64-linux-musl)
nokogiri (1.16.5-x86_64-linux-musl)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
orm_adapter (0.5.0)
@ -407,7 +410,7 @@ GEM
pry (0.14.2)
coderay (~> 1.1)
method_source (~> 1.0)
public_suffix (5.0.4)
public_suffix (5.0.5)
puma (6.4.2-x86_64-linux-musl)
nio4r (~> 2.0)
pundit (2.3.1)
@ -486,7 +489,7 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.9.2)
redis (>= 4, < 6)
regexp_parser (2.9.0)
regexp_parser (2.9.2)
request_store (1.5.1)
rack (>= 1.4)
responders (3.1.1)
@ -542,7 +545,7 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (4.8.6)
selenium-webdriver (4.9.1)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
@ -632,7 +635,7 @@ DEPENDENCIES
bootstrap (~> 4)
brakeman
bundler-audit
capybara (~> 2.13)
capybara
chartkick
commonmarker
concurrent-ruby-ext
@ -707,7 +710,7 @@ DEPENDENCIES
safe_yaml
safely_block (~> 0.3.0)
sassc-rails
selenium-webdriver (~> 4.8.0)
selenium-webdriver
sourcemap
spring
spring-watcher-listen

View file

@ -1,13 +1,6 @@
migrate: bundle exec rake db:prepare db:seed
sutty: bundle exec puma config.ru
blazer_5m: bundle exec rake blazer:run_checks SCHEDULE="5 minutes"
blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour"
blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day"
blazer: bundle exec rake blazer:send_failing_checks
prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_"
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
cleanup: bundle exec rake cleanup:everything
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7
stats: bundle exec rake stats:process_all
que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que
stats: bundle exec rake stats:process_all
fediblock: bundle exec rails activity_pub:fediblocks

View file

View file

@ -20,6 +20,8 @@ $form-feedback-valid-color: $black;
$form-feedback-invalid-color: $magenta;
$form-feedback-icon-valid-color: $black;
$component-active-bg: $magenta;
$btn-white-space: nowrap;
$font-weight-bolder: 700;
$spacers: (
2-plus: 0.75rem
@ -51,7 +53,7 @@ $sizes: (
.editor {
.editor-content {
figure {
border: 1px solid transparentize($magenta, 0.3)
border: 1px solid transparentize($magenta, 0.3);
}
}
}
@ -87,6 +89,26 @@ $sizes: (
box-shadow: 0 0 0 0.2rem $cyan;
}
}
a.black {
color: $white;
&:active {
color: var(--color);
}
}
form.was-validated {
div.border {
div.custom-control {
div {
.custom-control-label {
color: $white;
}
}
}
}
}
}
// TODO: Encontrar la forma de generar esto desde los locales de Rails
@ -135,6 +157,10 @@ a {
color: var(--color);
}
&:focus {
outline: 1px solid var(--color);
}
&[target=_blank] {
/* TODO: Convertir a base64 para no hacer peticiones extra */
&:after {
@ -143,6 +169,8 @@ a {
}
}
$footer-height: 60px;
/* Colores */
@ -256,6 +284,10 @@ svg {
}
}
.badge {
white-space: break-spaces;
}
.btn-sm {
@extend .badge
}
@ -527,6 +559,7 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
color: var(--#{$color});
}
::-moz-selection,
::selection {
background: var(--#{$color});
@ -551,6 +584,7 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
a {
color: var(--#{$color});
}
}
}
@ -621,3 +655,31 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
}
}
}
hr {
border-bottom: 1px solid #dee2e6;
}
a.black {
&:active {
color: var(--color);
}
&:focus {
color: var(--color);
}
}
.break-all {
word-break: break-all;
}
.details-agregar {
@extend .d-flex;
@extend .border;
@extend .border-magenta;
@extend .justify-content-between;
@extend .align-items-center;
@extend .w-100;
@extend .mb-3;
}

View file

@ -31,4 +31,17 @@ $cyan: #13fefe;
}
}
a.black {
color: $white;
&:hover {
color: var(--color);
}
&:active {
color: var(--color);
}
&:focus {
color: var(--color);
}
}

View file

@ -86,7 +86,7 @@ class ApplicationController < ActionController::Base
end
def site
@site ||= find_site
@site ||= find_site.tap(&:reindex_changes!)
end
protected

View file

@ -55,9 +55,11 @@ class PostsController < ApplicationController
def new
authorize Post
@post = site.posts(lang: locale).build(layout: params[:layout])
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
layout = site.layouts[params[:layout].to_sym]
@post = Post.build(locale: locale, layout: layout, site: site)
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: layout.humanized_name.downcase), ''
end
def create
@ -155,13 +157,13 @@ class PostsController < ApplicationController
#
# @return [Hash]
def filter_params
@filter_params ||= params.permit(:q, :category, :layout, :page).to_hash.select do |_, v|
@filter_params ||= params.permit(:q, :category, :page, layout: []).to_hash.select do |_, v|
v.present?
end.transform_keys(&:to_sym)
end
def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
@post ||= site.indexed_posts.find_by!(locale: locale, path: params[:post_id] || params[:id]).post
end
# Recuerda el nombre del servicio de subida de archivos

View file

@ -175,7 +175,7 @@ class StatsController < ApplicationController
locale: I18n.locale,
empty: I18n.t('stats.index.empty', **please_return_at),
loading: I18n.t('stats.index.loading'),
html: %(<div id="%{id}" class="d-flex align-items-center justify-content-center" style="height: %{height}; width: %{width};">%{loading}</div>)
html: %(<div id="%<id>s" class="d-flex align-items-center justify-content-center" style="height: %<height>s; width: %<width>s;">%<loading>s</div>)
}
end
@ -209,7 +209,7 @@ class StatsController < ApplicationController
#
# @return [Integer]
def nodes
@nodes ||= ENV.fetch('NODES', 1).to_i
@nodes ||= Rails.application.nodes.size + 1
end
def period

View file

@ -19,6 +19,24 @@ module ApplicationHelper
[root, name]
end
# Devuelve los params sin el valor para una llave, detectando si el
# valor es un array.
#
# @param filtering_params [Hash]
# @param key [Symbol,String]
# @param value [Any]
def filter_params_by(filtering_params, key, value)
filtering_params.map do |k, v|
if k == key
case v
when Array then [k, v - [value]]
end
else
[k, v]
end
end.compact.to_h
end
def plain_field_name_for(*names)
root, name = field_name_for(*names)

View file

@ -77,6 +77,8 @@ class DeployJob < ApplicationJob
t << ([type.to_s] + row.values)
end
end)
rescue DeployTimedOutException => e
notify_exception e
ensure
if site.present?
site.update status: 'waiting'

View file

@ -1,18 +1,29 @@
# frozen_string_literal: true
# Permite traer los cambios desde webhooks
# Permite traer los cambios desde el repositorio remoto
class GitPullJob < ApplicationJob
# @param :site [Site]
# @param :usuarie [Usuarie]
# @param :message [String]
# @return [nil]
def perform(site, usuarie)
def perform(site, usuarie, message)
@site = site
return unless site.repository.origin
return unless site.repository.fetch.positive?
site.repository.merge(usuarie)
site.repository.fetch
return if site.repository.up_to_date?
if site.repository.fast_forward?
site.repository.fast_forward!
else
site.repository.merge(usuarie, message)
end
site.repository.git_lfs_checkout
site.reindex_changes!
nil
end
end

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

@ -17,13 +17,13 @@ class DeployDistributedPress < Deploy
before_create :create_remote_site!
before_destroy :delete_remote_site!
DEPENDENCIES = %i[deploy_local]
DEPENDENCIES = %i[deploy_local].freeze
# Actualiza la información y luego envía los cambios
#
# @param :output [Bool]
# @return [Bool]
def deploy
def deploy(output: true)
status = false
log = []
@ -32,9 +32,7 @@ class DeployDistributedPress < Deploy
create_remote_site! if remote_site_id.blank?
save
if remote_site_id.blank?
raise DeployJob::DeployException, 'El sitio no se creó en Distributed Press'
end
raise DeployJob::DeployException, 'El sitio no se creó en Distributed Press' if remote_site_id.blank?
site_client.tap do |c|
stdout = Thread.new(publisher.logger_out) do |io|
@ -54,7 +52,7 @@ class DeployDistributedPress < Deploy
end
if status
self.remote_info[:distributed_press] = c.show(publishing_site).to_h
remote_info[:distributed_press] = c.show(publishing_site).to_h
save
end
@ -123,7 +121,10 @@ class DeployDistributedPress < Deploy
#
# @return [DistributedPressPublisher::V1::Schemas::NewSite]
def create_site
DistributedPress::V1::Schemas::NewSite.new.call(domain: hostname, protocols: { http: true, ipfs: true, hyper: true })
DistributedPress::V1::Schemas::NewSite.new.call(domain: hostname,
protocols: {
http: true, ipfs: true, hyper: true
})
end
# Crea el sitio en la instancia con el hostname especificado
@ -149,7 +150,7 @@ class DeployDistributedPress < Deploy
# @param log [String]
# @return [nil]
def create_stat!(status, log)
build_stats.create action: publisher.to_s,log: log, seconds: time_spent_in_seconds, bytes: size, status: status
build_stats.create action: publisher.to_s, log: log, seconds: time_spent_in_seconds, bytes: size, status: status
nil
end

View file

@ -9,8 +9,8 @@ class DeployLocal < Deploy
def bundle(output: false)
run %(bundle config set --local clean 'true'), output: output
run(%(bundle config set --local deployment 'true'), output: output) if site.gemfile_lock_path?
run %(bundle config set --local path '#{gems_dir}'), output: output
run %(bundle config set --local deployment 'true'), output: output if site.gemfile_lock_path?
run %(bundle config set --local path '#{site.bundle_path}'), output: output
run %(bundle config set --local without 'test development'), output: output
run %(bundle config set --local cache_all 'false'), output: output
run %(bundle install), output: output
@ -86,7 +86,7 @@ class DeployLocal < Deploy
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
'YARN_CACHE_FOLDER' => yarn_cache_dir,
'GEMS_SOURCE' => ENV['GEMS_SOURCE']
'GEMS_SOURCE' => ENV.fetch('GEMS_SOURCE', nil)
}
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

@ -5,7 +5,7 @@
class DeployRsync < Deploy
store :values, accessors: %i[hostname destination host_keys], coder: JSON
DEPENDENCIES = %i[deploy_local deploy_zip]
DEPENDENCIES = %i[deploy_local deploy_zip].freeze
def deploy(output: false)
ssh? && rsync(output: output)
@ -39,6 +39,7 @@ class DeployRsync < Deploy
# @return [Boolean]
def ssh?
return true if destination.start_with? 'rsync://'
user, host = user_host
ssh_available = false
@ -66,7 +67,7 @@ class DeployRsync < Deploy
{
'HOME' => home_dir,
'PATH' => '/usr/bin',
'LANG' => ENV['LANG']
'LANG' => ENV.fetch('LANG', nil)
}
end
@ -81,7 +82,7 @@ class DeployRsync < Deploy
#
# @return [Array]
def user_host
destination.split(':', 2).first.split('@', 2).tap do |d|
@user_host ||= destination.split(':', 2).first.split('@', 2).tap do |d|
next unless d.size == 1
d.insert(0, nil)
@ -92,7 +93,8 @@ class DeployRsync < Deploy
#
# @return [Boolean]
def rsync(output: false)
run %(rsync -aviH --delete-after --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output
run %(rsync -aviH --delete-after --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/),
output: output
end
# El origen es el destino de la compilación

View file

@ -34,15 +34,66 @@ class IndexedPost < ApplicationRecord
scope :in_category, ->(category) { where("front_matter->'categories' ? :category", category: category.to_s) }
scope :by_usuarie, ->(usuarie) { where("front_matter->'usuaries' @> :usuarie::jsonb", usuarie: usuarie.to_s) }
# Trae todos los valores únicos para un atributo
#
# @param :attribute [String,Symbol]
# @return [Array]
scope :everything_of, lambda { |attribute|
where('front_matter ? :attribute', attribute: attribute)
.pluck(
Arel.sql(
ActiveRecord::Base.sanitize_sql(['front_matter -> :attribute', { attribute: attribute }])
)
)
.flatten.uniq
}
validates_presence_of :layout, :path, :locale
belongs_to :site
# Encuentra el post original
# La ubicación del Post en el disco
#
# @return [nil,Post]
def post
return if post_id.blank?
# @return [String]
def full_path
@full_path ||= File.join(site.path, "_#{locale}", "#{path}.markdown")
end
@post ||= site.posts(lang: locale).find(post_id, uuid: true)
# La colección
#
# @return [Jekyll::Collection]
def collection
site.collections[locale.to_s]
end
# Obtiene el documento
#
# @return [Jekyll::Document]
def document
@document ||= Jekyll::Document.new(full_path, site: site.jekyll, collection: collection)
end
# El Post
#
# @todo Decidir qué pasa si el archivo ya no existe
# @return [Post]
def post
@post ||= Post.new(document: document, site: site, layout: schema)
end
# Devuelve el esquema de datos
#
# @todo Renombrar
# @return [Layout]
def schema
site.layouts[layout.to_sym]
end
# Existe físicamente?
#
# @return [Boolean]
def exist?
File.exist?(full_path)
end
# Convertir locale a direccionario de PG

View file

@ -3,6 +3,8 @@
# Almacena el UUID de otro Post y actualiza el valor en el Post
# relacionado.
class MetadataBelongsTo < MetadataRelatedPosts
include Metadata::InverseConcern
# TODO: Convertir algunos tipos de valores en módulos para poder
# implementar varios tipos de campo sin repetir código
#
@ -20,83 +22,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
document.data[name.to_s]
end
def validate
super
errors << I18n.t('metadata.belongs_to.missing_post') unless post_exists?
errors.empty?
end
# Guardar y guardar la relación inversa también, eliminando la
# relación anterior si existía.
def save
super
# Si no hay relación inversa, no hacer nada más
return true unless changed?
return true unless inverse?
# Si estamos cambiando la relación, tenemos que eliminar la relación
# anterior
if belonged_to.present?
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
rej == post.uuid.value
end
end
# No duplicar las relaciones
belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
true
end
# El Post actual está incluido en la relación inversa?
def included?
belongs_to[inverse].value.include?(post.uuid.value)
end
# Hay una relación inversa y el artículo existe?
def inverse?
inverse.present?
end
# El campo que es la relación inversa de este
def inverse
@inverse ||= layout.metadata.dig(name, 'inverse')&.to_sym
end
# El Post relacionado con este artículo
def belongs_to
posts.find(value, uuid: true) if value.present?
end
# El artículo relacionado anterior
def belonged_to
posts.find(value_was, uuid: true) if value_was.present?
end
def related_posts?
true
end
def related_methods
@related_methods ||= %i[belongs_to belonged_to].freeze
end
def indexable_values
belongs_to&.title&.value
posts.find_by_post_uuid(value).try(:title)
end
private
def post_exists?
return true if sanitize(value).blank?
sanitize(value).present? && belongs_to.present?
end
def sanitize(uuid)
uuid.to_s.gsub(/[^a-f0-9\-]/i, '')
uuid.to_s.gsub(/[^a-f0-9-]/i, '')
end
end

View file

@ -53,20 +53,21 @@ class MetadataContent < MetadataTemplate
# Eliminar elementos sin src y comprobar su origen
html.css(elements).each do |element|
begin
raise URI::Error unless element['src'].present?
raise URI::Error unless element['src'].present?
uri = URI element['src']
uri = URI element['src']
# No permitimos recursos externos
raise URI::Error unless Rails.application.config.hosts.include?(uri.hostname)
element['src'] = convert_src_to_internal_path uri
raise URI::Error if element['src'].blank?
rescue URI::Error
element.remove
# No permitimos recursos externos, solo si sabemos cuales son
# los recursos locales
if Rails.application.config.hosts.present? && !Rails.application.config.hosts.include?(uri.hostname)
raise URI::Error
end
element['src'] = convert_src_to_internal_path uri
raise URI::Error if element['src'].blank?
rescue URI::Error
element.remove
end
# Eliminar figure sin contenido
@ -96,11 +97,10 @@ class MetadataContent < MetadataTemplate
# @param style [String]
# @return [String]
def sanitize_style(style)
style.split(';').reduce({}) do |style_hash, style_string|
style.split(';').each_with_object({}) do |style_string, style_hash|
key, value = style_string.split(':', 2)
style_hash[key] ||= value
style_hash
end.slice(*allowed_styles).map do |style_pair|
style_pair.join(':')
end.join(';')

View file

@ -1,46 +1,5 @@
# frozen_string_literal: true
# Establece una relación de muchos a muchos artículos. Cada campo es un
# Array de UUID que se mantienen sincronizados.
#
# Por ejemplo:
#
# Un libro puede tener muches autores y une autore muchos libros. La
# relación has_many tiene que traer todes les autores relacionades con
# el libro actual. La relación belongs_to tiene que traer todes les
# autores que tienen este libro. La relación es bidireccional, no hay
# diferencia entre has_many y belongs_to.
# Establece una relación de muchos a muchos artículos
class MetadataHasAndBelongsToMany < MetadataHasMany
# Mantiene la relación inversa si existe.
#
# La relación belongs_to se mantiene actualizada en la modificación
# actual. Lo que buscamos es mantener sincronizada esa relación.
#
# Buscamos en belongs_to la relación local, si se eliminó hay que
# quitarla de la relación remota, sino hay que agregarla.
#
def save
# XXX: No usamos super
self[:value] = sanitize value
return true unless changed?
return true unless inverse?
# XXX: Usamos asignación para aprovechar value= que setea el valor
# anterior en @value_was
(had_many - has_many).each do |remove|
remove[inverse].value = remove[inverse].value.reject do |rej|
rej == post.uuid.value
end
end
(has_many - had_many).each do |add|
next unless add[inverse]
next if add[inverse].value.include? post.uuid.value
add[inverse].value = (add[inverse].value.dup << post.uuid.value)
end
true
end
end

View file

@ -6,55 +6,5 @@
# Localmente tenemos un Array de UUIDs. Remotamente tenemos una String
# apuntando a un Post, que se mantiene actualizado como el actual.
class MetadataHasMany < MetadataRelatedPosts
# Todos los Post relacionados
def has_many
return default_value if value.blank?
posts.where(uuid: value)
end
# La relación anterior
def had_many
return default_value if value_was.blank?
posts.where(uuid: value_was)
end
def inverse?
inverse.present?
end
# La relación inversa
#
# @return [Nil,Symbol]
def inverse
@inverse ||= layout.metadata.dig(name, 'inverse')&.to_sym
end
# Actualizar las relaciones inversas. Hay que buscar la diferencia
# entre had y has_many.
def save
super
return true unless changed?
return true unless inverse?
(had_many - has_many).each do |remove|
remove[inverse]&.value = remove[inverse].default_value
end
(has_many - had_many).each do |add|
add[inverse]&.value = post.uuid.value
end
true
end
def related_posts?
true
end
def related_methods
@related_methods ||= %i[has_many had_many].freeze
end
include Metadata::InverseConcern
end

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

View file

@ -5,7 +5,7 @@ class MetadataOrder < MetadataTemplate
# El valor según la posición del post en la relación ordenada por
# fecha, a fecha más alta, posición más alta
def default_value
super || site.posts(lang: lang).sort_by(:date).index(post)
super || ((site.indexed_posts.where(locale: locale).first&.order || 0) + 1)
end
def save

View file

@ -3,14 +3,17 @@
# Devuelve una lista de títulos y UUID de todos los posts del mismo
# idioma que el actual, para usar con input-map.js
class MetadataRelatedPosts < MetadataArray
# Genera un Hash de { title | slug => uuid } y excluye el Post actual
# Genera un Hash de { title (schema) => uuid } para usar en
# options_for_select
#
# @return [Hash]
def values
@values ||= posts.map do |p|
next if p.uuid.value == post.uuid.value
[title(p), p.uuid.value]
end.compact.to_h
@values ||= posts.pluck(:title, :created_at, :layout, :post_id).to_h do |row|
row.tap do |value|
value[0] =
"#{value[0]} #{value.delete_at(1).strftime('%F')} (#{site.layouts[value.delete_at(1)].humanized_name})"
end
end
end
# Las relaciones nunca son privadas
@ -23,28 +26,28 @@ class MetadataRelatedPosts < MetadataArray
end
def indexable_values
posts.where(uuid: value).map(&:title).map(&:value)
posts.where(post_id: value).pluck(:title)
end
private
# Obtiene todos los posts y opcionalmente los filtra
# Obtiene todos los posts menos el actual y opcionalmente los filtra
#
# @return [IndexedPost::ActiveRecord_AssociationRelation]
def posts
site.posts(lang: lang).where(**filter)
site.indexed_posts.where(locale: locale).where.not(post_id: post.uuid.value).where(filter)
end
def title(post)
"#{post&.title&.value || post&.slug&.value} #{post&.date&.value.strftime('%F')} (#{post.layout.humanized_name})"
end
# Encuentra el filtro
# Encuentra el filtro desde el esquema del atributo
#
# @return [Hash,nil]
def filter
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys || {}
layout.metadata.dig(name, 'filter')&.to_h&.symbolize_keys
end
def sanitize(uuid)
super(uuid.map do |u|
u.to_s.gsub(/[^a-f0-9\-]/i, '')
u.to_s.gsub(/[^a-f0-9-]/i, '')
end)
end
end

View file

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

View file

@ -30,15 +30,44 @@ class Post
# a demanda?
def find_layout(path)
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
rescue Errno::ENOENT => e
ExceptionNotifier.notify_exception(e, data: { path: path })
:post
end
# Genera un Post nuevo
#
# @todo Mergear en Post#initialize
# @params :path [String]
# @params :site [Site]
# @params :locale [String, Symbol]
# @params :document [Jekyll::Document]
# @params :layout [String,Symbol]
# @return [Post]
def build(**args)
args[:path] ||= ''
args[:document] ||=
begin
site = args[:site]
collection = site.collections[args[:locale].to_s]
Jekyll::Document.new(args[:path], site: site.jekyll, collection: collection).tap do |doc|
doc.data['date'] = Date.today.to_time if args[:path].blank?
end
end
args[:layout] = args[:site].layouts[args[:layout]] if args[:layout].is_a? Symbol
Post.new(**args)
end
end
# Redefinir el inicializador de OpenStruct
#
# @param site: [Site] el sitio en Sutty
# @param document: [Jekyll::Document] el documento leído por Jekyll
# @param layout: [Layout] la plantilla
#
# @param :site [Site] el sitio en Sutty
# @param :document [Jekyll::Document] el documento leído por Jekyll
# @param :layout [Layout] la plantilla
def initialize(**args)
default_attributes_missing(**args)
@ -103,6 +132,7 @@ class Post
src = element.attributes['src']
next unless src&.value&.start_with? 'public/'
file = MetadataFile.new(site: site, post: self, document: document, layout: layout)
file.value['path'] = src.value
@ -221,7 +251,8 @@ class Post
# La fecha de creación inmodificable del post
def created_at
@metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at, type: :created_at, post: self, required: true)
@metadata[:created_at] ||= MetadataCreatedAt.new(document: document, site: site, layout: layout, name: :created_at,
type: :created_at, post: self, required: true)
end
# Detecta si es un atributo válido o no, a partir de la tabla de la
@ -289,8 +320,6 @@ class Post
def destroy
run_callbacks :destroy do
FileUtils.rm_f path.absolute
site.delete_post self
end
end
alias destroy! destroy

View file

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

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

@ -57,8 +57,7 @@ class Site < ApplicationRecord
before_create :clone_skel!
# Elimina el directorio al destruir un sitio
before_destroy :remove_directories!
# Cambiar el nombre del directorio
before_update :update_name!
before_save :add_private_key_if_missing!
# Guardar la configuración si hubo cambios
after_save :sync_attributes_with_config!
@ -219,15 +218,10 @@ class Site < ApplicationRecord
jekyll.data
end
# Traer las colecciones. Todos los artículos van a estar dentro de
# colecciones.
# Trae las colecciones desde el sitio, sin leer su contenido
#
# @return [Hash]
def collections
unless @read
jekyll.reader.read_collections
@read = true
end
jekyll.collections
end
@ -236,55 +230,6 @@ class Site < ApplicationRecord
@config ||= Site::Config.new(self)
end
# Los posts en el idioma actual o en uno en particular
#
# @param lang: [String|Symbol] traer los artículos de este idioma
def posts(lang: nil)
# Traemos los posts del idioma actual por defecto o el que haya
lang ||= locales.include?(I18n.locale) ? I18n.locale : default_locale
lang = lang.to_sym
# Crea un Struct dinámico con los valores de los locales, si
# llegamos a pasar un idioma que no existe vamos a tener una
# excepción NoMethodError
@posts ||= Struct.new(*locales).new
return @posts[lang] unless @posts[lang].blank?
@posts[lang] = PostRelation.new site: self, lang: lang
# No fallar si no existe colección para este idioma
# XXX: queremos fallar silenciosamente?
(collections[lang.to_s]&.docs || []).each do |doc|
layout = layouts[Post.find_layout(doc.path)]
@posts[lang].build(document: doc, layout: layout, lang: lang)
rescue TypeError => e
ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path })
end
@posts[lang]
end
# Todos los Post del sitio para poder buscar en todos.
#
# @return PostRelation
def docs
@docs ||= PostRelation.new(site: self, lang: :docs).push(locales.flat_map do |locale|
posts(lang: locale)
end).flatten!
end
# Elimina un artículo de la colección
def delete_post(post)
lang = post.lang.value
collections[lang.to_s].docs.delete(post.document) &&
posts(lang: lang).delete(post)
post
end
# Obtiene todas las plantillas de artículos
#
# @return [Hash] { post: Layout }
@ -323,24 +268,6 @@ class Site < ApplicationRecord
jekyll.reader.read_layouts
end
# Trae todos los valores disponibles para un campo
#
# TODO: Traer recursivamente, si el campo contiene Hash
#
# TODO: Mover a PostRelation#pluck
#
# @param attr [Symbol|String] El atributo a buscar
# @return Array
def everything_of(attr, lang: nil)
Rails.cache.fetch("#{cache_key_with_version}/everything_of/#{lang}/#{attr}", expires_in: 1.hour) do
attr = attr.to_sym
posts(lang: lang).flat_map do |p|
p[attr].value if p.attribute? attr
end.uniq.compact
end
end
# Poner en la cola de compilación
def enqueue!
update(status: 'enqueued') if waiting?
@ -457,7 +384,6 @@ class Site < ApplicationRecord
@incompatible_layouts = nil
@jekyll = nil
@config = nil
@posts = nil
@docs = nil
end
@ -493,13 +419,6 @@ class Site < ApplicationRecord
FileUtils.rm_rf path
end
def update_name!
return unless name_changed?
FileUtils.mv path_was, path
reload_jekyll!
end
# Sincroniza algunos atributos del sitio con su configuración y
# guarda los cambios
#

View file

@ -16,7 +16,12 @@ class Site
def index_posts!
Site.transaction do
docs.each(&:index!)
jekyll.read
jekyll.documents.each do |doc|
doc.read!
Post.build(document: doc, site: self, layout: doc['layout'].to_sym).index!
end
update(last_indexed_commit: repository.head_commit.oid)
end
@ -99,9 +104,10 @@ class Site
indexable_posts.select do |delta|
MODIFIED_STATUSES.include? delta.status
end.each do |delta|
locale, path = locale_and_path_from(delta.new_file[:path])
locale, = locale_and_path_from(delta.new_file[:path])
full_path = File.join(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

View file

@ -76,12 +76,17 @@ class Site
# escribir los cambios
rugged.checkout 'HEAD', strategy: :force
git_sh("git", "lfs", "fetch", "origin", default_branch)
# reemplaza los pointers por los archivos correspondientes
git_sh("git", "lfs", "checkout")
commit
end
# Trae todos los archivos desde LFS
#
# @return [Boolean]
def git_lfs_checkout
git_sh('git', 'lfs', 'fetch', 'origin', default_branch)
git_sh('git', 'lfs', 'checkout')
end
# El último commit
#
# @return [Rugged::Commit]
@ -111,10 +116,30 @@ class Site
walker.each.to_a
end
# Hay commits sin aplicar?
def needs_pull?
fetch
!commits.empty?
# Detecta si hay que hacer un pull o no
#
# @return [Boolean]
def up_to_date?
rugged.merge_analysis(remote_head_commit).include?(:up_to_date)
end
# Detecta si es posible adelantar la historia local a la remota o
# necesitamos un merge
#
# @return [Boolean]
def fast_forward?
rugged.merge_analysis(remote_head_commit).include?(:fastforward)
end
# Mueve la historia local a la remota
#
# @see {https://stackoverflow.com/a/27077322}
# @return [nil]
def fast_forward!
rugged.checkout_tree(remote_head_commit)
rugged.references.update(rugged.head.resolve, remote_head_commit.oid)
nil
end
# Guarda los cambios en git
@ -123,7 +148,7 @@ class Site
# @param :rm [Array] Archivos a eliminar
# @param :usuarie [Usuarie] Quién hace el commit
# @param :message [String] Mensaje
def commit(add: [], rm: [], usuarie:, message:)
def commit(usuarie:, message:, add: [], rm: [])
# Cargar el árbol actual
rugged.index.read_tree rugged.head.target.tree
@ -157,7 +182,7 @@ class Site
#
# @return [Boolean]
def gc
git_sh("git", "gc")
git_sh('git', 'gc')
end
# Pushea cambios al repositorio remoto
@ -171,8 +196,8 @@ class Site
# Hace limpieza de LFS
def lfs_cleanup
git_sh("git", "lfs", "prune")
git_sh("git", "lfs", "dedup")
git_sh('git', 'lfs', 'prune')
git_sh('git', 'lfs', 'dedup')
end
private
@ -232,7 +257,7 @@ class Site
# @param :args [Array]
# @return [Boolean]
def git_sh(*args)
env = { 'PATH' => '/usr/bin', 'LANG' => ENV['LANG'], 'HOME' => path }
env = { 'PATH' => '/usr/bin', 'LANG' => ENV.fetch('LANG', nil), 'HOME' => path }
r = nil
Open3.popen2e(env, *args, unsetenv_others: true, chdir: path) do |_, _, t|

View file

@ -3,12 +3,11 @@
# Este servicio se encarga de crear artículos y guardarlos en git,
# asignándoselos a une usuarie
PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# Crea un artículo nuevo
# Crea un artículo nuevo y modificar las asociaciones
#
# @return Post
def create
self.post = site.posts(lang: locale)
.build(layout: layout)
self.post = Post.build(site: site, locale: locale, layout: layout)
post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie
@ -16,42 +15,13 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post.slug.value = p[:slug] if p[:slug].present?
end
commit(action: :created, add: update_related_posts) if post.update(post_params)
update_site_license!
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
post
end
# Crear un post anónimo, con opciones más limitadas. No usamos post.
def create_anonymous
# XXX: Confiamos en el parámetro de idioma porque estamos
# verificándolos en Site#posts
self.post = site.posts(lang: locale)
.build(layout: layout)
# Los artículos anónimos siempre son borradores
params[:draft] = true
commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params)
post
end
def update
post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie
# Eliminar ("mover") el archivo si cambió de ubicación.
if post.update(post_params)
rm = []
rm << post.path.value_was if post.path.changed?
added_paths << post.path.value
# Es importante que el artículo se guarde primero y luego los
# relacionados.
commit(action: :updated, add: update_related_posts, rm: rm)
# Recorrer todas las asociaciones y agregarse donde corresponda
update_associations(post)
update_site_license!
commit(action: :created, add: added_paths)
end
# Devolver el post aunque no se haya salvado para poder rescatar los
@ -59,6 +29,45 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post
end
# Crear un post anónimo, con opciones más limitadas. No usamos post.
#
# @todo Permitir asociaciones?
def create_anonymous
# XXX: Confiamos en el parámetro de idioma porque estamos
# verificándolos en Site#posts
self.post = Post.build(site: site, locale: locale, layout: layouts)
# Los artículos anónimos siempre son borradores
params[:draft] = true
commit(action: :created, add: [post.path.absolute]) if post.update(anon_post_params)
post
end
# Al actualizar, modificamos un post pre-existente, todas las
# relaciones anteriores y las relaciones actuales.
def update
post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie
if post.update(post_params)
# Eliminar ("mover") el archivo si cambió de ubicación.
rm = []
rm << post.path.value_was if post.path.changed?
added_paths << post.path.value
# Recorrer todas las asociaciones y agregarse donde corresponda
update_associations(post)
commit(action: :updated, add: added_paths, rm: rm)
end
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
post
end
# @todo Eliminar relaciones
def destroy
post.destroy!
@ -74,7 +83,7 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# { uuid => 2, uuid => 1, uuid => 0 }
def reorder
reorder = params.require(:post).permit(reorder: {})&.dig(:reorder)&.transform_values(&:to_i)
posts = site.posts(lang: locale).where(uuid: reorder.keys)
posts = site.indexed_posts.where(locale: locale, post_id: reorder.keys).map(&:post)
files = posts.map do |post|
next unless post.attribute? :order
@ -90,8 +99,11 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
return if files.empty?
# TODO: Implementar transacciones!
posts.save_all(validate: false) &&
commit(action: :reorder, add: files)
posts.map do |post|
post.save(validate: false)
end
commit(action: :reorder, add: files)
end
private
@ -118,43 +130,147 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
end
end
# @return [Symbol]
def locale
params.dig(:post, :lang)&.to_sym || I18n.locale
end
# @return [Layout]
def layout
params.dig(:post, :layout) || params[:layout]
end
# Actualiza los artículos relacionados según los métodos que los
# metadatos declaren.
#
# Este método se asegura que todos los artículos se guardan una sola
# vez.
#
# @return [Array] Lista de archivos modificados
def update_related_posts
posts = Set.new
post.attributes.each do |a|
post[a].related_methods.each do |m|
next unless post[a].respond_to? m
# La respuesta puede ser una PostRelation también
posts.merge [post[a].public_send(m)].flatten.compact
end
end
posts.map do |p|
p.path.absolute if p.save(validate: false)
end.compact << post.path.absolute
site.layouts[
(params.dig(:post, :layout) || params[:layout]).to_sym
]
end
# Si les usuaries modifican o crean una licencia, considerarla
# personalizada en el panel.
def update_site_license!
if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
site.update licencia: Licencia.find_by_icons('custom')
return unless site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
site.update licencia: Licencia.find_by_icons('custom')
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

View file

@ -24,7 +24,9 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
#
# TODO: hacer que el repositorio se cree cuando es necesario, para
# que no haya estados intermedios.
site.locales = [usuarie.lang] + I18n.available_locales
site.locales = [usuarie.lang]
add_role_to_deploys! role
add_role_to_deploys! role
@ -82,16 +84,28 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
commit_config(action: :tor)
end
# Trae cambios desde la rama remota y reindexa los artículos.
# Trae cambios desde la rama remota
#
# @return [Boolean]
def merge
result = site.repository.merge(usuarie)
site.repository.merge(usuarie).present?
end
# TODO: Implementar callbacks
site.try(:index_posts!) if result
def rename(name)
return if name == site.name
moved = false
site.name = name
result.present?
Site.transaction do
raise ActiveRecord::Rollback if File.exists?(site.path)
FileUtils.mv (site.path_was, site.path)
moved = true
ActiveStorage::Blob.where(service_name: site.name_was).update_all(service_name: site.name)
site.save
rescue StandardError
FileUtils.mv (site.path, site.path_was) if moved
raise
end
end
private
@ -146,7 +160,7 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
return true if site.licencia.custom?
with_all_locales do |locale|
post = site.posts(lang: locale).find_by(layout: 'license')
post = site.indexed_posts(locale: locale).find_by(layout: 'license')&.post
change_licencia(post: post) if post
end.compact.map(&:valid?).all?

View file

@ -0,0 +1,10 @@
- content = t("activerecord.attributes.#{field.object_name}.#{name}")
- id = "#{field.object_name}_#{name}"
- name = "#{field.object_name}[#{name}]"
= render 'bootstrap/custom_checkbox', id: id,
name: name,
content: content,
required: local_assigns[:required],
value: '1' do
= yield

View file

@ -1,6 +1,6 @@
%main.row
%aside.menu.col-md-3
= render 'sites/header', site: @site
%aside.menu.col-12.col-lg-3
= render 'sites/header', site: @site, filter_params: @filter_params
.col
%h1= t('.title')
@ -14,7 +14,10 @@
- row[:urls].each do |url|
%tr
%th{ scope: 'row' }= row[:title]
%td= link_to_if (url.present? && url.scheme.present?), url.to_s, url.to_s, class: 'word-break-all'
%td= link_to_if (url.present? && url.scheme.present?),
url.to_s,
url.to_s,
class: 'word-break-all'
%td
%time{ datetime: row[:seconds][:machine] }= row[:seconds][:human]
%td= row[:size]

View file

@ -1,28 +1,28 @@
.row.align-items-center.justify-content-center.full-height
.col-md-10.align-self-center
.col-12.col-lg-10.align-self-center
- welcome = @site.config.dig('welcome', 'message') || t('.welcome',
site: @site.hostname)
site: @site.hostname)
= sanitize_markdown welcome
.col-md-6.align-self-center
.col-12.col-lg-6.align-self-center
-# Copiado y pegado de app/views/devise/registrations/new.haml
- resource = resource_name = @invitade
= form_for(resource, as: resource_name,
url: site_collaborate_path(@site),
method: :post) do |f|
url: site_collaborate_path(@site),
method: :post) do |f|
- unless current_usuarie
.form-group
= f.label :email
= f.email_field :email, autofocus: true, autocomplete: 'email',
class: 'form-control'
class: 'form-control'
.form-group
= f.label :password
- if @minimum_password_length
%em
= t('devise.shared.minimum_password_length',
count: @minimum_password_length)
count: @minimum_password_length)
= f.password_field :password, autocomplete: 'new-password',
class: 'form-control'
class: 'form-control'
.form-group
= f.submit t('.submit'), class: 'btn btn-secondary btn-lg btn-block'

View file

@ -4,31 +4,30 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-4.align-self-center
.col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.resend_confirmation_instructions')
= form_for(resource,
as: resource_name,
url: confirmation_path(resource_name),
html: { method: :post }) do |f|
as: resource_name,
url: confirmation_path(resource_name),
html: { method: :post }) do |f|
:ruby
value = if resource.pending_reconfirmation?
resource.unconfirmed_email
else
resource.email
end
resource.unconfirmed_email
else
resource.email
end
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email,
autofocus: true,
autocomplete: 'email',
class: 'form-control',
value: value,
placeholder: t('activerecord.attributes.usuarie.email')
autofocus: true,
autocomplete: 'email',
class: 'form-control',
value: value,
placeholder: t('activerecord.attributes.usuarie.email')
.actions
= f.submit t('.resend_confirmation_instructions'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'
= render 'devise/shared/links'

View file

@ -4,32 +4,32 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.col-12.col-lg-5.align-self-center
%h2= t 'devise.invitations.edit.header'
= form_for(resource,
as: resource_name,
url: invitation_path(resource_name),
html: { method: :put }) do |f|
as: resource_name,
url: invitation_path(resource_name),
html: { method: :put }) do |f|
= f.hidden_field :invitation_token, readonly: true
- if f.object.class.require_password_on_accepting
.form-group
= f.label :password, class: 'sr-only'
= f.password_field :password, class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t('activerecord.attributes.usuarie.password')
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t('activerecord.attributes.usuarie.password')
- if @minimum_password_length
%small.text-muted.form-text#minimum-password-length
= t('devise.shared.minimum_password_length',
count: @minimum_password_length)
count: @minimum_password_length)
.form-group
= f.label :password_confirmation, class: 'sr-only'
= f.password_field :password_confirmation,
class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t('activerecord.attributes.usuarie.password')
class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t('activerecord.attributes.usuarie.password')
.actions
= f.submit t('devise.invitations.edit.submit_button'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'

View file

@ -4,16 +4,16 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.col-12.col-lg-5.align-self-center
%h2= t 'devise.invitations.new.header'
= form_for(resource,
as: resource_name,
url: invitation_path(resource_name),
html: { method: :post }) do |f|
as: resource_name,
url: invitation_path(resource_name),
html: { method: :post }) do |f|
- resource.class.invite_key_fields.each do |field|
.form-group
= f.label field
= f.text_field field, class: 'form-control'
.actions
= f.submit t('devise.invitations.new.submit_button'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'

View file

@ -10,15 +10,20 @@
- if @resource.needs_invitation_link?
%p= link_to t('devise.mailer.invitation_instructions.accept'),
accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
accept_invitation_url(@resource,
invitation_token: @token,
change_locale_to: @resource.lang)
- if @resource.invitation_due_at
%p= t('devise.mailer.invitation_instructions.accept_until',
due_date: l(@resource.invitation_due_at,
format: :'devise.mailer.invitation_instructions.accept_until_format'))
due_date: l(@resource.invitation_due_at,
format: :'devise.mailer.invitation_instructions.accept_until_format'))
%p= t('devise.mailer.invitation_instructions.ignore')
- elsif !@resource.confirmed? && @resource.confirmation_token
= confirmation_url(@resource, confirmation_token: @resource.confirmation_token, change_locale_to: @resource.lang)
= confirmation_url(@resource,
confirmation_token: @resource.confirmation_token,
change_locale_to: @resource.lang)
- else
%p= link_to t('devise.mailer.invitation_instructions.sign_in'), root_url
%p= link_to t('devise.mailer.invitation_instructions.sign_in'),
root_url(change_locale_to: @resource.lang)

View file

@ -4,41 +4,40 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.change_your_password')
%p= t('.help')
= form_for(resource, as: resource_name,
url: password_path(resource_name),
html: { method: :put }) do |f|
url: password_path(resource_name),
html: { method: :put }) do |f|
= f.hidden_field :reset_password_token
.form-group
= f.label :password, t('.new_password'), class: 'sr-only'
= f.password_field :password,
autofocus: true,
autocomplete: 'new-password',
class: 'form-control', min: @minimum_password_length,
aria: { describedby: 'minimum_password_length' },
placeholder: t('.new_password')
autofocus: true,
autocomplete: 'new-password',
class: 'form-control', min: @minimum_password_length,
aria: { describedby: 'minimum_password_length' },
placeholder: t('.new_password')
- if @minimum_password_length
%small.form-text.text-muted
= t('devise.shared.minimum_password_length',
count: @minimum_password_length)
count: @minimum_password_length)
.form-group
= f.label :password_confirmation, t('.confirm_new_password'),
class: 'sr-only'
class: 'sr-only'
= f.password_field :password_confirmation, autocomplete: 'off',
class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum_password_length' },
placeholder: t('.confirm_new_password')
class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum_password_length' },
placeholder: t('.confirm_new_password')
.actions
= f.submit t('.change_my_password'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'
= render 'devise/shared/links'

View file

@ -4,21 +4,21 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.forgot_your_password')
%p= t('.help')
= form_for(resource,
as: resource_name,
url: password_path(resource_name),
html: { method: :post }) do |f|
as: resource_name,
url: password_path(resource_name),
html: { method: :post }) do |f|
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email',
class: 'form-control',
placeholder: t('activerecord.attributes.usuarie.email')
class: 'form-control',
placeholder: t('activerecord.attributes.usuarie.email')
.actions
= f.submit t('.send_me_reset_password_instructions'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'
= render 'devise/shared/links'

View file

@ -6,61 +6,61 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-6.align-self-center
.col-12.col-lg-6.align-self-center
%h2= t('.title')
= form_for(resource,
as: resource_name,
url: registration_path(resource_name),
html: { method: :put }) do |f|
as: resource_name,
url: registration_path(resource_name),
html: { method: :put }) do |f|
.form-group
= f.label :email
= f.email_field :email, autofocus: true, autocomplete: 'email',
class: 'form-control'
class: 'form-control'
- if devise_mapping.confirmable? && resource.pending_reconfirmation?
%div
= t('.currently_waiting_confirmation_for_email',
email: resource.unconfirmed_email)
email: resource.unconfirmed_email)
.form-group
= f.label :lang
= f.select :lang,
I18n.available_locales.map { |lang| [t(lang), lang] }, {},
class: 'form-control'
I18n.available_locales.map { |lang| [t(lang), lang] }, {},
class: 'form-control'
.form-group
= f.label :password
= f.password_field :password, autocomplete: 'new-password',
class: 'form-control', aria: { describedby: 'password-help' }
class: 'form-control',
aria: { describedby: 'password-help' }
%small.text-muted.form-text#password-help
= t('.leave_blank_if_you_don_t_want_to_change_it')
- if @minimum_password_length
= t('devise.shared.minimum_password_length',
count: @minimum_password_length)
count: @minimum_password_length)
.form-group
= f.label :password_confirmation
= f.password_field :password_confirmation,
autocomplete: 'new-password',
class: 'form-control'
autocomplete: 'new-password',
class: 'form-control'
.form-group
= f.label :current_password
= f.password_field :current_password,
autocomplete: 'current-password',
required: true,
class: 'form-control',
aria: { describedby: 'current-password-help' }
autocomplete: 'current-password',
required: true,
class: 'form-control',
aria: { describedby: 'current-password-help' }
%small.text-muted.form-text#current-password-help
= t('.we_need_your_current_password_to_confirm_your_changes')
.actions
= f.submit t('.update'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'
%hr/
.sr-only
%h3= t('.cancel_my_account')
= button_to t('.cancel_my_account'),
registration_path(resource_name),
data: { confirm: t('.are_you_sure') },
method: :delete, class: 'btn btn-secondary btn-block'
registration_path(resource_name),
data: { confirm: t('.are_you_sure') },
method: :delete, class: 'btn btn-secondary btn-block'

View file

@ -4,41 +4,41 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-6.align-self-center
.col-12.col-lg-6.align-self-center
%h2= t('.sign_up')
%p= t('.help')
= form_for(resource,
as: resource_name,
url: registration_path(resource_name, params: { locale: params[:locale] })) do |f|
as: resource_name,
url: registration_path(resource_name, params: { locale: params[:locale] })) do |f|
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email',
class: 'form-control',
placeholder: t('activerecord.attributes.usuarie.email')
class: 'form-control',
placeholder: t('activerecord.attributes.usuarie.email')
- password = 'activerecord.attributes.usuarie.password'
.form-group
= f.label :password, class: 'sr-only'
= f.password_field :password, autocomplete: 'new-password',
class: 'form-control', min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t(password)
class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t(password)
- if @minimum_password_length
%small.text-muted.form-text#minimum-password-length
= t('devise.shared.minimum_password_length',
count: @minimum_password_length)
count: @minimum_password_length)
.form-group
= f.label :password_confirmation, class: 'sr-only'
= f.password_field :password_confirmation,
autocomplete: 'new-password',
class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t("#{password}_confirmation")
autocomplete: 'new-password',
class: 'form-control',
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t("#{password}_confirmation")
.form-group
- Usuarie::CONSENT_FIELDS.each do |field|
@ -48,7 +48,12 @@
- content = t(".#{field}.label")
- href = t(".#{field}.href", default: '')
- help_content = t(".#{field}.help")
= render 'bootstrap/custom_checkbox', id: id, name: name, content: content, required: required, value: "1" do
= render 'bootstrap/custom_checkbox',
id: id,
name: name,
content: content,
required: required,
value: '1' do
- if href.present?
= link_to help_content, href, target: '_blank', rel: 'noopener'
- else
@ -56,6 +61,6 @@
.actions
= f.submit t('.sign_up'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'
= render 'devise/shared/links'

View file

@ -2,38 +2,37 @@
- 'black-bg'
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.sign_in')
%p= t('.help')
= form_for(resource,
as: resource_name,
url: session_path(resource_name)) do |f|
as: resource_name,
url: session_path(resource_name)) do |f|
- if @site
= hidden_field :referer, value: site_path(@site)
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email,
autofocus: true,
autocomplete: 'email',
class: 'form-control',
placeholder: t('login.email')
autofocus: true,
autocomplete: 'email',
class: 'form-control',
placeholder: t('login.email')
.form-group
= f.label :password, class: 'sr-only'
= f.password_field :password,
autocomplete: 'current-password',
class: 'form-control',
placeholder: t('login.password')
autocomplete: 'current-password',
class: 'form-control',
placeholder: t('login.password')
- if devise_mapping.rememberable?
.form-group
= f.check_box :remember_me, aria: { describedby: 'remember-for' }
= f.label :remember_me
%small.form-text.text-muted#remember-for
= render 'bootstrap/custom_checkbox_for_field',
field: f, name: :remember_me do
= t('login.remember_me',
remember_for: distance_of_time_in_words(Usuarie.remember_for))
remember_for: distance_of_time_in_words(Usuarie.remember_for))
.actions
= f.submit t('.sign_in'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'
= render 'devise/shared/links'

View file

@ -4,21 +4,21 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.col-12.col-lg-5.align-self-center
.sr-only
%h2= t('.resend_unlock_instructions')
%p= t('.help')
= form_for(resource,
as: resource_name,
url: unlock_path(resource_name),
html: { method: :post }) do |f|
as: resource_name,
url: unlock_path(resource_name),
html: { method: :post }) do |f|
.form-group
= f.label :email, class: 'sr-only'
= f.email_field :email, autofocus: true, autocomplete: 'email',
class: 'form-control',
placeholder: t('activerecord.attributes.usuarie.email')
class: 'form-control',
placeholder: t('activerecord.attributes.usuarie.email')
.actions
= f.submit t('.resend_unlock_instructions'),
class: 'btn btn-secondary btn-lg btn-block'
class: 'btn btn-secondary btn-lg btn-block'
= render 'devise/shared/links'

View file

@ -1,33 +1,38 @@
%nav.navbar
%a.navbar-brand.d-none.d-sm-block{ href: '/' }
%nav.navbar.flex-md-nowrap.px-0
%a.navbar-brand.order-0{ href: '/' }
= inline_svg_tag 'sutty.svg', class: 'black', aria: true,
title: t('svg.sutty.title'), desc: t('svg.sutty.desc')
title: t('svg.sutty.title'),
desc: t('svg.sutty.desc')
%nav{ aria: { label: t('.title') } }
%ol.breadcrumb.m-0.flex-wrap
- breadcrumb_trail do |crumb|
%li.breadcrumb-item{ class: crumb.current? ? 'active' : '' }
- if crumb.current?
%span.line-clamp-1{ aria: { current: 'page' } }= crumb.name
- else
%span.line-clamp-1= link_to crumb.name, crumb.url
- if breadcrumbs?
%nav.flex-grow-1.order-2.order-md-1{ aria: { label: t('.title') } }
%ol.breadcrumb.m-0.flex-wrap
- breadcrumb_trail do |crumb|
%li.breadcrumb-item{ class: crumb.current? ? 'active' : '' }
- if crumb.current?
%span.line-clamp-1{ aria: { current: 'page' } }= crumb.name
- else
= link_to crumb.name, crumb.url, class: 'line-clamp-1'
- if @current_usuarie || current_usuarie
%ul.navbar-nav.flex-row
- if @site&.tienda?
- if usuarie || current_usuarie
%ul.navbar-nav.order-1.order-md-2
- if site&.tienda?
%li.nav-item
= link_to t('.tienda'), @site.tienda_url,
role: 'button', class: 'btn btn-secondary'
= link_to t('.tienda'), site.tienda_url,
role: 'button', class: 'btn btn-secondary'
%li.nav-item
= link_to t('.contact_us'), t('.contact_us_href'),
class: 'btn btn-secondary', rel: 'me', target: '_blank'
class: 'btn btn-secondary', rel: 'me', target: '_blank'
%li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn btn-secondary'
method: :delete, role: 'button', class: 'btn btn-secondary'
- else
- params.permit!
- I18n.available_locales.each do |locale|
- next if locale == I18n.locale
= link_to t("switch_locale.#{locale}"), params.to_h.merge(change_locale_to: locale)
%li.nav-item
= link_to t("switch_locale.#{locale}"),
params.to_h.merge(change_locale_to: locale)

View file

@ -7,10 +7,15 @@
@param :summary_class [String] Clases para el summary
- local_assigns[:summary_class] ||= 'h3'
- local_assigns[:closed] ||= '&#x25B6'.html_safe
- local_assigns[:open] ||= '&#x25BC'.html_safe
%details.details.py-2{ id: local_assigns[:id], data: { controller: 'details', action: 'toggle->details#store' } }
%details.details.py-2{ id: local_assigns[:id],
data: { controller: 'details',
action: 'toggle->details#store' },
class: local_assigns[:details_class] }
%summary.d-flex.flex-row.align-items-center.justify-content-between{ class: local_assigns[:summary_class] }
%span= summary
%span.hide-when-open &#x25B6;
%span.show-when-open &#x25BC;
%span.hide-when-open{ class: local_assigns[:open_class] }= local_assigns[:closed]
%span.show-when-open{ class: local_assigns[:closed_class] }= local_assigns[:open]
= yield

View file

@ -13,16 +13,27 @@
%script{ type: 'text/javascript', src: '/env.js' }
= csrf_meta_tags
= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
= stylesheet_link_tag 'dark', rel: 'alternate stylesheet', media: 'all', 'data-turbolinks-track': 'reload', title: t('dark')
= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload', defer: true
= stylesheet_pack_tag 'application', 'data-turbolinks-track': 'reload'
= favicon_link_tag 'sutty_cuadrada.png', rel: 'apple-touch-icon', type: 'image/png'
= stylesheet_link_tag 'application',
media: 'all',
'data-turbolinks-track': 'reload'
= stylesheet_link_tag 'dark',
rel: 'alternate stylesheet',
media: 'all',
'data-turbolinks-track': 'reload',
title: t('dark')
= javascript_pack_tag 'application',
'data-turbolinks-track': 'reload',
defer: true
= stylesheet_pack_tag 'application',
'data-turbolinks-track': 'reload'
= favicon_link_tag 'sutty_cuadrada.png',
rel: 'apple-touch-icon',
type: 'image/png'
= render 'layouts/link_rel_alternate'
%body{ class: yield(:body) }
.container-fluid#sutty
= render 'layouts/breadcrumb'
= render 'layouts/breadcrumb', usuarie: @current_usuarie, site: @site
= render 'layouts/flash'
= yield

View file

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

View file

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

View file

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

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

View file

@ -1,43 +1,54 @@
.form-group{ data: { controller: 'file-preview' } }
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
- if metadata.static_file
- case metadata.static_file.blob.content_type
- when %r{\Avideo/}
= video_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid',
data: { target: 'file-preview.preview' }
- when %r{\Aaudio/}
= audio_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid',
data: { target: 'file-preview.preview' }
- when 'application/pdf'
%iframe{ src: url_for(metadata.static_file) }
- else
= link_to t('posts.attribute_ro.file.download'),
url_for(metadata.static_file)
- when %r{\Avideo/}
= video_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid',
data: { target: 'file-preview.preview' }
- when %r{\Aaudio/}
= audio_tag url_for(metadata.static_file),
controls: true, class: 'img-fluid',
data: { target: 'file-preview.preview' }
- when 'application/pdf'
%iframe{ src: url_for(metadata.static_file) }
- else
= link_to t('posts.attribute_ro.file.download'),
url_for(metadata.static_file)
-# 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'
= 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, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}",
data: { target: 'file-preview.input', action: 'file-preview#update' })
**field_options(attribute,
metadata,
required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}",
data: { target: 'file-preview.input',
action: 'file-preview#update' })
= label_tag "#{base}_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label'
post_label_t(attribute, :path, post: post),
class: 'custom-file-label'
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :path], metadata: metadata
post: post, attribute: [attribute, :path], metadata: metadata
.form-group
= label_tag "#{base}_#{attribute}_description",
post_label_t(attribute, :description, post: post, required: false)
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,
**field_options(attribute, metadata, required: false))
value: metadata.value['description'],
dir: dir, lang: locale,
**field_options(attribute, metadata, required: false))
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :description], metadata: metadata
post: post, attribute: [attribute, :description], metadata: metadata

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

@ -1,40 +1,51 @@
.form-group{ data: { controller: 'file-preview' } }
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
- if metadata.static_file
= image_tag url_for(metadata.static_file),
alt: metadata.value['description'],
class: 'img-fluid',
data: { target: 'file-preview.preview' }
alt: metadata.value['description'],
class: 'img-fluid',
data: { target: 'file-preview.preview' }
-# Mantener el valor si no enviamos ninguna imagen
= hidden_field_tag "#{base}[#{attribute}][path]", metadata.value['path']
-# Las imágenes requeridas 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.image.destroy'), class: 'custom-control-label'
= check_box_tag "#{base}[#{attribute}][path]",
'',
false,
id: "#{base}_#{attribute}_destroy",
class: 'custom-control-input'
= label_tag "#{base}_#{attribute}_destroy",
t('posts.attributes.image.destroy'),
class: 'custom-control-label'
- else
= image_tag '',
alt: metadata.value['description'],
class: 'img-fluid',
data: { target: 'file-preview.preview' }
alt: metadata.value['description'],
class: 'img-fluid',
data: { target: 'file-preview.preview' }
.custom-file
= file_field(*field_name_for(base, attribute, :path),
**field_options(attribute, metadata, required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}",
accept: ActiveStorage.web_image_content_types.join(','),
data: { target: 'file-preview.input', action: 'file-preview#update' })
**field_options(attribute,
metadata,
required: (metadata.required && !metadata.path?)),
class: "custom-file-input #{invalid(post, attribute)}",
accept: ActiveStorage.web_image_content_types.join(','),
data: { target: 'file-preview.input',
action: 'file-preview#update' })
= label_tag "#{base}_#{attribute}_path",
post_label_t(attribute, :path, post: post), class: 'custom-file-label'
post_label_t(attribute, :path, post: post),
class: 'custom-file-label'
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :path], metadata: metadata
post: post, attribute: [attribute, :path], metadata: metadata
.form-group
= label_tag "#{base}_#{attribute}_description",
post_label_t(attribute, :description, post: post, required: false)
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,
**field_options(attribute, metadata, required: false))
value: metadata.value['description'],
dir: dir, lang: locale,
**field_options(attribute, metadata, required: false))
= render 'posts/attribute_feedback',
post: post, attribute: [attribute, :description], metadata: metadata
post: post, attribute: [attribute, :description], metadata: metadata

View file

@ -3,10 +3,9 @@
%legend= post_label_t(attribute, post: post)
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
post: post, attribute: attribute, metadata: metadata
- site.locales.each do |locale|
- next if post.lang.value == locale
- locale_t = t("locales.#{locale}.name", default: locale.to_s.humanize)
- value = metadata.value.find do |v|
- metadata.values[locale].values.include? v
@ -15,5 +14,6 @@
= label_tag "#{base}_#{attribute}_#{locale}", locale_t
= select_tag("#{plain_field_name_for(base, attribute)}[]",
options_for_select(metadata.values[locale], value),
**field_options(attribute, metadata), include_blank: t('.empty'))
options_for_select(metadata.values[locale], value),
**field_options(attribute, metadata),
include_blank: t('.empty'))

View file

@ -1,9 +1,3 @@
.row.justify-content-center
.col-md-8
- if policy(@site).edit?
= render 'layouts/details', summary: t('posts.edit.post') do
= render 'posts/form', site: @site, post: @post
= render 'layouts/details', summary: t('posts.edit.moderation_queue') do
= render 'posts/moderation_queue', site: @site, post: @post, moderation_queue: @moderation_queue
- else
= render 'posts/form', site: @site, post: @post
.col-12.col-lg-8
= render 'posts/form', site: @site, post: @post

View file

@ -6,70 +6,93 @@
- reorder_target = reorder_controller = {}
%main.row
%aside.menu.col-md-3
%aside.menu.col-lg-3
.mb-3
= render 'sites/header', site: @site
= render 'sites/header', site: @site, filter_params: @filter_params
= render 'sites/status', site: @site
= render 'sites/build', site: @site, class: 'btn-block'
= render 'sites/moderation_queue', site: @site, class: 'btn-block'
%h3= t('posts.new')
%table.table.table-sm.mb-3
%tbody
- @site.schema_organization.each do |schema, _|
- schema = @site.layouts[schema]
- next if schema.hidden?
= render 'schemas/row', site: @site, schema: schema, filter: @filter_params
= render 'layouts/details', summary: t('posts.filters.title') do
%form{ method: :get }
.border.border-magenta.p-1
- @filter_params.each do |param, values|
- next if param == :layout
- [values].flatten.each do |value|
%input{ type: 'hidden',
name: values.is_a?(Array) ? "#{param}[]" : param,
value: value }
%legend.font-weight-bold.m-0.h6= 'Tipo de contenido'
- @site.schema_organization.each do |key, _|
.custom-control.custom-checkbox
- schema = @site.layouts[key]
= render 'schemas/filter', site: @site,
key: key,
schema: schema,
filter: @filter_params
%button.btn.btn-secondary.mt-3{ type: 'submit' }= t('posts.filters.submit')
= render 'layouts/details',
summary: t('posts.new'),
summary_class: 'h4 magenta font-weight-bold m-0 px-2',
details_class: 'details-agregar',
open: '+', closed: '+',
open_class: 'h1 magenta font-weight-bold m-0',
closed_class: 'h1 magenta font-weight-bold m-0' do
%table.table-sm.w-100
%tbody
- @site.schema_organization.each do |schema, _|
- schema = @site.layouts[schema]
- next if schema.hidden?
= render 'schemas/row', site: @site,
schema: schema,
filter: @filter_params
- if policy(@site_stat).index?
= link_to t('stats.index.title'), site_stats_path(@site), class: 'btn btn-secondary'
= link_to t('stats.index.title'),
site_stats_path(@site),
class: 'btn btn-secondary'
- if policy(@site).edit?
= link_to t('sites.edit.btn', site: @site.title), edit_site_path(@site), class: 'btn btn-secondary'
= link_to t('sites.edit.btn', site: @site.title),
edit_site_path(@site),
class: 'btn btn-secondary'
- if policy(@site).private?
= link_to t('sites.private'), '../private/' + @site.name, class: 'btn btn-secondary', target: '_blank', rel: 'noopener'
= link_to t('sites.private'), "../private/#{@site.name}",
class: 'btn btn-secondary',
target: '_blank',
rel: 'noopener'
- if policy(SiteUsuarie.new(@site, current_usuarie)).index?
= render 'layouts/btn_with_tooltip',
tooltip: t('usuaries.index.help.self'),
text: t('usuaries.index.title'),
type: 'info',
link: site_usuaries_path(@site)
tooltip: t('usuaries.index.help.self'),
text: t('usuaries.index.title'),
type: 'info',
link: site_usuaries_path(@site)
- if @site.design.credits
= render 'bootstrap/alert' do
= sanitize_markdown @site.design.credits
= link_to t('sites.donations.text'), t('sites.donations.url'), class: 'btn btn-secondary'
= link_to t('sites.donations.text'),
t('sites.donations.url'),
class: 'btn btn-secondary'
- if @site.design.designer_url
= link_to t('sites.designer_url'), @site.design.designer_url, class: 'btn btn-secondary'
= link_to t('sites.designer_url'),
@site.design.designer_url,
class: 'btn btn-secondary'
%section.col
.d-flex.justify-content-between.align-items-center.pl-2-plus.pr-2-plus.mb-2
%form{ action: site_posts_path }
- @filter_params.each do |param, value|
- next if param == 'q'
%input{ type: 'hidden', name: param, value: value }
.form-group.flex-grow-0.m-0
%label.sr-only{for: 'q'}= t('.search')
%input#q.form-control.border.border-magenta{ type: 'search', placeholder: t('.search'), name: 'q', value: @filter_params[:q] }
%input.sr-only{ type: 'submit' }
- if @site.locales.size > 1
%nav#locales
- @site.locales.each do |locale|
= link_to @site.data.dig(locale.to_s, 'locale') || locale, site_posts_path(@site, **@filter_params.merge(locale: locale)),
class: "mr-2 mt-2 mb-2 #{locale == @locale ? 'active font-weight-bold' : ''}"
.pl-2-plus
- @filter_params.each do |param, value|
- if param == 'layout'
- value = @site.layouts[value.to_sym].humanized_name
= link_to site_posts_path(@site, **@filter_params.reject { |k, _| k == param }),
class: 'btn btn-secondary btn-sm',
title: t('posts.remove_filter_help', filter: value),
aria: { labelledby: "help-filter-#{param}" } do
= value
&times;
= link_to @site.data.dig(locale.to_s, 'locale') || locale,
site_posts_path(@site, **@filter_params.merge(locale: locale)),
class: "mr-2 mt-2 mb-2 #{locale == @locale ? 'active font-weight-bold' : ''}"
- if @posts.empty?
%h2= t('posts.empty')
- else
@ -78,11 +101,12 @@
%caption.sr-only= t('posts.caption')
%thead
%tr.sticky-top
%th.border-0{ colspan: '4' }
%th.border-0.p-0.p-md-2-plus{ colspan: '4' }
.d-flex.flex-row.justify-content-between
%div
- if reorder_allowed
= submit_tag t('posts.reorder.submit'), class: 'btn btn-secondary'
= submit_tag t('posts.reorder.submit'),
class: 'btn btn-secondary'
%button.btn.btn-secondary{ data: { action: 'reorder#unselect' } }
= t('posts.reorder.unselect')
%span.badge{ data: { target: 'reorder.counter' } } 0
@ -90,11 +114,18 @@
%button.btn.btn-secondary{ data: { action: 'reorder#down' } }= t('posts.reorder.down')
%button.btn.btn-secondary{ data: { action: 'reorder#top' } }= t('posts.reorder.top')
%button.btn.btn-secondary{ data: { action: 'reorder#bottom' } }= t('posts.reorder.bottom')
%input{ type: 'hidden',
name: 'post[lang]',
value: @locale }
- if @site.pagination
%div
= link_to_prev_page @posts, t('posts.prev'), class: 'btn btn-secondary'
= link_to_next_page @posts, t('posts.next'), class: 'btn btn-secondary'
= link_to_prev_page @posts,
t('posts.prev'),
class: 'btn btn-secondary'
= link_to_next_page @posts,
t('posts.next'),
class: 'btn btn-secondary'
%tbody
- dir = @site.data.dig(params[:locale], 'dir')
- size = @posts.size
@ -102,19 +133,23 @@
-#
TODO: Solo les usuaries cachean porque tenemos que separar
les botones por permisos.
- cache_if @usuarie, [post, I18n.locale] do
- checkbox_id = "checkbox-#{post.post_id}"
%tr{ id: post.post_id, data: reorder_target }
- if reorder_allowed
%td
.custom-control.custom-checkbox
%input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
%input.custom-control-input{ id: checkbox_id,
type: 'checkbox',
autocomplete: 'off',
data: { action: 'reorder#select' } }
%label.custom-control-label{ for: checkbox_id }
%span.sr-only= t('posts.reorder.select')
-# Orden más alto es mayor prioridad
= hidden_field 'post[reorder]', post.post_id,
value: size - i,
data: { reorder: true }
value: size - i,
data: { reorder: true }
%td.w-100{ class: dir }
= link_to site_post_path(@site, post.path) do
%span{ lang: post.locale, dir: dir }= post.title
@ -122,7 +157,8 @@
%span.badge.badge-primary= I18n.t('posts.attributes.draft.label')
%br
%small
= link_to @site.layouts[post.layout].humanized_name, site_posts_path(@site, **@filter_params.merge(layout: post.layout))
= link_to @site.layouts[post.layout].humanized_name,
site_posts_path(@site, **@filter_params.merge(layout: [post.layout]))
- post.front_matter['categories']&.each do |category|
= link_to site_posts_path(@site, **@filter_params.merge(category: category)) do
%span{ lang: post.locale, dir: dir }= category
@ -135,9 +171,23 @@
%td.text-nowrap
.d-flex.flex-row.align-items-start
- if @usuarie || policy(post).edit?
= link_to t('posts.edit_post'), edit_site_post_path(@site, post.path), class: 'btn btn-secondary'
= link_to t('posts.edit_post'),
edit_site_post_path(@site, post.path),
class: 'btn btn-secondary'
- if @usuarie || policy(post).destroy?
= link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-secondary', method: :delete, data: { confirm: t('posts.confirm_destroy') }
= link_to t('posts.destroy'),
site_post_path(@site, post.path),
class: 'btn btn-secondary',
method: :delete,
data: { confirm: t('posts.confirm_destroy') }
-#
Rescatar cualquier error en un post, notificarlo e
ignorar su renderización.
- rescue ActionView::Template::Error => e
- ExceptionNotifier.notify_exception(e.cause,
data: { site: @site.name,
post: @post.path.absolute,
usuarie: current_usuarie.id })
#footnotes{ hidden: true }
- @filter_params.each do |param, value|

View file

@ -1,3 +1,3 @@
.row.justify-content-center
.col-md-8
.col-12.col-lg-8
= render 'posts/form', site: @site, post: @post

View file

@ -1,10 +1,10 @@
- dir = @site.data.dig(params[:locale], 'dir')
.row.justify-content-center
.col-md-8
.col-12.col-lg-8
%article.content.table-responsive-md
= link_to t('posts.edit_post'),
edit_site_post_path(@site, @post.id),
class: 'btn btn-secondary btn-block'
edit_site_post_path(@site, @post.id),
class: 'btn btn-secondary btn-block'
%table.table.table-condensed
%thead

View file

@ -1 +1,3 @@
= link_to t('.add'), new_site_post_path(site, layout: schema.value), class: 'btn btn-secondary btn-sm m-0'
= link_to t(schema.humanized_name),
new_site_post_path(site, layout: schema.value),
class: 'stretched-link black text-decoration-none'

View file

@ -1,4 +1,18 @@
- if filter[:layout] == schema.name.to_s
= link_to t('.remove'), site_posts_path(site, **filter.merge(layout: nil)), class: 'btn btn-primary btn-sm m-0'
- else
= link_to t('.filter'), site_posts_path(site, **filter.merge(layout: schema.value)), class: 'btn btn-secondary btn-sm m-0'
%div
%input.custom-control-input.magenta{ type: 'checkbox',
id: schema,
name: 'layout[]',
class: '',
value: schema.name,
checked: filter[:layout]&.include?(key.to_s) }
%label.custom-control-label.font-weight-normal{ for: schema }= schema.humanized_name
-# XXX: Solo un nivel de recursividad
- unless local_assigns[:parent_schema]
- schema.schemas.each do |s|
- next if s.hidden?
= render 'schemas/filter', schema: s,
key: s.name,
site: site,
filter: filter

View file

@ -1,15 +1,15 @@
%tr
%th.w-100{ scope: 'row' }
%tr.border-top.border-magenta
%th.font-weight-normal.w-100.position-relative{ scope: 'row' }
- if local_assigns[:parent_schema]
%span.text-muted &mdash;
= schema.humanized_name
%td.px-0.text-nowrap
= render 'schemas/add', **local_assigns
= render 'schemas/filter', **local_assigns
= render 'schemas/add', schema: schema, **local_assigns
-# XXX: Solo un nivel de recursividad
- unless local_assigns[:parent_schema]
- schema.schemas.each do |s|
- next if s.hidden?
= render 'schemas/row', schema: s, site: site, filter: filter, parent_schema: schema
= render 'schemas/row', schema: s,
site: site,
filter: filter,
parent_schema: schema

View file

@ -8,7 +8,7 @@
- site.errors.messages.each_pair do |attr, error|
- attr = attr.to_s
- error.each do |e|
%li= link_to t('activerecord.attributes.site.' + attr) + ' ' + e, '#' + attr
%li= link_to "#{t("activerecord.attributes.site.#{attr}")} #{e}", "##{attr}"
= form_for site, html: { class: form_class(site) } do |f|
- unless site.persisted?
@ -21,11 +21,11 @@
No puede estar compuesto solo de números
= f.text_field :name,
class: form_control(site, :name),
required: true,
pattern: '^([a-z0-9][a-z0-9\-]*)?[a-z0-9\.]$',
minlength: 1,
maxlength: 63
class: form_control(site, :name),
required: true,
pattern: '^([a-z0-9][a-z0-9\-]*)?[a-z0-9\.]$',
minlength: 1,
maxlength: 63
- if invalid? site, :name
.invalid-feedback= site.errors.messages[:name].join(', ')
@ -33,7 +33,7 @@
%h2= f.label :title
%p.lead= t('.help.title')
= f.text_field :title, class: form_control(site, :title),
required: true
required: true
- if invalid? site, :title
.invalid-feedback= site.errors.messages[:title].join(', ')
@ -41,7 +41,7 @@
%h2= f.label :description
%p.lead= t('.help.description')
= f.text_area :description, class: form_control(site, :description),
maxlength: 160, minlength: 10, required: true
maxlength: 160, minlength: 10, required: true
- if invalid? site, :description
.invalid-feedback= site.errors.messages[:description].join(', ')
%hr/
@ -53,29 +53,29 @@
- if invalid? site, :design_id
= render 'bootstrap/alert' do
= t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.help',
layouts: site.incompatible_layouts.to_sentence)
layouts: site.incompatible_layouts.to_sentence)
.row.row-cols-1.row-cols-md-2.designs
-# Demasiado complejo para un f.collection_radio_buttons
- Design.all.order(priority: :desc).each do |design|
.design.col.d-flex.flex-column
.custom-control.custom-radio
= f.radio_button :design_id, design.id,
checked: design.id == site.design_id,
disabled: design.disabled,
required: true, class: 'custom-control-input'
checked: design.id == site.design_id,
disabled: design.disabled,
required: true, class: 'custom-control-input'
= f.label "design_id_#{design.id}", design.name,
class: 'custom-control-label'
class: 'custom-control-label'
.flex-fill
= sanitize_markdown design.description,
tags: %w[p a strong em]
tags: %w[p a strong em]
.btn-group{ role: 'group', 'aria-label': t('.design.actions') }
- if design.url
= link_to t('.design.url'), design.url,
target: '_blank', class: 'btn btn-secondary'
target: '_blank', class: 'btn btn-secondary'
- if design.license
= link_to t('.design.license'), design.license,
target: '_blank', class: 'btn btn-secondary'
target: '_blank', class: 'btn btn-secondary'
%hr/
.form-group.licenses#license_id
@ -83,6 +83,7 @@
%p.lead= t('.help.licencia')
- Licencia.all.find_each do |licencia|
- next if licencia.custom? && site.licencia != licencia
.row.license
.col
.media.mt-1
@ -91,15 +92,20 @@
.media-body
.custom-control.custom-radio
= f.radio_button :licencia_id, licencia.id,
checked: licencia.id == site.licencia_id,
required: true, class: 'custom-control-input'
= f.label "licencia_id_#{licencia.id}", class: 'custom-control-label' do
checked: licencia.id == site.licencia_id,
required: true, class: 'custom-control-input'
= f.label "licencia_id_#{licencia.id}",
class: 'custom-control-label' do
= licencia.name
= sanitize_markdown licencia.description,
tags: %w[p a strong em ul ol li h1 h2 h3 h4 h5 h6]
tags: %w[p a strong em ul ol li h1 h2 h3 h4 h5 h6]
- unless licencia.custom?
= link_to t('.licencia.url'), licencia.url, target: '_blank', class: 'btn btn-secondary', rel: 'noopener'
= link_to t('.licencia.url'),
licencia.url,
target: '_blank',
class: 'btn btn-secondary',
rel: 'noopener'
%hr/
@ -158,9 +164,10 @@
%h2= t('.deploys.title')
%p.lead= t('.help.deploys')
= f.fields_for :deploys do |deploy|
= render "deploys/#{deploy.object.type.underscore}",
deploy: deploy, site: site
- site.deployment_list.each do |deploy|
= f.fields_for :deploys, deploy do |deploy_fields|
= render "deploys/#{deploy.type.underscore}",
deploy: deploy_fields, site: site
.form-group
= f.submit submit, class: 'btn btn-secondary btn-lg btn-block'

View file

@ -1,3 +1,32 @@
.hyphens{ lang: site.default_locale }
%h1= site.title
%p.lead= site.description
%form.mb-3{ action: site_posts_path }
- filter_params.each do |param, values|
- next if param == :q
- [values].flatten.each do |value|
%input{ type: 'hidden',
name: values.is_a?(Array) ? "#{param}[]" : param,
value: value }
.form-group.flex-grow-0.m-0
%label.h3{ for: 'q' }= t('posts.index.search')
.input-group
%input.form-control.border.border-magenta.border-right-0#q{ type: 'search',
name: 'q',
value: filter_params[:q] }
.input-group-append
%span.input-group-text.background-white.magenta.border.border-magenta.border-top.border-left-0.border-right.border-bottom
%i.fa.fa-fw.fa-search
%input.sr-only{ type: 'submit' }
- filter_params.each do |param, values|
- [values].flatten.each do |value|
= link_to site_posts_path(site, **filter_params_by(filter_params, param, value)),
class: 'btn btn-secondary btn-sm',
title: t('posts.remove_filter_help', filter: value),
aria: { labelledby: "help-filter-#{param}" } do
- if param == :layout
= site.layouts[value.to_sym].humanized_name
- else
= value
&times;

View file

@ -1,5 +1,5 @@
.row.justify-content-center
.col-md-8
.col-12.col-lg-8
%h1= t('.title', site: @site.name)
= render 'form', site: @site, submit: t('.submit')

View file

@ -1,5 +1,5 @@
.row.justify-content-center
.col-md-8#pull
.col-12.col-lg-8#pull
%h1= t('.title')
%p.lead= sanitize_markdown t('.help.fetch'), tags: %w[em strong a]
@ -10,7 +10,7 @@
- @commits.each do |commit|
.row.justify-content-center
.col-md-8{ id: commit.oid }
.col-12.col-lg-8{ id: commit.oid }
%h1= commit.summary
%p.lead= render 'layouts/time', time: commit.time
@ -19,12 +19,12 @@
resumen, cortamos por el primer salto de línea doble y obtenemos
todo lo demás
= sanitize_markdown commit.message.split("\n\n", 2).last,
tags: %w[p a h1 h2 h3 h4 h5 h6 ol ul li strong em]
tags: %w[p a h1 h2 h3 h4 h5 h6 ol ul li strong em]
%hr
- unless @commits.empty?
.row.justify-content-center
.col-md-8
.col-12.col-lg-8
= link_to t('.merge.request'), site_pull_path(@site),
method: 'post', class: 'btn btn-secondary btn-lg'
method: 'post', class: 'btn btn-secondary btn-lg'

View file

@ -1,5 +1,5 @@
%main.row
%aside.col-md-3
%aside.col-12.col-lg-3
%h1= t('.title')
%p.lead= t('.help')
- if policy(Site).new?

View file

@ -1,5 +1,5 @@
.row.justify-content-center
.col-md-8
.col-12.col-lg-8
%h1= t('.title')
%p.lead= t('.help')

View file

@ -11,10 +11,19 @@
%form.mb-5.form-inline{ method: 'get' }
- Stat::INTERVALS.each do |interval|
= link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls], period_start: params[:period_start].to_date.try(:"beginning_of_#{interval}").to_date, period_end: params[:period_end]), class: "mb-0 btn #{@interval == interval ? 'btn-primary active' : 'btn-secondary' }"
= link_to t(".#{interval}"),
site_stats_path(interval: interval,
urls: params[:urls],
period_start: params[:period_start].to_date.try(:"beginning_of_#{interval}").to_date,
period_end: params[:period_end]),
class: "mb-0 btn #{@interval == interval ? 'btn-primary active' : 'btn-secondary'}"
%input.form-control{ type: 'date', name: :period_start, value: params[:period_start] }
%input.form-control{ type: 'date', name: :period_end, value: params[:period_end] }
%input.form-control{ type: 'date',
name: :period_start,
value: params[:period_start] }
%input.form-control{ type: 'date',
name: :period_end,
value: params[:period_end] }
%button.btn.btn-secondary.mb-0{ type: 'submit' }= t('.filter')
.mb-5
@ -22,25 +31,38 @@
%p.lead= t('.host.description')
= line_chart site_stats_host_path(@chart_params), **@chart_options
#custom-urls.mb-5
.mb-5#custom-urls
%h2= t('.urls.title')
%p.lead= t('.urls.description')
%form{ method: 'get', action: '#custom-urls' }
%input{ type: 'hidden', name: 'interval', value: @interval }
%input{ type: 'hidden', name: 'period_start', value: params[:period_start] }
%input{ type: 'hidden', name: 'period_end', value: params[:period_end] }
%input{ type: 'hidden',
name: 'interval',
value: @interval }
%input{ type: 'hidden',
name: 'period_start',
value: params[:period_start] }
%input{ type: 'hidden',
name: 'period_end',
value: params[:period_end] }
.form-group
%label{ for: 'urls' }= t('.urls.label')
%textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size + 1, aria_describedby: 'help-urls' }= @normalized_urls.join("\n")
%small#help-urls.feedback.form-text.text-muted= t('.urls.help')
%textarea.form-control#urls{ name: 'urls',
autocomplete: 'on',
required: true,
rows: @normalized_urls.size + 1,
aria_describedby: 'help-urls' }= @normalized_urls.join("\n")
%small.feedback.form-text.text-muted#help-urls= t('.urls.help')
.form-group
%button.btn.btn-secondary{ type: 'submit' }= t('.urls.submit')
- if @normalized_urls.present?
= line_chart site_stats_uris_path(urls: @normalized_urls, **@chart_params), **@chart_options
= line_chart site_stats_uris_path(urls: @normalized_urls,
**@chart_params),
**@chart_options
.row.mb-5.row-cols-1.row-cols-md-2
.row.mb-5.row-cols-1.row-cols-lg-2
- @columns.each_pair do |column, values|
- next if values.blank?
.col.mb-5
%h2= t(".columns.#{column}.title")
%p.lead= t(".columns.#{column}.description")
@ -57,7 +79,8 @@
%tbody
- values.each_pair do |col, val|
%tr
%th{ scope: 'row', style: 'word-break: break-all' }= col.blank? ? t(".columns.#{column}.empty") : col
%th.break-all{ scope: 'row' }
= col.blank? ? t(".columns.#{column}.empty") : col
%td= val
.mb-5
%h2= t('.resources.title')
@ -67,4 +90,5 @@
.mb-5
%h3= t(".resources.#{resource}.title")
%p.lead= t(".resources.#{resource}.description")
= line_chart site_stats_resources_path(resource: resource, **@chart_params), **@chart_options.merge(StatsController::EXTRA_OPTIONS[resource])
= line_chart site_stats_resources_path(resource: resource, **@chart_params),
**@chart_options.merge(StatsController::EXTRA_OPTIONS[resource])

View file

@ -1,5 +1,5 @@
.row.justify-content-center
.col.col-md-8
.col.col-lg-8
%h1= t('.title')
-# Una tabla de usuaries y otra de invitades, con acciones
@ -8,16 +8,16 @@
.btn-group{ role: 'group', 'aria-label': t('.actions') }
- if @policy.invite?
= link_to t('.invite'),
site_usuaries_invite_path(@site, invite_as: u.to_s),
class: 'btn btn-secondary',
data: { toggle: 'tooltip' },
title: t('.help.invite', invite_as: u.to_s)
site_usuaries_invite_path(@site, invite_as: u.to_s),
class: 'btn btn-secondary',
data: { toggle: 'tooltip' },
title: t('.help.invite', invite_as: u.to_s)
- if policy(Collaboration.new(@site)).collaborate?
= link_to t('.public_invite'),
site_collaborate_path(@site),
class: 'btn btn-secondary',
data: { toggle: 'tooltip' },
title: t('.help.public_invite')
site_collaborate_path(@site),
class: 'btn btn-secondary',
data: { toggle: 'tooltip' },
title: t('.help.public_invite')
%p.lead= t(".help.#{u}")
%table.table.table-condensed
%tbody
@ -34,28 +34,28 @@
%span.badge.badge-info= t('.invited')
%td
.btn-group{ role: 'group',
aria: { label: t('.individual_actions') } }
aria: { label: t('.individual_actions') } }
- if @policy.demote? && @site.usuarie?(cuenta)
= link_to t('.demote.text'),
site_usuarie_demote_path(@site, cuenta),
class: 'btn btn-secondary',
data: { toggle: 'tooltip',
confirm: t('.demote.confirm') },
title: t('.help.demote'),
method: :patch
site_usuarie_demote_path(@site, cuenta),
class: 'btn btn-secondary',
data: { toggle: 'tooltip',
confirm: t('.demote.confirm') },
title: t('.help.demote'),
method: :patch
- if @policy.promote? && @site.invitade?(cuenta)
= link_to t('.promote.text'),
site_usuarie_promote_path(@site, cuenta),
class: 'btn btn-secondary',
data: { toggle: 'tooltip',
confirm: t('.promote.confirm') },
title: t('.help.promote'),
method: :patch
site_usuarie_promote_path(@site, cuenta),
class: 'btn btn-secondary',
data: { toggle: 'tooltip',
confirm: t('.promote.confirm') },
title: t('.help.promote'),
method: :patch
- if @policy.destroy?
= link_to t('.destroy.text'),
site_usuarie_path(@site, cuenta),
class: 'btn btn-secondary',
data: { toggle: 'tooltip',
confirm: t('.destroy.confirm') },
title: t('.help.destroy'),
method: :delete
site_usuarie_path(@site, cuenta),
class: 'btn btn-secondary',
data: { toggle: 'tooltip',
confirm: t('.destroy.confirm') },
title: t('.help.destroy'),
method: :delete

View file

@ -1,7 +1,7 @@
- invite_as = t("usuaries.invite_as.#{params[:invite_as]}")
.row.justify-content-center
.col.col-md-8
.col.col-lg-8
%h1= t('.title', invite_as: invite_as)
= form_with url: site_usuaries_invite_path(@site), local: true do |f|
@ -9,8 +9,8 @@
.form-group
= f.label :invitaciones do
= t('.invitaciones')
%small.text-muted.form-text= t('.help.invitaciones',
invite_as: invite_as)
%small.text-muted.form-text
= t('.help.invitaciones', invite_as: invite_as)
= f.text_area :invitaciones, class: 'form-control'
.form-group
= f.submit t('.submit'), class: 'btn btn-secondary'

View file

@ -3,7 +3,7 @@ set -e
# Volcar y eliminar todos los access logs de dos días atrás
date="`dateadd today -1d`"
file="/srv/http/_storage/${date}.psql.gz"
file="/srv/_storage/${date}.psql.gz"
test -n "${date}"
test ! -f "${file}"

View file

@ -68,7 +68,6 @@ module Sutty
config.active_record.schema_format = :sql
config.to_prepare do
# Load application's model / class decorators
Dir.glob(File.join(File.dirname(__FILE__), '..', 'app', '**', '*_decorator.rb')).sort.each do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
@ -87,7 +86,7 @@ module Sutty
end
def nodes
@nodes ||= ENV.fetch('SUTTY_NODES', '').split(',')
@nodes ||= ENV.fetch('SUTTY_NODES', 'anarres.sutty.nl').split(',')
end
end
end

View file

@ -26,8 +26,4 @@ test:
user: <%= ENV['USER'] %>
production:
adapter: postgresql
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
database: <%= ENV.fetch('DATABASE') { 'sutty' } %>
user: sutty
host: postgresql
url: <%= ENV['DATABASE_URL'] %>

View file

@ -46,10 +46,19 @@ Rails.application.configure do
# The :test delivery method accumulates sent emails in the
# ActionMailer::Base.deliveries array.
config.action_mailer.delivery_method = :test
config.action_mailer.default_options = { from: ENV['DEFAULT_FROM'] }
config.action_mailer.default_options = { from: ENV.fetch('DEFAULT_FROM', nil) }
config.action_mailer.default_url_options = { host: 'localhost',
port: 3000 }
config.middleware.use ExceptionNotification::Rack,
error_grouping: true,
email: {
email_prefix: '',
sender_address: ENV.fetch('DEFAULT_FROM', nil),
exception_recipients: ENV.fetch('EXCEPTION_TO', nil),
normalize_subject: true
}
# Print deprecation notices to the stderr.
config.active_support.deprecation = :stderr

View file

@ -334,10 +334,6 @@ en:
title: Synchronize to another Sutty node
success: Success!
error: Error
deploy_distributed_press:
title: Distributed Web
success: Success!
error: Error
help: You can contact us by replying to this e-mail
maintenance_mailer:
notice:
@ -508,7 +504,7 @@ en:
storage network may continue retaining copies of the data
indefinitely.
[Learn more](https://sutty.nl/learn-more-about-publish-to-dweb-functionality/)
[Learn more](https://sutty.nl/en/learn-more-about-publish-to-dweb-functionality/)
deploy_social_distributed_press:
title: 'Publish on the Fediverse'
help: |
@ -631,8 +627,8 @@ en:
help: Please, look for the invalid fields to fix them
help:
name: "This will be the host name for your site, ie. **example**.sutty.nl. Choose an expression up to 63 characters. It can contain only lowercase letters, numbers and dashes, **and no spaces**. It can't start or end with a dash, or be entirely composed of numbers."
title: 'The title can be anything you want'
description: 'You site description that appears in search engines. Between 50 and 160 characters.'
title: 'The title can be anything you want.'
description: 'You site description that appears in search engines. Between 10 and 160 characters.'
design: 'Select the design for your site. We add more designs from time to time!'
licencia: 'Everything we publish has automatic copyright. This
means nobody can use our works without explicit permission. By
@ -709,6 +705,9 @@ en:
next: Next page
empty: "There are no results for those search parameters."
caption: Post list
filters:
title: Filters
submit: Submit
attribute_ro:
file:
download: Download file
@ -746,6 +745,12 @@ en:
image:
label: Image
destroy: Remove image
audio:
label: Audio file
logo:
label: Logo
download:
label: Archivo
belongs_to:
empty: "(Empty)"
predefined_value:
@ -766,7 +771,7 @@ en:
date: 'date'
order: 'Order'
content: 'Text'
new: 'Post types'
new: 'Add content'
remove_filter_help: 'Remove the filter: %{filter}'
categories: 'Everything'
index:
@ -923,14 +928,14 @@ en:
queries:
show:
empty: '(empty)'
build_stats:
index:
title: "Publications"
schemas:
add:
add: 'Add'
filter:
filter: 'Filter'
remove: 'Back'
build_stats:
index:
title: "Publications"
indexed_posts:
deleted: "Deleted indexed post %{path} from %{site} (records: %{records})"

View file

@ -317,10 +317,6 @@ es:
title: Fediverso
success: ¡Éxito!
error: Hubo un error
deploy_reindex:
title: Reindexación
success: ¡Éxito!
error: Hubo un error
deploy_localized_domain:
title: Dominio según idioma
success: ¡Éxito!
@ -329,12 +325,12 @@ es:
title: Sincronizar al servidor alternativo
success: ¡Éxito!
error: Hubo un error
deploy_full_rsync:
title: Sincronizar a otro nodo de Sutty
deploy_reindex:
title: Reindexación
success: ¡Éxito!
error: Hubo un error
deploy_distributed_press:
title: Web distribuida
deploy_full_rsync:
title: Sincronizar a otro nodo de Sutty
success: ¡Éxito!
error: Hubo un error
help: Por cualquier duda, responde este correo para contactarte con nosotres.
@ -637,7 +633,7 @@ es:
help:
name: 'El nombre de tu sitio que formará parte de la dirección (**ejemplo**.sutty.nl). Solo puede contener hasta 63 letras minúsculas, números y guiones, pero **sin espacios**. No puede empezar ni terminar con guión, ni estar compuesto enteramente por números.'
title: 'El título de tu sitio puede ser lo que quieras.'
description: 'La descripción del sitio, que saldrá en buscadores. Entre 50 y 160 caracteres.'
description: 'La descripción del sitio, que saldrá en buscadores. Entre 10 y 160 caracteres.'
design: 'Elegí el diseño que va a tener tu sitio aquí. De tanto en tanto vamos sumando diseños nuevos.'
licencia: 'Todo lo que publicamos posee automáticamente derechos
de autore. Esto significa que nadie puede hacer uso de nuestras
@ -717,6 +713,9 @@ es:
next: Página siguiente
empty: No hay artículos con estos parámetros de búsqueda.
caption: Lista de artículos
filters:
title: Filtros
submit: Aplicar
attribute_ro:
file:
download: Descargar archivo
@ -754,6 +753,12 @@ es:
image:
label: Imagen
destroy: 'Eliminar imagen'
logo:
label: Logo
audio:
label: Audio
download:
label: Archivo
belongs_to:
empty: "(Vacío)"
predefined_value:
@ -775,7 +780,7 @@ es:
order: 'Posición'
content: 'Cuerpo del artículo'
categories: 'Todos'
new: 'Tipos de artículos'
new: 'Agregar contenido'
remove_filter_help: 'Quitar este filtro: %{filter}'
index:
search: 'Buscar'
@ -931,14 +936,14 @@ es:
queries:
show:
empty: '(vacío)'
build_stats:
index:
title: "Publicaciones"
schemas:
add:
add: 'Agregar'
filter:
filter: 'Filtrar'
remove: 'Volver'
build_stats:
index:
title: "Publicaciones"
indexed_posts:
deleted: "Eliminado artículo %{path} de %{site} (filas: %{records})"

View file

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

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

View file

@ -9,6 +9,27 @@ SET xmloption = content;
SET client_min_messages = warning;
SET row_security = off;
--
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
--
-- *not* creating schema, since initdb creates it
--
-- Name: dblink; Type: EXTENSION; Schema: -; Owner: -
--
CREATE EXTENSION IF NOT EXISTS dblink WITH SCHEMA public;
--
-- Name: EXTENSION dblink; Type: COMMENT; Schema: -; Owner: -
--
COMMENT ON EXTENSION dblink IS 'connect to other PostgreSQL databases from within a database';
--
-- Name: pg_trgm; Type: EXTENSION; Schema: -; Owner: -
--
@ -2600,6 +2621,13 @@ ALTER TABLE ONLY public.active_storage_attachments
ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id);
--
-- Name: publisher; Type: PUBLICATION; Schema: -; Owner: -
--
CREATE PUBLICATION publisher FOR ALL TABLES WITH (publish = 'insert, update, delete, truncate');
--
-- PostgreSQL database dump complete
--

View file

@ -15,7 +15,7 @@ check program fediblocks
if status != 0 then alert
check program access_logs
with path "/srv/http/bin/access_logs" as uid "app" and gid "www-data"
with path "/srv/bin/access_logs" as uid "rails" and gid "www-data"
every "0 0 * * *"
if status != 0 then alert

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more