mirror of
https://0xacab.org/sutty/sutty
synced 2025-02-22 04:01:47 +00:00
Merge branch 'rails' into recuperar-partials
This commit is contained in:
commit
55bd712291
147 changed files with 4422 additions and 2071 deletions
10
.env.example
10
.env.example
|
@ -1,6 +1,11 @@
|
||||||
RAILS_ENV=
|
RAILS_GROUPS=assets
|
||||||
|
DELEGATE=athshe.sutty.nl
|
||||||
|
HAINISH=../haini.sh/haini.sh
|
||||||
|
DATABASE=
|
||||||
|
RAILS_ENV=development
|
||||||
IMAP_SERVER=
|
IMAP_SERVER=
|
||||||
DEFAULT_FROM=
|
DEFAULT_FROM=
|
||||||
|
EXCEPTION_TO=
|
||||||
SKEL_SUTTY=https://0xacab.org/sutty/skel.sutty.nl
|
SKEL_SUTTY=https://0xacab.org/sutty/skel.sutty.nl
|
||||||
# XXX: Si cambiás esta variable, tenés que editar config/webpacker.yml también :(
|
# XXX: Si cambiás esta variable, tenés que editar config/webpacker.yml también :(
|
||||||
SUTTY=sutty.local
|
SUTTY=sutty.local
|
||||||
|
@ -27,3 +32,6 @@ TIENDA=tienda.sutty.local
|
||||||
PANEL_URL=https://panel.sutty.nl
|
PANEL_URL=https://panel.sutty.nl
|
||||||
AIRBRAKE_SITE_ID=1
|
AIRBRAKE_SITE_ID=1
|
||||||
AIRBRAKE_API_KEY=
|
AIRBRAKE_API_KEY=
|
||||||
|
GITLAB_URI=https://0xacab.org
|
||||||
|
GITLAB_PROJECT=
|
||||||
|
GITLAB_TOKEN=
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
2.7.1
|
|
35
Dockerfile
35
Dockerfile
|
@ -2,20 +2,22 @@
|
||||||
# el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas
|
# el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas
|
||||||
# como el tarball van a tener que cambiar porque ya vamos a haber hecho
|
# como el tarball van a tener que cambiar porque ya vamos a haber hecho
|
||||||
# un clone/pull limpio.
|
# un clone/pull limpio.
|
||||||
FROM alpine:3.13.4 AS build
|
FROM alpine:3.13.6 AS build
|
||||||
MAINTAINER "f <f@sutty.nl>"
|
MAINTAINER "f <f@sutty.nl>"
|
||||||
|
|
||||||
ARG RAILS_MASTER_KEY
|
ARG RAILS_MASTER_KEY
|
||||||
|
ARG BRANCH
|
||||||
|
|
||||||
# Un entorno base
|
# Un entorno base
|
||||||
|
ENV BRANCH=$BRANCH
|
||||||
ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake
|
ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake
|
||||||
ENV RAILS_ENV production
|
ENV RAILS_ENV production
|
||||||
ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
|
ENV RAILS_MASTER_KEY=$RAILS_MASTER_KEY
|
||||||
|
|
||||||
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler ruby-json ruby-bigdecimal ruby-rake
|
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake
|
||||||
RUN apk add --no-cache postgresql-libs git yarn brotli libssh2 python3
|
RUN apk add --no-cache postgresql-libs git yarn brotli libssh2 python3
|
||||||
|
|
||||||
RUN test "2.7.3" = `ruby -e 'puts RUBY_VERSION'`
|
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
|
||||||
|
|
||||||
# https://github.com/rubygems/rubygems/issues/2918
|
# https://github.com/rubygems/rubygems/issues/2918
|
||||||
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
|
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
|
||||||
|
@ -27,7 +29,7 @@ RUN cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/rubygems-platform-musl.patch
|
||||||
RUN addgroup -g 82 -S www-data
|
RUN addgroup -g 82 -S www-data
|
||||||
RUN adduser -s /bin/sh -G www-data -h /home/app -D app
|
RUN adduser -s /bin/sh -G www-data -h /home/app -D app
|
||||||
RUN install -dm750 -o app -g www-data /home/app/sutty
|
RUN install -dm750 -o app -g www-data /home/app/sutty
|
||||||
RUN gem install --no-document bundler
|
RUN gem install --no-document bundler:2.1.4
|
||||||
|
|
||||||
# Empezamos con la usuaria app
|
# Empezamos con la usuaria app
|
||||||
USER app
|
USER app
|
||||||
|
@ -37,7 +39,8 @@ WORKDIR /home/app/sutty
|
||||||
# Copiamos solo el Gemfile para poder instalar las gemas necesarias
|
# Copiamos solo el Gemfile para poder instalar las gemas necesarias
|
||||||
COPY --chown=app:www-data ./Gemfile .
|
COPY --chown=app:www-data ./Gemfile .
|
||||||
COPY --chown=app:www-data ./Gemfile.lock .
|
COPY --chown=app:www-data ./Gemfile.lock .
|
||||||
RUN bundle config set no-cache 'true'
|
RUN bundle config set no-cache true
|
||||||
|
RUN bundle config set specific_platform true
|
||||||
RUN bundle install --path=./vendor --without='test development'
|
RUN bundle install --path=./vendor --without='test development'
|
||||||
# Vaciar la caché
|
# Vaciar la caché
|
||||||
RUN rm vendor/ruby/2.7.0/cache/*.gem
|
RUN rm vendor/ruby/2.7.0/cache/*.gem
|
||||||
|
@ -47,22 +50,17 @@ COPY --chown=app:www-data ./.git/ ./.git/
|
||||||
# Hacer un clon limpio del repositorio en lugar de copiar todos los
|
# Hacer un clon limpio del repositorio en lugar de copiar todos los
|
||||||
# archivos
|
# archivos
|
||||||
RUN cd .. && git clone sutty checkout
|
RUN cd .. && git clone sutty checkout
|
||||||
|
RUN cd ../checkout && git checkout $BRANCH
|
||||||
|
|
||||||
WORKDIR /home/app/checkout
|
WORKDIR /home/app/checkout
|
||||||
# Traer las gemas:
|
# Traer las gemas:
|
||||||
|
RUN rm -rf ./vendor
|
||||||
RUN mv ../sutty/vendor ./vendor
|
RUN mv ../sutty/vendor ./vendor
|
||||||
RUN mv ../sutty/.bundle ./.bundle
|
RUN mv ../sutty/.bundle ./.bundle
|
||||||
|
|
||||||
# Instalar secretos
|
# Instalar secretos
|
||||||
COPY --chown=app:root ./config/credentials.yml.enc ./config/
|
COPY --chown=app:root ./config/credentials.yml.enc ./config/
|
||||||
# Traer los assets pre-compilados
|
|
||||||
COPY --chown=app:www-data ./public/assets ./public/assets
|
|
||||||
COPY --chown=app:www-data ./public/packs ./public/packs
|
|
||||||
|
|
||||||
# Eliminar la necesidad de un runtime JS en producción, porque los
|
|
||||||
# assets ya están pre-compilados.
|
|
||||||
RUN sed -re "/(sassc|uglifier|bootstrap|coffee-rails)/d" -i Gemfile
|
|
||||||
RUN bundle clean
|
|
||||||
RUN rm -rf ./node_modules ./tmp/cache ./.git ./test ./doc
|
RUN rm -rf ./node_modules ./tmp/cache ./.git ./test ./doc
|
||||||
# Eliminar archivos innecesarios
|
# Eliminar archivos innecesarios
|
||||||
USER root
|
USER root
|
||||||
|
@ -70,7 +68,7 @@ RUN apk add --no-cache findutils
|
||||||
RUN find /home/app/checkout/vendor/ruby/2.7.0 -maxdepth 3 -type d -name test -o -name spec -o -name rubocop | xargs -r rm -rf
|
RUN find /home/app/checkout/vendor/ruby/2.7.0 -maxdepth 3 -type d -name test -o -name spec -o -name rubocop | xargs -r rm -rf
|
||||||
|
|
||||||
# Contenedor final
|
# Contenedor final
|
||||||
FROM sutty/monit:latest
|
FROM registry.nulo.in/sutty/monit:3.13.6
|
||||||
ENV RAILS_ENV production
|
ENV RAILS_ENV production
|
||||||
|
|
||||||
# Pandoc
|
# Pandoc
|
||||||
|
@ -78,13 +76,13 @@ RUN echo 'http://dl-cdn.alpinelinux.org/alpine/edge/testing' >> /etc/apk/reposit
|
||||||
|
|
||||||
# Instalar las dependencias, separamos la librería de base de datos para
|
# Instalar las dependencias, separamos la librería de base de datos para
|
||||||
# poder reutilizar este primer paso desde otros contenedores
|
# poder reutilizar este primer paso desde otros contenedores
|
||||||
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler ruby-json ruby-bigdecimal ruby-rake ruby-irb
|
RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-json ruby-bigdecimal ruby-rake ruby-irb ruby-io-console ruby-etc
|
||||||
RUN apk add --no-cache postgresql-libs libssh2 file rsync git jpegoptim vips
|
RUN apk add --no-cache postgresql-libs libssh2 file rsync git jpegoptim vips
|
||||||
RUN apk add --no-cache ffmpeg imagemagick pandoc tectonic oxipng jemalloc
|
RUN apk add --no-cache ffmpeg imagemagick pandoc tectonic oxipng jemalloc
|
||||||
RUN apk add --no-cache git-lfs
|
RUN apk add --no-cache git-lfs openssh-client patch
|
||||||
|
|
||||||
# Chequear que la versión de ruby sea la correcta
|
# Chequear que la versión de ruby sea la correcta
|
||||||
RUN test "2.7.3" = `ruby -e 'puts RUBY_VERSION'`
|
RUN test "2.7.4" = `ruby -e 'puts RUBY_VERSION'`
|
||||||
|
|
||||||
# https://github.com/rubygems/rubygems/issues/2918
|
# https://github.com/rubygems/rubygems/issues/2918
|
||||||
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
|
# https://gitlab.alpinelinux.org/alpine/aports/issues/10808
|
||||||
|
@ -96,7 +94,7 @@ RUN apk add --no-cache patch && cd /usr/lib/ruby/2.7.0 && patch -Np 0 -i /tmp/ru
|
||||||
# principal
|
# principal
|
||||||
RUN apk add --no-cache yarn
|
RUN apk add --no-cache yarn
|
||||||
# Instalar foreman para poder correr los servicios
|
# Instalar foreman para poder correr los servicios
|
||||||
RUN gem install --no-document --no-user-install bundler foreman
|
RUN gem install --no-document --no-user-install bundler:2.1.4 foreman
|
||||||
|
|
||||||
# Agregar el grupo del servidor web y la usuaria
|
# Agregar el grupo del servidor web y la usuaria
|
||||||
RUN addgroup -g 82 -S www-data
|
RUN addgroup -g 82 -S www-data
|
||||||
|
@ -110,13 +108,10 @@ RUN rm -rf /srv/http/_sites /srv/http/_deploy
|
||||||
RUN ln -s data/_storage /srv/http/_storage
|
RUN ln -s data/_storage /srv/http/_storage
|
||||||
RUN ln -s data/_sites /srv/http/_sites
|
RUN ln -s data/_sites /srv/http/_sites
|
||||||
RUN ln -s data/_deploy /srv/http/_deploy
|
RUN ln -s data/_deploy /srv/http/_deploy
|
||||||
RUN ln -s data/_public /srv/http/_public
|
|
||||||
RUN ln -s data/_private /srv/http/_private
|
RUN ln -s data/_private /srv/http/_private
|
||||||
|
|
||||||
# Volver a root para cerrar la compilación
|
# Volver a root para cerrar la compilación
|
||||||
USER root
|
USER root
|
||||||
# Sincronizar los assets a un directorio compartido
|
|
||||||
RUN install -m 755 /srv/http/sync_assets.sh /usr/local/bin/sync_assets
|
|
||||||
# Instalar la configuración de monit
|
# Instalar la configuración de monit
|
||||||
RUN install -m 640 -o root -g root /srv/http/monit.conf /etc/monit.d/sutty.conf
|
RUN install -m 640 -o root -g root /srv/http/monit.conf /etc/monit.d/sutty.conf
|
||||||
RUN apk add --no-cache daemonize ruby-webrick
|
RUN apk add --no-cache daemonize ruby-webrick
|
||||||
|
|
47
Gemfile
47
Gemfile
|
@ -1,18 +1,7 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
# TODO: Podríamos usar solo gems.sutty.nl pero por alguna razón bundler
|
puts 'Usa haini.sh para generar un entorno de trabajo reproducible'
|
||||||
# prefiere x86_64-linux-musl antes que x86_64-linux y ya perdimos mucho
|
source 'https://gems.sutty.nl'
|
||||||
# tiempo buscando soporte para musl
|
|
||||||
if ENV['RAILS_ENV'] == 'production'
|
|
||||||
source 'https://gems.sutty.nl'
|
|
||||||
else
|
|
||||||
source 'https://rubygems.org'
|
|
||||||
end
|
|
||||||
|
|
||||||
git_source(:github) do |repo_name|
|
|
||||||
repo_name = "#{repo_name}/#{repo_name}" unless repo_name.include?('/')
|
|
||||||
"https://github.com/#{repo_name}.git"
|
|
||||||
end
|
|
||||||
|
|
||||||
ruby '~> 2.7'
|
ruby '~> 2.7'
|
||||||
|
|
||||||
|
@ -22,13 +11,18 @@ gem 'dotenv-rails', require: 'dotenv/rails-now'
|
||||||
gem 'rails', '~> 6'
|
gem 'rails', '~> 6'
|
||||||
# Use Puma as the app server
|
# Use Puma as the app server
|
||||||
gem 'puma'
|
gem 'puma'
|
||||||
# See https://github.com/rails/execjs#readme for more supported runtimes
|
|
||||||
# gem 'therubyracer', platforms: :ruby
|
# Solo incluir las gemas cuando estemos en desarrollo o compilando los
|
||||||
# Use SCSS for stylesheets
|
# assets. No es necesario instalarlas en producción.
|
||||||
gem 'sassc-rails'
|
#
|
||||||
# Use Uglifier as compressor for JavaScript assets
|
# XXX: Supuestamente Rails ya soporta RAILS_GROUPS, pero Bundler no.
|
||||||
gem 'uglifier', '>= 1.3.0'
|
if ENV['RAILS_GROUPS']&.split(',')&.include? 'assets'
|
||||||
gem 'bootstrap', '~> 4'
|
gem 'sassc-rails'
|
||||||
|
gem 'uglifier', '>= 1.3.0'
|
||||||
|
gem 'bootstrap', '~> 4'
|
||||||
|
end
|
||||||
|
|
||||||
|
gem 'nokogiri'
|
||||||
|
|
||||||
# Turbolinks makes navigating your web application faster. Read more:
|
# Turbolinks makes navigating your web application faster. Read more:
|
||||||
# https://github.com/turbolinks/turbolinks
|
# https://github.com/turbolinks/turbolinks
|
||||||
|
@ -39,6 +33,7 @@ gem 'jbuilder', '~> 2.5'
|
||||||
# Use ActiveModel has_secure_password
|
# Use ActiveModel has_secure_password
|
||||||
gem 'bcrypt', '~> 3.1.7'
|
gem 'bcrypt', '~> 3.1.7'
|
||||||
gem 'blazer'
|
gem 'blazer'
|
||||||
|
gem 'chartkick'
|
||||||
gem 'commonmarker'
|
gem 'commonmarker'
|
||||||
gem 'devise'
|
gem 'devise'
|
||||||
gem 'devise-i18n'
|
gem 'devise-i18n'
|
||||||
|
@ -52,22 +47,24 @@ gem 'hiredis'
|
||||||
gem 'image_processing'
|
gem 'image_processing'
|
||||||
gem 'icalendar'
|
gem 'icalendar'
|
||||||
gem 'inline_svg'
|
gem 'inline_svg'
|
||||||
|
gem 'httparty'
|
||||||
gem 'safe_yaml', source: 'https://gems.sutty.nl'
|
gem 'safe_yaml', source: 'https://gems.sutty.nl'
|
||||||
gem 'jekyll', '~> 4.2'
|
gem 'jekyll', '~> 4.2'
|
||||||
gem 'jekyll-data', source: 'https://gems.sutty.nl'
|
gem 'jekyll-data', source: 'https://gems.sutty.nl'
|
||||||
gem 'jekyll-commonmark'
|
gem 'jekyll-commonmark'
|
||||||
gem 'jekyll-images'
|
gem 'jekyll-images'
|
||||||
gem 'jekyll-include-cache'
|
gem 'jekyll-include-cache'
|
||||||
gem 'sutty-liquid'
|
gem 'sutty-liquid', '>= 0.7.3'
|
||||||
|
gem 'loaf'
|
||||||
gem 'lockbox'
|
gem 'lockbox'
|
||||||
gem 'mini_magick'
|
gem 'mini_magick'
|
||||||
gem 'mobility'
|
gem 'mobility'
|
||||||
gem 'pg'
|
|
||||||
gem 'pundit'
|
gem 'pundit'
|
||||||
gem 'rails-i18n'
|
gem 'rails-i18n'
|
||||||
gem 'rails_warden'
|
gem 'rails_warden'
|
||||||
gem 'redis', require: %w[redis redis/connection/hiredis]
|
gem 'redis', require: %w[redis redis/connection/hiredis]
|
||||||
gem 'redis-rails'
|
gem 'redis-rails'
|
||||||
|
gem 'rollups', git: 'https://github.com/ankane/rollup.git', branch: 'master'
|
||||||
gem 'rubyzip'
|
gem 'rubyzip'
|
||||||
gem 'rugged'
|
gem 'rugged'
|
||||||
gem 'concurrent-ruby-ext'
|
gem 'concurrent-ruby-ext'
|
||||||
|
@ -77,6 +74,12 @@ gem 'terminal-table'
|
||||||
gem 'validates_hostname'
|
gem 'validates_hostname'
|
||||||
gem 'webpacker'
|
gem 'webpacker'
|
||||||
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
|
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
|
||||||
|
gem 'kaminari'
|
||||||
|
|
||||||
|
# database
|
||||||
|
gem 'hairtrigger'
|
||||||
|
gem 'pg'
|
||||||
|
gem 'pg_search'
|
||||||
|
|
||||||
# performance
|
# performance
|
||||||
gem 'flamegraph'
|
gem 'flamegraph'
|
||||||
|
|
411
Gemfile.lock
411
Gemfile.lock
|
@ -6,6 +6,15 @@ GIT
|
||||||
rails (>= 3.0)
|
rails (>= 3.0)
|
||||||
rake (>= 0.8.7)
|
rake (>= 0.8.7)
|
||||||
|
|
||||||
|
GIT
|
||||||
|
remote: https://github.com/ankane/rollup.git
|
||||||
|
revision: 0ab6c603450175eb1004f7793e86486943cb9f72
|
||||||
|
branch: master
|
||||||
|
specs:
|
||||||
|
rollups (0.1.3)
|
||||||
|
activesupport (>= 5.1)
|
||||||
|
groupdate (>= 5.2)
|
||||||
|
|
||||||
GIT
|
GIT
|
||||||
remote: https://github.com/fauno/email_address
|
remote: https://github.com/fauno/email_address
|
||||||
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
|
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
|
||||||
|
@ -18,66 +27,66 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://gems.sutty.nl/
|
remote: https://gems.sutty.nl/
|
||||||
specs:
|
specs:
|
||||||
actioncable (6.1.3.1)
|
actioncable (6.1.4.1)
|
||||||
actionpack (= 6.1.3.1)
|
actionpack (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
actionmailbox (6.1.3.1)
|
actionmailbox (6.1.4.1)
|
||||||
actionpack (= 6.1.3.1)
|
actionpack (= 6.1.4.1)
|
||||||
activejob (= 6.1.3.1)
|
activejob (= 6.1.4.1)
|
||||||
activerecord (= 6.1.3.1)
|
activerecord (= 6.1.4.1)
|
||||||
activestorage (= 6.1.3.1)
|
activestorage (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
mail (>= 2.7.1)
|
mail (>= 2.7.1)
|
||||||
actionmailer (6.1.3.1)
|
actionmailer (6.1.4.1)
|
||||||
actionpack (= 6.1.3.1)
|
actionpack (= 6.1.4.1)
|
||||||
actionview (= 6.1.3.1)
|
actionview (= 6.1.4.1)
|
||||||
activejob (= 6.1.3.1)
|
activejob (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
mail (~> 2.5, >= 2.5.4)
|
mail (~> 2.5, >= 2.5.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
actionpack (6.1.3.1)
|
actionpack (6.1.4.1)
|
||||||
actionview (= 6.1.3.1)
|
actionview (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
rack (~> 2.0, >= 2.0.9)
|
rack (~> 2.0, >= 2.0.9)
|
||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||||
actiontext (6.1.3.1)
|
actiontext (6.1.4.1)
|
||||||
actionpack (= 6.1.3.1)
|
actionpack (= 6.1.4.1)
|
||||||
activerecord (= 6.1.3.1)
|
activerecord (= 6.1.4.1)
|
||||||
activestorage (= 6.1.3.1)
|
activestorage (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (6.1.3.1)
|
actionview (6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.4)
|
erubi (~> 1.4)
|
||||||
rails-dom-testing (~> 2.0)
|
rails-dom-testing (~> 2.0)
|
||||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||||
activejob (6.1.3.1)
|
activejob (6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (6.1.3.1)
|
activemodel (6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
activerecord (6.1.3.1)
|
activerecord (6.1.4.1)
|
||||||
activemodel (= 6.1.3.1)
|
activemodel (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
activestorage (6.1.3.1)
|
activestorage (6.1.4.1)
|
||||||
actionpack (= 6.1.3.1)
|
actionpack (= 6.1.4.1)
|
||||||
activejob (= 6.1.3.1)
|
activejob (= 6.1.4.1)
|
||||||
activerecord (= 6.1.3.1)
|
activerecord (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
marcel (~> 1.0.0)
|
marcel (~> 1.0.0)
|
||||||
mini_mime (~> 1.0.2)
|
mini_mime (>= 1.1.0)
|
||||||
activesupport (6.1.3.1)
|
activesupport (6.1.4.1)
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.6, < 2)
|
||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
zeitwerk (~> 2.3)
|
zeitwerk (~> 2.3)
|
||||||
addressable (2.7.0)
|
addressable (2.8.0)
|
||||||
public_suffix (>= 2.0.2, < 5.0)
|
public_suffix (>= 2.0.2, < 5.0)
|
||||||
adhesiones-jekyll-theme (0.2.1)
|
adhesiones-jekyll-theme (0.2.1)
|
||||||
jekyll (~> 4.0)
|
jekyll (~> 4.0)
|
||||||
|
@ -89,13 +98,13 @@ GEM
|
||||||
jekyll-relative-urls (~> 0.0)
|
jekyll-relative-urls (~> 0.0)
|
||||||
jekyll-seo-tag (~> 2.1)
|
jekyll-seo-tag (~> 2.1)
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
autoprefixer-rails (10.2.4.0)
|
autoprefixer-rails (10.3.3.0)
|
||||||
execjs
|
execjs (~> 2)
|
||||||
bcrypt (3.1.16)
|
bcrypt (3.1.16-x86_64-linux-musl)
|
||||||
bcrypt_pbkdf (1.1.0)
|
bcrypt_pbkdf (1.1.0-x86_64-linux-musl)
|
||||||
benchmark-ips (2.8.4)
|
benchmark-ips (2.9.2)
|
||||||
bindex (0.8.1)
|
bindex (0.8.1-x86_64-linux-musl)
|
||||||
blazer (2.4.2)
|
blazer (2.4.7)
|
||||||
activerecord (>= 5)
|
activerecord (>= 5)
|
||||||
chartkick (>= 3.2)
|
chartkick (>= 3.2)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
|
@ -104,7 +113,7 @@ GEM
|
||||||
autoprefixer-rails (>= 9.1.0)
|
autoprefixer-rails (>= 9.1.0)
|
||||||
popper_js (>= 1.14.3, < 2)
|
popper_js (>= 1.14.3, < 2)
|
||||||
sassc-rails (>= 2.0.0)
|
sassc-rails (>= 2.0.0)
|
||||||
brakeman (5.0.0)
|
brakeman (5.1.2)
|
||||||
builder (3.2.4)
|
builder (3.2.4)
|
||||||
capybara (2.18.0)
|
capybara (2.18.0)
|
||||||
addressable
|
addressable
|
||||||
|
@ -113,24 +122,24 @@ GEM
|
||||||
rack (>= 1.0.0)
|
rack (>= 1.0.0)
|
||||||
rack-test (>= 0.5.4)
|
rack-test (>= 0.5.4)
|
||||||
xpath (>= 2.0, < 4.0)
|
xpath (>= 2.0, < 4.0)
|
||||||
chartkick (4.0.3)
|
chartkick (4.1.2)
|
||||||
childprocess (3.0.0)
|
childprocess (4.1.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
colorator (1.1.0)
|
colorator (1.1.0)
|
||||||
commonmarker (0.21.2)
|
commonmarker (0.21.2-x86_64-linux-musl)
|
||||||
ruby-enum (~> 0.5)
|
ruby-enum (~> 0.5)
|
||||||
concurrent-ruby (1.1.8)
|
concurrent-ruby (1.1.9)
|
||||||
concurrent-ruby-ext (1.1.8)
|
concurrent-ruby-ext (1.1.9-x86_64-linux-musl)
|
||||||
concurrent-ruby (= 1.1.8)
|
concurrent-ruby (= 1.1.9)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
database_cleaner (2.0.1)
|
database_cleaner (2.0.1)
|
||||||
database_cleaner-active_record (~> 2.0.0)
|
database_cleaner-active_record (~> 2.0.0)
|
||||||
database_cleaner-active_record (2.0.0)
|
database_cleaner-active_record (2.0.1)
|
||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
dead_end (1.1.6)
|
dead_end (3.1.0)
|
||||||
derailed_benchmarks (2.0.1)
|
derailed_benchmarks (2.1.1)
|
||||||
benchmark-ips (~> 2)
|
benchmark-ips (~> 2)
|
||||||
dead_end
|
dead_end
|
||||||
get_process_mem (~> 0)
|
get_process_mem (~> 0)
|
||||||
|
@ -142,25 +151,25 @@ GEM
|
||||||
rake (> 10, < 14)
|
rake (> 10, < 14)
|
||||||
ruby-statistics (>= 2.1)
|
ruby-statistics (>= 2.1)
|
||||||
thor (>= 0.19, < 2)
|
thor (>= 0.19, < 2)
|
||||||
devise (4.7.3)
|
devise (4.8.0)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
orm_adapter (~> 0.1)
|
orm_adapter (~> 0.1)
|
||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise-i18n (1.9.3)
|
devise-i18n (1.10.1)
|
||||||
devise (>= 4.7.1)
|
devise (>= 4.8.0)
|
||||||
devise_invitable (2.0.4)
|
devise_invitable (2.0.5)
|
||||||
actionmailer (>= 5.0)
|
actionmailer (>= 5.0)
|
||||||
devise (>= 4.6)
|
devise (>= 4.6)
|
||||||
dotenv (2.7.6)
|
dotenv (2.7.6)
|
||||||
dotenv-rails (2.7.6)
|
dotenv-rails (2.7.6)
|
||||||
dotenv (= 2.7.6)
|
dotenv (= 2.7.6)
|
||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
down (5.2.0)
|
down (5.2.4)
|
||||||
addressable (~> 2.5)
|
addressable (~> 2.8)
|
||||||
ed25519 (1.2.4)
|
ed25519 (1.2.4-x86_64-linux-musl)
|
||||||
editorial-autogestiva-jekyll-theme (0.3.0)
|
editorial-autogestiva-jekyll-theme (0.3.4)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
jekyll-commonmark (~> 1.3)
|
jekyll-commonmark (~> 1.3)
|
||||||
jekyll-data (~> 1.1)
|
jekyll-data (~> 1.1)
|
||||||
|
@ -179,44 +188,50 @@ GEM
|
||||||
jekyll-unique-urls (~> 0)
|
jekyll-unique-urls (~> 0)
|
||||||
jekyll-write-and-commit-changes (~> 0)
|
jekyll-write-and-commit-changes (~> 0)
|
||||||
sutty-liquid (~> 0)
|
sutty-liquid (~> 0)
|
||||||
em-websocket (0.5.2)
|
em-websocket (0.5.3)
|
||||||
eventmachine (>= 0.12.9)
|
eventmachine (>= 0.12.9)
|
||||||
http_parser.rb (~> 0.6.0)
|
http_parser.rb (~> 0)
|
||||||
errbase (0.2.1)
|
errbase (0.2.1)
|
||||||
erubi (1.10.0)
|
erubi (1.10.0)
|
||||||
eventmachine (1.2.7)
|
eventmachine (1.2.7-x86_64-linux-musl)
|
||||||
exception_notification (4.4.3)
|
exception_notification (4.4.3)
|
||||||
actionmailer (>= 4.0, < 7)
|
actionmailer (>= 4.0, < 7)
|
||||||
activesupport (>= 4.0, < 7)
|
activesupport (>= 4.0, < 7)
|
||||||
execjs (2.7.0)
|
execjs (2.8.1)
|
||||||
factory_bot (6.1.0)
|
factory_bot (6.2.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
factory_bot_rails (6.1.0)
|
factory_bot_rails (6.2.0)
|
||||||
factory_bot (~> 6.1.0)
|
factory_bot (~> 6.2.0)
|
||||||
railties (>= 5.0.0)
|
railties (>= 5.0.0)
|
||||||
fast_blank (1.0.0)
|
fast_blank (1.0.1-x86_64-linux-musl)
|
||||||
fast_jsonparser (0.5.0)
|
fast_jsonparser (0.5.0-x86_64-linux-musl)
|
||||||
ffi (1.15.0)
|
ffi (1.15.4-x86_64-linux-musl)
|
||||||
flamegraph (0.9.5)
|
flamegraph (0.9.5)
|
||||||
forwardable-extended (2.6.0)
|
forwardable-extended (2.6.0)
|
||||||
friendly_id (5.4.2)
|
friendly_id (5.4.2)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
get_process_mem (0.2.7)
|
get_process_mem (0.2.7)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
globalid (0.4.2)
|
globalid (0.6.0)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 5.0)
|
||||||
haml (5.2.1)
|
groupdate (5.2.2)
|
||||||
|
activesupport (>= 5)
|
||||||
|
hairtrigger (0.2.24)
|
||||||
|
activerecord (>= 5.0, < 7)
|
||||||
|
ruby2ruby (~> 2.4)
|
||||||
|
ruby_parser (~> 3.10)
|
||||||
|
haml (5.2.2)
|
||||||
temple (>= 0.8.0)
|
temple (>= 0.8.0)
|
||||||
tilt
|
tilt
|
||||||
haml-lint (0.999.999)
|
haml-lint (0.999.999)
|
||||||
haml_lint
|
haml_lint
|
||||||
haml_lint (0.37.0)
|
haml_lint (0.37.1)
|
||||||
haml (>= 4.0, < 5.3)
|
haml (>= 4.0, < 5.3)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 0.50.0)
|
rubocop (>= 0.50.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hamlit (2.15.0)
|
hamlit (2.15.1-x86_64-linux-musl)
|
||||||
temple (>= 0.8.2)
|
temple (>= 0.8.2)
|
||||||
thor
|
thor
|
||||||
tilt
|
tilt
|
||||||
|
@ -227,25 +242,25 @@ GEM
|
||||||
railties (>= 4.0.1)
|
railties (>= 4.0.1)
|
||||||
heapy (0.2.0)
|
heapy (0.2.0)
|
||||||
thor
|
thor
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3-x86_64-linux-musl)
|
||||||
http_parser.rb (0.6.0)
|
http_parser.rb (0.8.0-x86_64-linux-musl)
|
||||||
httparty (0.18.1)
|
httparty (0.18.1)
|
||||||
mime-types (~> 3.0)
|
mime-types (~> 3.0)
|
||||||
multi_xml (>= 0.5.2)
|
multi_xml (>= 0.5.2)
|
||||||
i18n (1.8.10)
|
i18n (1.8.11)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
icalendar (2.7.1)
|
icalendar (2.7.1)
|
||||||
ice_cube (~> 0.16)
|
ice_cube (~> 0.16)
|
||||||
ice_cube (0.16.3)
|
ice_cube (0.16.4)
|
||||||
image_processing (1.12.1)
|
image_processing (1.12.1)
|
||||||
mini_magick (>= 4.9.5, < 5)
|
mini_magick (>= 4.9.5, < 5)
|
||||||
ruby-vips (>= 2.0.17, < 3)
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
inline_svg (1.7.2)
|
inline_svg (1.7.2)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
jbuilder (2.11.2)
|
jbuilder (2.11.3)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
jekyll (4.2.0)
|
jekyll (4.2.1)
|
||||||
addressable (~> 2.4)
|
addressable (~> 2.4)
|
||||||
colorator (~> 1.0)
|
colorator (~> 1.0)
|
||||||
em-websocket (~> 0.5)
|
em-websocket (~> 0.5)
|
||||||
|
@ -260,8 +275,8 @@ GEM
|
||||||
rouge (~> 3.0)
|
rouge (~> 3.0)
|
||||||
safe_yaml (~> 1.0)
|
safe_yaml (~> 1.0)
|
||||||
terminal-table (~> 2.0)
|
terminal-table (~> 2.0)
|
||||||
jekyll-commonmark (1.3.1)
|
jekyll-commonmark (1.3.2)
|
||||||
commonmarker (~> 0.14)
|
commonmarker (~> 0.14, < 0.22)
|
||||||
jekyll (>= 3.7, < 5.0)
|
jekyll (>= 3.7, < 5.0)
|
||||||
jekyll-data (1.1.2)
|
jekyll-data (1.1.2)
|
||||||
jekyll (>= 3.3, < 5.0.0)
|
jekyll (>= 3.3, < 5.0.0)
|
||||||
|
@ -272,21 +287,19 @@ GEM
|
||||||
jekyll (>= 3.7, < 5.0)
|
jekyll (>= 3.7, < 5.0)
|
||||||
jekyll-hardlinks (0.1.2)
|
jekyll-hardlinks (0.1.2)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
jekyll-ignore-layouts (0.1.0)
|
jekyll-ignore-layouts (0.1.2)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
jekyll-images (0.2.7)
|
jekyll-images (0.3.0)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
ruby-filemagic (~> 0.7)
|
ruby-filemagic (~> 0.7)
|
||||||
ruby-vips (~> 2)
|
ruby-vips (~> 2)
|
||||||
jekyll-include-cache (0.2.1)
|
jekyll-include-cache (0.2.1)
|
||||||
jekyll (>= 3.7, < 5.0)
|
jekyll (>= 3.7, < 5.0)
|
||||||
jekyll-linked-posts (0.2.0)
|
jekyll-linked-posts (0.4.2)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
jekyll-locales (0.1.12)
|
jekyll-locales (0.1.13)
|
||||||
jekyll-lunr (0.2.0)
|
jekyll-lunr (0.3.0)
|
||||||
loofah (~> 2.4)
|
loofah (~> 2.4)
|
||||||
jekyll-node-modules (0.1.0)
|
|
||||||
jekyll (~> 4)
|
|
||||||
jekyll-order (0.1.4)
|
jekyll-order (0.1.4)
|
||||||
jekyll-relative-urls (0.0.6)
|
jekyll-relative-urls (0.0.6)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
|
@ -294,9 +307,9 @@ GEM
|
||||||
sassc (> 2.0.1, < 3.0)
|
sassc (> 2.0.1, < 3.0)
|
||||||
jekyll-seo-tag (2.7.1)
|
jekyll-seo-tag (2.7.1)
|
||||||
jekyll (>= 3.8, < 5.0)
|
jekyll (>= 3.8, < 5.0)
|
||||||
jekyll-spree-client (0.1.12)
|
jekyll-spree-client (0.1.19)
|
||||||
fast_blank (~> 1)
|
fast_blank (~> 1)
|
||||||
spree-api-client (~> 0.2)
|
spree-api-client (>= 0.2.4)
|
||||||
jekyll-turbolinks (0.0.5)
|
jekyll-turbolinks (0.0.5)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
turbolinks-source (~> 5)
|
turbolinks-source (~> 5)
|
||||||
|
@ -304,9 +317,21 @@ GEM
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
jekyll-watch (2.2.1)
|
jekyll-watch (2.2.1)
|
||||||
listen (~> 3.0)
|
listen (~> 3.0)
|
||||||
jekyll-write-and-commit-changes (0.1.2)
|
jekyll-write-and-commit-changes (0.2.1)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
rugged (~> 1)
|
rugged (~> 1)
|
||||||
|
kaminari (1.2.1)
|
||||||
|
activesupport (>= 4.1.0)
|
||||||
|
kaminari-actionview (= 1.2.1)
|
||||||
|
kaminari-activerecord (= 1.2.1)
|
||||||
|
kaminari-core (= 1.2.1)
|
||||||
|
kaminari-actionview (1.2.1)
|
||||||
|
actionview
|
||||||
|
kaminari-core (= 1.2.1)
|
||||||
|
kaminari-activerecord (1.2.1)
|
||||||
|
activerecord
|
||||||
|
kaminari-core (= 1.2.1)
|
||||||
|
kaminari-core (1.2.1)
|
||||||
kramdown (2.3.1)
|
kramdown (2.3.1)
|
||||||
rexml
|
rexml
|
||||||
kramdown-parser-gfm (1.1.0)
|
kramdown-parser-gfm (1.1.0)
|
||||||
|
@ -320,72 +345,77 @@ GEM
|
||||||
rb-fsevent (~> 0.9, >= 0.9.4)
|
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||||
rb-inotify (~> 0.9, >= 0.9.7)
|
rb-inotify (~> 0.9, >= 0.9.7)
|
||||||
ruby_dep (~> 1.2)
|
ruby_dep (~> 1.2)
|
||||||
lockbox (0.6.4)
|
loaf (0.10.0)
|
||||||
|
railties (>= 3.2)
|
||||||
|
lockbox (0.6.6)
|
||||||
lograge (0.11.2)
|
lograge (0.11.2)
|
||||||
actionpack (>= 4)
|
actionpack (>= 4)
|
||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
railties (>= 4)
|
railties (>= 4)
|
||||||
request_store (~> 1.0)
|
request_store (~> 1.0)
|
||||||
loofah (2.9.1)
|
loofah (2.12.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.5.9)
|
nokogiri (>= 1.5.9)
|
||||||
mail (2.7.1)
|
mail (2.7.1)
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
marcel (1.0.1)
|
marcel (1.0.2)
|
||||||
memory_profiler (1.0.0)
|
memory_profiler (1.0.0)
|
||||||
mercenary (0.4.0)
|
mercenary (0.4.0)
|
||||||
method_source (1.0.0)
|
method_source (1.0.0)
|
||||||
mime-types (3.3.1)
|
mime-types (3.4.1)
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2021.0225)
|
mime-types-data (3.2021.1115)
|
||||||
mini_histogram (0.3.1)
|
mini_histogram (0.3.1)
|
||||||
mini_magick (4.11.0)
|
mini_magick (4.11.0)
|
||||||
mini_mime (1.0.3)
|
mini_mime (1.1.2)
|
||||||
mini_portile2 (2.5.0)
|
mini_portile2 (2.6.1)
|
||||||
minima (2.5.1)
|
minima (2.5.1)
|
||||||
jekyll (>= 3.5, < 5.0)
|
jekyll (>= 3.5, < 5.0)
|
||||||
jekyll-feed (~> 0.9)
|
jekyll-feed (~> 0.9)
|
||||||
jekyll-seo-tag (~> 2.1)
|
jekyll-seo-tag (~> 2.1)
|
||||||
minitest (5.14.4)
|
minitest (5.14.4)
|
||||||
mobility (1.1.1)
|
mobility (1.2.4)
|
||||||
i18n (>= 0.6.10, < 2)
|
i18n (>= 0.6.10, < 2)
|
||||||
request_store (~> 1.0)
|
request_store (~> 1.0)
|
||||||
multi_xml (0.6.0)
|
multi_xml (0.6.0)
|
||||||
net-ssh (6.1.0)
|
net-ssh (6.1.0)
|
||||||
netaddr (2.0.4)
|
netaddr (2.0.5)
|
||||||
nio4r (2.5.7)
|
nio4r (2.5.8-x86_64-linux-musl)
|
||||||
nokogiri (1.11.3)
|
nokogiri (1.12.5-x86_64-linux-musl)
|
||||||
mini_portile2 (~> 2.5.0)
|
mini_portile2 (~> 2.6.1)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
parallel (1.20.1)
|
parallel (1.21.0)
|
||||||
parser (3.0.1.0)
|
parser (3.0.2.0)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
pathutil (0.16.2)
|
pathutil (0.16.2)
|
||||||
forwardable-extended (~> 2.6)
|
forwardable-extended (~> 2.6)
|
||||||
pg (1.2.3)
|
pg (1.2.3-x86_64-linux-musl)
|
||||||
|
pg_search (2.3.5)
|
||||||
|
activerecord (>= 5.2)
|
||||||
|
activesupport (>= 5.2)
|
||||||
popper_js (1.16.0)
|
popper_js (1.16.0)
|
||||||
prometheus_exporter (0.7.0)
|
prometheus_exporter (1.0.0)
|
||||||
webrick
|
webrick
|
||||||
pry (0.14.1)
|
pry (0.14.1)
|
||||||
coderay (~> 1.1)
|
coderay (~> 1.1)
|
||||||
method_source (~> 1.0)
|
method_source (~> 1.0)
|
||||||
public_suffix (4.0.6)
|
public_suffix (4.0.6)
|
||||||
puma (5.2.2)
|
puma (5.5.2-x86_64-linux-musl)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.1.0)
|
pundit (2.1.1)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
racc (1.5.2)
|
racc (1.6.0-x86_64-linux-musl)
|
||||||
rack (2.2.3)
|
rack (2.2.3)
|
||||||
rack-cors (1.1.1)
|
rack-cors (1.1.1)
|
||||||
rack (>= 2.0.0)
|
rack (>= 2.0.0)
|
||||||
rack-mini-profiler (2.3.1)
|
rack-mini-profiler (2.3.3)
|
||||||
rack (>= 1.2.0)
|
rack (>= 1.2.0)
|
||||||
rack-proxy (0.6.5)
|
rack-proxy (0.7.0)
|
||||||
rack
|
rack
|
||||||
rack-test (1.1.0)
|
rack-test (1.1.0)
|
||||||
rack (>= 1.0, < 3)
|
rack (>= 1.0, < 3)
|
||||||
radios-comunitarias-jekyll-theme (0.1.4)
|
radios-comunitarias-jekyll-theme (0.1.5)
|
||||||
jekyll (~> 4.0)
|
jekyll (~> 4.0)
|
||||||
jekyll-data (~> 1.1)
|
jekyll-data (~> 1.1)
|
||||||
jekyll-feed (~> 0.9)
|
jekyll-feed (~> 0.9)
|
||||||
|
@ -396,65 +426,66 @@ GEM
|
||||||
jekyll-relative-urls (~> 0.0)
|
jekyll-relative-urls (~> 0.0)
|
||||||
jekyll-seo-tag (~> 2.1)
|
jekyll-seo-tag (~> 2.1)
|
||||||
jekyll-turbolinks (~> 0)
|
jekyll-turbolinks (~> 0)
|
||||||
rails (6.1.3.1)
|
rails (6.1.4.1)
|
||||||
actioncable (= 6.1.3.1)
|
actioncable (= 6.1.4.1)
|
||||||
actionmailbox (= 6.1.3.1)
|
actionmailbox (= 6.1.4.1)
|
||||||
actionmailer (= 6.1.3.1)
|
actionmailer (= 6.1.4.1)
|
||||||
actionpack (= 6.1.3.1)
|
actionpack (= 6.1.4.1)
|
||||||
actiontext (= 6.1.3.1)
|
actiontext (= 6.1.4.1)
|
||||||
actionview (= 6.1.3.1)
|
actionview (= 6.1.4.1)
|
||||||
activejob (= 6.1.3.1)
|
activejob (= 6.1.4.1)
|
||||||
activemodel (= 6.1.3.1)
|
activemodel (= 6.1.4.1)
|
||||||
activerecord (= 6.1.3.1)
|
activerecord (= 6.1.4.1)
|
||||||
activestorage (= 6.1.3.1)
|
activestorage (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 6.1.3.1)
|
railties (= 6.1.4.1)
|
||||||
sprockets-rails (>= 2.0.0)
|
sprockets-rails (>= 2.0.0)
|
||||||
rails-dom-testing (2.0.3)
|
rails-dom-testing (2.0.3)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
rails-html-sanitizer (1.3.0)
|
rails-html-sanitizer (1.4.2)
|
||||||
loofah (~> 2.3)
|
loofah (~> 2.3)
|
||||||
rails-i18n (6.0.0)
|
rails-i18n (6.0.0)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 6.0.0, < 7)
|
railties (>= 6.0.0, < 7)
|
||||||
rails_warden (0.6.0)
|
rails_warden (0.6.0)
|
||||||
warden (>= 1.2.0)
|
warden (>= 1.2.0)
|
||||||
railties (6.1.3.1)
|
railties (6.1.4.1)
|
||||||
actionpack (= 6.1.3.1)
|
actionpack (= 6.1.4.1)
|
||||||
activesupport (= 6.1.3.1)
|
activesupport (= 6.1.4.1)
|
||||||
method_source
|
method_source
|
||||||
rake (>= 0.8.7)
|
rake (>= 0.13)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
rainbow (3.0.0)
|
rainbow (3.0.0)
|
||||||
rake (13.0.3)
|
rake (13.0.6)
|
||||||
rb-fsevent (0.10.4)
|
rb-fsevent (0.11.0)
|
||||||
rb-inotify (0.10.1)
|
rb-inotify (0.10.1)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
recursero-jekyll-theme (0.1.2)
|
recursero-jekyll-theme (0.2.0)
|
||||||
jekyll (~> 4.0)
|
jekyll (~> 4)
|
||||||
|
jekyll-commonmark (~> 1.3)
|
||||||
jekyll-data (~> 1.1)
|
jekyll-data (~> 1.1)
|
||||||
jekyll-feed (~> 0.9)
|
jekyll-dotenv (>= 0.2)
|
||||||
|
jekyll-feed (~> 0.15)
|
||||||
|
jekyll-ignore-layouts (~> 0)
|
||||||
jekyll-images (~> 0.2)
|
jekyll-images (~> 0.2)
|
||||||
jekyll-include-cache (~> 0)
|
jekyll-include-cache (~> 0)
|
||||||
jekyll-linked-posts (~> 0.2)
|
jekyll-linked-posts (~> 0)
|
||||||
jekyll-locales (~> 0.1)
|
jekyll-locales (~> 0.1)
|
||||||
jekyll-lunr (~> 0.1)
|
jekyll-lunr (~> 0.1)
|
||||||
jekyll-node-modules (~> 0.1)
|
jekyll-order (~> 0)
|
||||||
jekyll-order (~> 0.1)
|
jekyll-relative-urls (~> 0)
|
||||||
jekyll-relative-urls (~> 0.0)
|
jekyll-seo-tag (~> 2)
|
||||||
jekyll-seo-tag (~> 2.1)
|
|
||||||
jekyll-turbolinks (~> 0)
|
|
||||||
jekyll-unique-urls (~> 0.1)
|
jekyll-unique-urls (~> 0.1)
|
||||||
sutty-archives (~> 2.2)
|
sutty-archives (~> 2.2)
|
||||||
sutty-liquid (~> 0.1)
|
sutty-liquid (~> 0)
|
||||||
redis (4.2.5)
|
redis (4.5.1)
|
||||||
redis-actionpack (5.2.0)
|
redis-actionpack (5.2.0)
|
||||||
actionpack (>= 5, < 7)
|
actionpack (>= 5, < 7)
|
||||||
redis-rack (>= 2.1.0, < 3)
|
redis-rack (>= 2.1.0, < 3)
|
||||||
redis-store (>= 1.1.0, < 2)
|
redis-store (>= 1.1.0, < 2)
|
||||||
redis-activesupport (5.2.0)
|
redis-activesupport (5.2.1)
|
||||||
activesupport (>= 3, < 7)
|
activesupport (>= 3, < 7)
|
||||||
redis-store (>= 1.3, < 2)
|
redis-store (>= 1.3, < 2)
|
||||||
redis-rack (2.1.3)
|
redis-rack (2.1.3)
|
||||||
|
@ -473,36 +504,41 @@ GEM
|
||||||
actionpack (>= 5.0)
|
actionpack (>= 5.0)
|
||||||
railties (>= 5.0)
|
railties (>= 5.0)
|
||||||
rexml (3.2.5)
|
rexml (3.2.5)
|
||||||
rouge (3.26.0)
|
rouge (3.26.1)
|
||||||
rubocop (1.12.1)
|
rubocop (1.23.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.0.0.0)
|
parser (>= 3.0.0.0)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 1.8, < 3.0)
|
||||||
rexml
|
rexml
|
||||||
rubocop-ast (>= 1.2.0, < 2.0)
|
rubocop-ast (>= 1.12.0, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 1.4.0, < 3.0)
|
unicode-display_width (>= 1.4.0, < 3.0)
|
||||||
rubocop-ast (1.4.1)
|
rubocop-ast (1.13.0)
|
||||||
parser (>= 2.7.1.5)
|
parser (>= 3.0.1.1)
|
||||||
rubocop-rails (2.9.1)
|
rubocop-rails (2.12.4)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
rubocop (>= 0.90.0, < 2.0)
|
rubocop (>= 1.7.0, < 2.0)
|
||||||
ruby-enum (0.9.0)
|
ruby-enum (0.9.0)
|
||||||
i18n
|
i18n
|
||||||
ruby-filemagic (0.7.2)
|
ruby-filemagic (0.7.2-x86_64-linux-musl)
|
||||||
ruby-progressbar (1.11.0)
|
ruby-progressbar (1.11.0)
|
||||||
ruby-statistics (2.1.3)
|
ruby-statistics (3.0.0)
|
||||||
ruby-vips (2.1.0)
|
ruby-vips (2.1.4)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
|
ruby2ruby (2.4.4)
|
||||||
|
ruby_parser (~> 3.1)
|
||||||
|
sexp_processor (~> 4.6)
|
||||||
ruby_dep (1.5.0)
|
ruby_dep (1.5.0)
|
||||||
rubyzip (2.3.0)
|
ruby_parser (3.18.1)
|
||||||
rugged (1.1.0)
|
sexp_processor (~> 4.16)
|
||||||
|
rubyzip (2.3.2)
|
||||||
|
rugged (1.2.0-x86_64-linux-musl)
|
||||||
safe_yaml (1.0.6)
|
safe_yaml (1.0.6)
|
||||||
safely_block (0.3.0)
|
safely_block (0.3.0)
|
||||||
errbase (>= 0.1.1)
|
errbase (>= 0.1.1)
|
||||||
sassc (2.4.0)
|
sassc (2.4.0-x86_64-linux-musl)
|
||||||
ffi (~> 1.9)
|
ffi (~> 1.9)
|
||||||
sassc-rails (2.1.2)
|
sassc-rails (2.1.2)
|
||||||
railties (>= 4.0.0)
|
railties (>= 4.0.0)
|
||||||
|
@ -510,10 +546,12 @@ GEM
|
||||||
sprockets (> 3.0)
|
sprockets (> 3.0)
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
tilt
|
tilt
|
||||||
selenium-webdriver (3.142.7)
|
selenium-webdriver (4.1.0)
|
||||||
childprocess (>= 0.5, < 4.0)
|
childprocess (>= 0.5, < 5.0)
|
||||||
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2)
|
rubyzip (>= 1.2.2)
|
||||||
semantic_range (3.0.0)
|
semantic_range (3.0.0)
|
||||||
|
sexp_processor (4.16.0)
|
||||||
share-to-fediverse-jekyll-theme (0.1.4)
|
share-to-fediverse-jekyll-theme (0.1.4)
|
||||||
jekyll (~> 4.0)
|
jekyll (~> 4.0)
|
||||||
jekyll-data (~> 1.1)
|
jekyll-data (~> 1.1)
|
||||||
|
@ -525,7 +563,7 @@ GEM
|
||||||
simpleidn (0.2.1)
|
simpleidn (0.2.1)
|
||||||
unf (~> 0.1.4)
|
unf (~> 0.1.4)
|
||||||
sourcemap (0.1.1)
|
sourcemap (0.1.1)
|
||||||
spree-api-client (0.2.1)
|
spree-api-client (0.2.4)
|
||||||
fast_blank (~> 1)
|
fast_blank (~> 1)
|
||||||
httparty (~> 0.18.0)
|
httparty (~> 0.18.0)
|
||||||
spring (2.1.1)
|
spring (2.1.1)
|
||||||
|
@ -535,12 +573,12 @@ GEM
|
||||||
sprockets (4.0.2)
|
sprockets (4.0.2)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
rack (> 1, < 3)
|
rack (> 1, < 3)
|
||||||
sprockets-rails (3.2.2)
|
sprockets-rails (3.4.1)
|
||||||
actionpack (>= 4.0)
|
actionpack (>= 5.2)
|
||||||
activesupport (>= 4.0)
|
activesupport (>= 5.2)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
sqlite3 (1.4.2)
|
sqlite3 (1.4.2-x86_64-linux-musl)
|
||||||
stackprof (0.2.16)
|
stackprof (0.2.17-x86_64-linux-musl)
|
||||||
sucker_punch (3.0.1)
|
sucker_punch (3.0.1)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
sutty-archives (2.5.4)
|
sutty-archives (2.5.4)
|
||||||
|
@ -562,14 +600,14 @@ GEM
|
||||||
jekyll-include-cache (~> 0)
|
jekyll-include-cache (~> 0)
|
||||||
jekyll-relative-urls (~> 0.0)
|
jekyll-relative-urls (~> 0.0)
|
||||||
jekyll-seo-tag (~> 2.1)
|
jekyll-seo-tag (~> 2.1)
|
||||||
sutty-liquid (0.7.2)
|
sutty-liquid (0.7.4)
|
||||||
fast_blank (~> 1.0)
|
fast_blank (~> 1.0)
|
||||||
jekyll (~> 4)
|
jekyll (~> 4)
|
||||||
sutty-minima (2.5.0)
|
sutty-minima (2.5.0)
|
||||||
jekyll (>= 3.5, < 5.0)
|
jekyll (>= 3.5, < 5.0)
|
||||||
jekyll-feed (~> 0.9)
|
jekyll-feed (~> 0.9)
|
||||||
jekyll-seo-tag (~> 2.1)
|
jekyll-seo-tag (~> 2.1)
|
||||||
symbol-fstring (1.0.0)
|
symbol-fstring (1.0.2-x86_64-linux-musl)
|
||||||
sysexits (1.2.0)
|
sysexits (1.2.0)
|
||||||
temple (0.8.2)
|
temple (0.8.2)
|
||||||
terminal-table (2.0.0)
|
terminal-table (2.0.0)
|
||||||
|
@ -586,33 +624,34 @@ GEM
|
||||||
execjs (>= 0.3.0, < 3)
|
execjs (>= 0.3.0, < 3)
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.7.7)
|
unf_ext (0.0.8-x86_64-linux-musl)
|
||||||
unicode-display_width (1.7.0)
|
unicode-display_width (1.8.0)
|
||||||
validates_hostname (1.0.11)
|
validates_hostname (1.0.11)
|
||||||
activerecord (>= 3.0)
|
activerecord (>= 3.0)
|
||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
web-console (4.1.0)
|
web-console (4.2.0)
|
||||||
actionview (>= 6.0.0)
|
actionview (>= 6.0.0)
|
||||||
activemodel (>= 6.0.0)
|
activemodel (>= 6.0.0)
|
||||||
bindex (>= 0.4.0)
|
bindex (>= 0.4.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
webpacker (5.2.1)
|
webpacker (5.4.3)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
rack-proxy (>= 0.6.1)
|
rack-proxy (>= 0.6.1)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
semantic_range (>= 2.3.0)
|
semantic_range (>= 2.3.0)
|
||||||
webrick (1.7.0)
|
webrick (1.7.0)
|
||||||
websocket-driver (0.7.3)
|
websocket-driver (0.7.5-x86_64-linux-musl)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
xpath (3.2.0)
|
xpath (3.2.0)
|
||||||
nokogiri (~> 1.8)
|
nokogiri (~> 1.8)
|
||||||
zeitwerk (2.4.2)
|
zeitwerk (2.5.1)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
ruby
|
ruby
|
||||||
|
x86_64-linux-musl
|
||||||
|
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
adhesiones-jekyll-theme
|
adhesiones-jekyll-theme
|
||||||
|
@ -622,6 +661,7 @@ DEPENDENCIES
|
||||||
bootstrap (~> 4)
|
bootstrap (~> 4)
|
||||||
brakeman
|
brakeman
|
||||||
capybara (~> 2.13)
|
capybara (~> 2.13)
|
||||||
|
chartkick
|
||||||
commonmarker
|
commonmarker
|
||||||
concurrent-ruby-ext
|
concurrent-ruby-ext
|
||||||
database_cleaner
|
database_cleaner
|
||||||
|
@ -640,9 +680,11 @@ DEPENDENCIES
|
||||||
fast_jsonparser
|
fast_jsonparser
|
||||||
flamegraph
|
flamegraph
|
||||||
friendly_id
|
friendly_id
|
||||||
|
hairtrigger
|
||||||
haml-lint
|
haml-lint
|
||||||
hamlit-rails
|
hamlit-rails
|
||||||
hiredis
|
hiredis
|
||||||
|
httparty
|
||||||
icalendar
|
icalendar
|
||||||
image_processing
|
image_processing
|
||||||
inline_svg
|
inline_svg
|
||||||
|
@ -652,8 +694,10 @@ DEPENDENCIES
|
||||||
jekyll-data!
|
jekyll-data!
|
||||||
jekyll-images
|
jekyll-images
|
||||||
jekyll-include-cache
|
jekyll-include-cache
|
||||||
|
kaminari
|
||||||
letter_opener
|
letter_opener
|
||||||
listen (>= 3.0.5, < 3.2)
|
listen (>= 3.0.5, < 3.2)
|
||||||
|
loaf
|
||||||
lockbox
|
lockbox
|
||||||
lograge
|
lograge
|
||||||
memory_profiler
|
memory_profiler
|
||||||
|
@ -661,7 +705,9 @@ DEPENDENCIES
|
||||||
minima
|
minima
|
||||||
mobility
|
mobility
|
||||||
net-ssh
|
net-ssh
|
||||||
|
nokogiri
|
||||||
pg
|
pg
|
||||||
|
pg_search
|
||||||
prometheus_exporter
|
prometheus_exporter
|
||||||
pry
|
pry
|
||||||
puma
|
puma
|
||||||
|
@ -675,6 +721,7 @@ DEPENDENCIES
|
||||||
recursero-jekyll-theme
|
recursero-jekyll-theme
|
||||||
redis
|
redis
|
||||||
redis-rails
|
redis-rails
|
||||||
|
rollups!
|
||||||
rubocop-rails
|
rubocop-rails
|
||||||
rubyzip
|
rubyzip
|
||||||
rugged
|
rugged
|
||||||
|
@ -690,7 +737,7 @@ DEPENDENCIES
|
||||||
sucker_punch
|
sucker_punch
|
||||||
sutty-donaciones-jekyll-theme
|
sutty-donaciones-jekyll-theme
|
||||||
sutty-jekyll-theme
|
sutty-jekyll-theme
|
||||||
sutty-liquid
|
sutty-liquid (>= 0.7.3)
|
||||||
sutty-minima
|
sutty-minima
|
||||||
symbol-fstring
|
symbol-fstring
|
||||||
terminal-table
|
terminal-table
|
||||||
|
@ -706,4 +753,4 @@ RUBY VERSION
|
||||||
ruby 2.7.1p83
|
ruby 2.7.1p83
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.1.4
|
2.2.2
|
||||||
|
|
211
Makefile
211
Makefile
|
@ -1,99 +1,156 @@
|
||||||
# Incluir las variables de entorno
|
SHELL := /bin/bash
|
||||||
mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
|
.DEFAULT_GOAL := help
|
||||||
root_dir := $(patsubst %/,%,$(dir $(mkfile_path)))
|
|
||||||
include $(root_dir)/.env
|
|
||||||
|
|
||||||
delegate := athshe
|
# Copiar el archivo de configuración y avisar cuando hay que
|
||||||
|
# actualizarlo.
|
||||||
|
.env: .env.example
|
||||||
|
@test -f $@ || cp -v $< $@
|
||||||
|
@test -f $@ && echo "Revisa $@ para actualizarlo con respecto a $<"
|
||||||
|
@test -f $@ && diff -auN --color $@ $<
|
||||||
|
|
||||||
assets := package.json yarn.lock $(shell find app/assets/ app/javascript/ -type f)
|
include .env
|
||||||
|
|
||||||
alpine_version := 3.12
|
export
|
||||||
|
|
||||||
public/packs/manifest.json.br: $(assets)
|
# XXX: El espacio antes del comentario cuenta como espacio
|
||||||
PANEL_URL=https://panel.sutty.nl RAILS_ENV=production NODE_ENV=production bundle exec rake assets:precompile assets:clean
|
args ?=## Argumentos para Hain
|
||||||
|
commit ?= origin/rails## Commit desde el que actualizar
|
||||||
|
env ?= staging## Entorno del nodo delegado
|
||||||
|
sutty ?= $(SUTTY)## Dirección local
|
||||||
|
delegate ?= $(DELEGATE)## Cambia el nodo delegado
|
||||||
|
hain ?= ENV_FILE=.env $(HAINISH)## Ubicación de Hainish
|
||||||
|
|
||||||
assets: public/packs/manifest.json.br
|
# El nodo delegado tiene dos entornos, production y staging.
|
||||||
|
# Dependiendo del entorno que elijamos, se van a generar los assets y el
|
||||||
serve: /etc/hosts
|
# contenedor y subirse a un servidor u otro. No utilizamos CI/CD (aún).
|
||||||
bundle exec rails s -b "ssl://0.0.0.0:3000?key=../sutty.local/domain/$(SUTTY).key&cert=../sutty.local/domain/$(SUTTY).crt"
|
#
|
||||||
|
# Production es el entorno de panel.sutty.nl
|
||||||
# Servir JS con el dev server.
|
ifeq ($(env),production)
|
||||||
# Esto acelera la compilación del javascript, tiene que correrse por separado
|
container ?= sutty
|
||||||
# de serve.
|
## TODO: Cambiar a otra cosa
|
||||||
serve-js: /etc/hosts
|
branch ?= rails
|
||||||
bundle exec ./bin/webpack-dev-server
|
public ?= public
|
||||||
|
|
||||||
# Limpiar los archivos de testeo
|
|
||||||
clean:
|
|
||||||
rm -rf _sites/test-* _deploy/test-*
|
|
||||||
|
|
||||||
# Generar la imagen Docker
|
|
||||||
build: assets
|
|
||||||
time docker build --build-arg="RAILS_MASTER_KEY=`cat config/master.key`" -t sutty/sutty .
|
|
||||||
docker tag sutty/sutty:latest sutty:keep
|
|
||||||
|
|
||||||
save:
|
|
||||||
time docker save sutty/sutty:latest | ssh root@$(delegate).sutty.nl docker load
|
|
||||||
date +%F | xargs git tag -f
|
|
||||||
@echo -e "\a"
|
|
||||||
|
|
||||||
load:
|
|
||||||
ssh root@sutty.nl sh -c "gunzip -c sutty.latest.gz | docker load"
|
|
||||||
|
|
||||||
# Crear el directorio donde se almacenan las gemas binarias
|
|
||||||
../gems/:
|
|
||||||
mkdir -p $@
|
|
||||||
|
|
||||||
gem_dir := $(shell readlink -f ../gems)
|
|
||||||
gem_cache_dir := $(gem_dir)/cache
|
|
||||||
gem_binary_dir := $(gem_dir)/$(alpine_version)
|
|
||||||
ifeq ($(MAKECMDGOALS),build-gems)
|
|
||||||
gems := $(shell bundle show --paths | xargs -I {} sh -c 'find {}/ext/ -name extconf.rb &>/dev/null && basename {}')
|
|
||||||
gems := $(patsubst %-x86_64-linux,%,$(gems))
|
|
||||||
gems := $(patsubst %,$(gem_cache_dir)/%.gem,$(gems))
|
|
||||||
gems_musl := $(patsubst $(gem_cache_dir)/%.gem,$(gem_binary_dir)/%-x86_64-linux-musl.gem,$(gems))
|
|
||||||
endif
|
endif
|
||||||
|
|
||||||
$(gem_binary_dir)/%-x86_64-linux-musl.gem:
|
# Staging es el entorno de panel.staging.sutty.nl
|
||||||
@docker run \
|
ifeq ($(env),staging)
|
||||||
-v $(gem_dir):/srv/gems \
|
container := staging
|
||||||
-v `readlink -f ~/.ccache`:/home/builder/.ccache \
|
branch := staging
|
||||||
-e HTTP_BASIC_USER=$(HTTP_BASIC_USER) \
|
public := staging
|
||||||
-e HTTP_BASIC_PASSWORD=$(HTTP_BASIC_PASSWORD) \
|
endif
|
||||||
-e GEM=`echo $(notdir $*) | sed -re "s/-[^-]+$$//"` \
|
|
||||||
-e VERSION=`echo $(notdir $*) | sed -re "s/.*-([^-]+)$$/\1/"` \
|
|
||||||
-e JOBS=2 \
|
|
||||||
--rm -it \
|
|
||||||
sutty/gem-compiler:latest || echo "No se pudo compilar $*"
|
|
||||||
|
|
||||||
# Compilar todas las gemas binarias y subirlas a gems.sutty.nl para que
|
help: always ## Ayuda
|
||||||
# al crear el contenedor no tengamos que compilarlas cada vez
|
@echo -e "Sutty\n" | sed -re "s/^.*/\x1B[38;5;197m&\x1B[0m/"
|
||||||
build-gems: $(gems_musl)
|
@echo -e "Servidor: https://panel.$(SUTTY_WITH_PORT)/\n"
|
||||||
|
@echo -e "Uso: make TAREA args=\"ARGUMENTOS\"\n"
|
||||||
|
@echo -e "Tareas:\n"
|
||||||
|
@grep -E "^[a-z\-]+:.*##" Makefile | sed -re "s/(.*):.*##(.*)/\1;\2/" | column -s ";" -t | sed -re "s/^([^ ]+) /\x1B[38;5;197m\1\x1B[0m/"
|
||||||
|
@echo -e "\nArgumentos:\n"
|
||||||
|
@grep -E "^[a-z\-]+ \?=.*##" Makefile | sed -re "s/(.*) \?=.*##(.*)/\1;\2/" | column -s ";" -t | sed -re "s/^([^ ]+) /\x1B[38;5;197m\1\x1B[0m/"
|
||||||
|
|
||||||
cached_gems = $(wildcard $(gem_dir)/cache/*.gem)
|
assets: public/packs/manifest.json.br ## Compilar los assets
|
||||||
rebuild_gems = $(patsubst $(gem_dir)/cache/%.gem,$(gem_dir)/$(alpine_version)/%-x86_64-linux-musl.gem,$(cached_gems))
|
|
||||||
rebuild-gems: $(rebuild_gems)
|
|
||||||
|
|
||||||
dirs := $(patsubst %,root/%,data sites deploy public)
|
test: always ## Ejecutar los tests
|
||||||
|
$(MAKE) rake args="test RAILS_ENV=test $(args)"
|
||||||
|
|
||||||
$(dirs):
|
postgresql: /etc/hosts ## Iniciar la base de datos
|
||||||
mkdir -p $@
|
pgrep postgres >/dev/null || $(hain) postgresql
|
||||||
|
|
||||||
app/assets/fonts/forkawesome-webfont.woff2: fa.txt
|
serve-js: /etc/hosts node_modules ## Iniciar el servidor de desarrollo de Javascript
|
||||||
which glyphhanger || npm i -g glyphhanger
|
$(hain) 'bundle exec ./bin/webpack-dev-server'
|
||||||
grep -v "^#" fa.txt | sed "s/^/U+/" | cut -d " " -f 1 | tr "\n" "," | xargs -rI {} glyphhanger --subset=node_modules/fork-awesome/fonts/forkawesome-webfont.ttf --formats=woff2 --whitelist="{}"
|
|
||||||
mv node_modules/fork-awesome/fonts/forkawesome-webfont-subset.woff2 $@
|
|
||||||
|
|
||||||
fa: app/assets/fonts/forkawesome-webfont.woff2 ## Fork Awesome
|
serve: /etc/hosts postgresql Gemfile.lock ## Iniciar el servidor de desarrollo de Rails
|
||||||
|
$(MAKE) rails args=server
|
||||||
|
|
||||||
ota: assets
|
rails: ## Corre rails dentro del entorno de desarrollo (pasar argumentos con args=).
|
||||||
sudo chgrp -R 82 public/
|
$(MAKE) bundle args="exec rails $(args)"
|
||||||
rsync -av public/ athshe:/srv/sutty/srv/http/data/_public/
|
|
||||||
|
|
||||||
|
rake: ## Corre rake dentro del entorno de desarrollo (pasar argumentos con args=).
|
||||||
|
$(MAKE) bundle args="exec rake $(args)"
|
||||||
|
|
||||||
|
bundle: ## Corre bundle dentro del entorno de desarrollo (pasar argumentos con args=).
|
||||||
|
$(hain) 'bundle $(args)'
|
||||||
|
|
||||||
|
psql := psql -h $(PG_HOST) -U $(PG_USER) -p $(PG_PORT) -d sutty
|
||||||
|
copy-table:
|
||||||
|
test -n "$(table)"
|
||||||
|
echo "truncate $(table) $(cascade);" | $(psql)
|
||||||
|
ssh $(delegate) docker exec postgresql pg_dump -U sutty -d sutty -t $(table) | $(psql)
|
||||||
|
|
||||||
|
psql:
|
||||||
|
$(psql)
|
||||||
|
|
||||||
|
rubocop: ## Yutea el código que está por ser commiteado
|
||||||
|
git status --porcelain \
|
||||||
|
| grep -E "^(A|M)" \
|
||||||
|
| sed "s/^...//" \
|
||||||
|
| grep ".rb$$" \
|
||||||
|
| ../haini.sh/haini.sh "xargs -r ./bin/rubocop --auto-correct"
|
||||||
|
|
||||||
|
audit: ## Encuentra dependencias con vulnerabilidades
|
||||||
|
$(hain) 'gem install bundler-audit'
|
||||||
|
$(hain) 'bundle audit --update'
|
||||||
|
|
||||||
|
brakeman: ## Busca posibles vulnerabilidades en Sutty
|
||||||
|
$(MAKE) bundle args='exec brakeman'
|
||||||
|
|
||||||
|
yarn: ## Tareas de yarn
|
||||||
|
$(hain) 'yarn $(args)'
|
||||||
|
|
||||||
|
clean: ## Limpieza
|
||||||
|
rm -rf _sites/test-* _deploy/test-* log/*.log tmp/cache tmp/letter_opener tmp/miniprofiler tmp/storage
|
||||||
|
|
||||||
|
build: Gemfile.lock ## Generar la imagen Docker
|
||||||
|
time docker build --build-arg="BRANCH=$(branch)" --build-arg="RAILS_MASTER_KEY=`cat config/master.key`" -t sutty/$(container) .
|
||||||
|
docker tag sutty/$(container):latest sutty:keep
|
||||||
|
@echo -e "\a"
|
||||||
|
|
||||||
|
save: ## Subir la imagen Docker al nodo delegado
|
||||||
|
time docker save sutty/$(container):latest | ssh root@$(delegate) docker load
|
||||||
|
date +%F | xargs -I {} git tag -f $(container)-{}
|
||||||
|
@echo -e "\a"
|
||||||
|
|
||||||
|
ota-js: assets ## Actualizar Javascript en el nodo delegado
|
||||||
|
rsync -avi --delete-after --chown 1000:82 public/ root@$(delegate):/srv/sutty/srv/http/data/_$(public)/
|
||||||
|
ssh root@$(delegate) docker exec $(container) sh -c "cat /srv/http/tmp/puma.pid | xargs -r kill -USR2"
|
||||||
|
|
||||||
|
ota: ## Actualizar Rails en el nodo delegado
|
||||||
|
umask 022; git format-patch $(commit)
|
||||||
|
ssh $(delegate) mkdir -p /tmp/patches-$(commit)/
|
||||||
|
scp ./0*.patch $(delegate):/tmp/patches-$(commit)/
|
||||||
|
scp ./ota.sh $(delegate):/tmp/
|
||||||
|
ssh $(delegate) docker cp /tmp/patches-$(shell echo $(commit) | cut -d / -f 1) $(container):/tmp/
|
||||||
|
ssh $(delegate) docker cp /tmp/ota.sh $(container):/usr/local/bin/ota
|
||||||
|
ssh $(delegate) docker exec $(container) apk add --no-cache patch
|
||||||
|
ssh $(delegate) docker exec $(container) ota $(commit)
|
||||||
|
rm ./0*.patch
|
||||||
|
|
||||||
|
# Todos los archivos de assets. Si alguno cambia, se van a recompilar
|
||||||
|
# los assets que luego se suben al nodo delegado.
|
||||||
|
assets := package.json yarn.lock $(shell find app/assets/ app/javascript/ -type f)
|
||||||
|
public/packs/manifest.json.br: $(assets)
|
||||||
|
$(hain) 'PANEL_URL=https://panel.sutty.nl RAILS_ENV=production NODE_ENV=production bundle exec rake assets:precompile assets:clean'
|
||||||
|
|
||||||
|
# Correr un test en particular por ejemplo
|
||||||
|
# `make test/models/usuarie_test.rb`
|
||||||
|
tests := $(shell find test/ -name "*_test.rb")
|
||||||
|
$(tests): always
|
||||||
|
$(MAKE) test args="TEST=$@"
|
||||||
|
|
||||||
|
# Agrega las direcciones locales al sistema
|
||||||
/etc/hosts: always
|
/etc/hosts: always
|
||||||
@echo "Chequeando si es necesario agregar el dominio local $(SUTTY)"
|
@echo "Chequeando si es necesario agregar el dominio local $(SUTTY)"
|
||||||
@grep -q " $(SUTTY)$$" $@ || echo -e "127.0.0.1 $(SUTTY)\n::1 $(SUTTY)" | sudo tee -a $@
|
@grep -q " $(SUTTY)$$" $@ || echo -e "127.0.0.1 $(SUTTY)\n::1 $(SUTTY)" | sudo tee -a $@
|
||||||
@grep -q " api.$(SUTTY)$$" $@ || echo -e "127.0.0.1 api.$(SUTTY)\n::1 api.$(SUTTY)" | sudo tee -a $@
|
@grep -q " api.$(SUTTY)$$" $@ || echo -e "127.0.0.1 api.$(SUTTY)\n::1 api.$(SUTTY)" | sudo tee -a $@
|
||||||
@grep -q " panel.$(SUTTY)$$" $@ || echo -e "127.0.0.1 panel.$(SUTTY)\n::1 panel.$(SUTTY)" | sudo tee -a $@
|
@grep -q " panel.$(SUTTY)$$" $@ || echo -e "127.0.0.1 panel.$(SUTTY)\n::1 panel.$(SUTTY)" | sudo tee -a $@
|
||||||
|
@grep -q " postgresql.$(SUTTY)$$" $@ || echo -e "127.0.0.1 postgresql.$(SUTTY)\n::1 postgresql.$(SUTTY)" | sudo tee -a $@
|
||||||
|
|
||||||
|
# Instala las dependencias de Javascript
|
||||||
|
node_modules: package.json
|
||||||
|
$(MAKE) yarn
|
||||||
|
|
||||||
|
# Instala las dependencias de Rails
|
||||||
|
Gemfile.lock: Gemfile
|
||||||
|
$(MAKE) bundle args=install
|
||||||
|
|
||||||
.PHONY: always
|
.PHONY: always
|
||||||
|
|
21
README.md
21
README.md
|
@ -15,6 +15,17 @@ Este repositorio es la plataforma _Ruby on Rails_ para alojar el
|
||||||
|
|
||||||
Para más información visita el [sitio de Sutty](https://sutty.nl/).
|
Para más información visita el [sitio de Sutty](https://sutty.nl/).
|
||||||
|
|
||||||
|
### Desarrollar
|
||||||
|
|
||||||
|
Todas las tareas se gestionan con `make`, por favor instala GNU Make
|
||||||
|
antes de comenzar.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
[Leer la documentación](https://docs.sutty.nl/)
|
||||||
|
|
||||||
## English
|
## English
|
||||||
|
|
||||||
Sutty is a platform for hosting safer, faster and more resilient
|
Sutty is a platform for hosting safer, faster and more resilient
|
||||||
|
@ -25,3 +36,13 @@ This repository is the Ruby on Rails platform that hosts the
|
||||||
self-managed [panel](https://panel.sutty.nl/).
|
self-managed [panel](https://panel.sutty.nl/).
|
||||||
|
|
||||||
For more information, visit [Sutty's website](https://sutty.nl/en/).
|
For more information, visit [Sutty's website](https://sutty.nl/en/).
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Every task is run via `make`, please install GNU Make before developing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make help
|
||||||
|
```
|
||||||
|
|
||||||
|
[Read the documentation](https://docs.sutty.nl/en/)
|
||||||
|
|
|
@ -21,6 +21,10 @@ $form-feedback-invalid-color: $magenta;
|
||||||
$form-feedback-icon-valid-color: $black;
|
$form-feedback-icon-valid-color: $black;
|
||||||
$component-active-bg: $magenta;
|
$component-active-bg: $magenta;
|
||||||
|
|
||||||
|
$spacers: (
|
||||||
|
2-plus: 0.75rem
|
||||||
|
);
|
||||||
|
|
||||||
@import "bootstrap";
|
@import "bootstrap";
|
||||||
@import "editor";
|
@import "editor";
|
||||||
|
|
||||||
|
@ -210,6 +214,10 @@ svg {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
@extend .badge
|
||||||
|
}
|
||||||
|
|
||||||
.black-bg {
|
.black-bg {
|
||||||
color: $white;
|
color: $white;
|
||||||
background-color: $black;
|
background-color: $black;
|
||||||
|
@ -355,6 +363,13 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
|
||||||
.text-column-#{$size} {
|
.text-column-#{$size} {
|
||||||
column-count: $size;
|
column-count: $size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.line-clamp-#{$size} {
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: $size;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -2,6 +2,13 @@
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
*, *::before, *::after { box-sizing: inherit; }
|
*, *::before, *::after { box-sizing: inherit; }
|
||||||
|
|
||||||
|
// Arreglo temporal para que las cosas sean legibles en modo oscuro
|
||||||
|
--foreground: black;
|
||||||
|
--background: white;
|
||||||
|
--color: #f206f9;
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6, p, li {
|
h1, h2, h3, h4, h5, h6, p, li {
|
||||||
min-height: 1.5rem;
|
min-height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
@ -64,6 +71,10 @@
|
||||||
strong, em, del, u, sub, sup, small { background: #0002; }
|
strong, em, del, u, sub, sup, small { background: #0002; }
|
||||||
a { background: #13fefe50; }
|
a { background: #13fefe50; }
|
||||||
[data-editor-selected] { outline: #f206f9 solid thick; }
|
[data-editor-selected] { outline: #f206f9 solid thick; }
|
||||||
|
p[data-multimedia-inner] {
|
||||||
|
// Ignorar clicks en el párrafo placeholder
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
*[data-editor-loading] {
|
*[data-editor-loading] {
|
||||||
|
|
|
@ -23,8 +23,6 @@ class ApplicationController < ActionController::Base
|
||||||
redirect_to sites_path
|
redirect_to sites_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def markdown; end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def uuid?(string)
|
def uuid?(string)
|
||||||
|
@ -42,11 +40,21 @@ class ApplicationController < ActionController::Base
|
||||||
site
|
site
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Devuelve el idioma actual y si no lo encuentra obtiene uno por
|
||||||
|
# defecto.
|
||||||
|
#
|
||||||
|
# Esto se refiere al idioma de la interfaz, no de los artículos.
|
||||||
|
def current_locale(include_params: true, site: nil)
|
||||||
|
return params[:locale] if include_params && params[:locale].present?
|
||||||
|
|
||||||
|
current_usuarie&.lang || I18n.locale
|
||||||
|
end
|
||||||
|
|
||||||
# El idioma es el preferido por le usuarie, pero no necesariamente se
|
# El idioma es el preferido por le usuarie, pero no necesariamente se
|
||||||
# corresponde con el idioma de los artículos, porque puede querer
|
# corresponde con el idioma de los artículos, porque puede querer
|
||||||
# traducirlos.
|
# traducirlos.
|
||||||
def set_locale(&action)
|
def set_locale(&action)
|
||||||
I18n.with_locale(current_usuarie&.lang || I18n.default_locale, &action)
|
I18n.with_locale(current_locale(include_params: false), &action)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Muestra una página 404
|
# Muestra una página 404
|
||||||
|
|
|
@ -7,80 +7,67 @@ class PostsController < ApplicationController
|
||||||
|
|
||||||
before_action :authenticate_usuarie!
|
before_action :authenticate_usuarie!
|
||||||
|
|
||||||
|
# TODO: Traer los comunes desde ApplicationController
|
||||||
|
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
|
||||||
|
breadcrumb 'sites.index', :sites_path, match: :exact
|
||||||
|
breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact
|
||||||
|
|
||||||
# Las URLs siempre llevan el idioma actual o el de le usuarie
|
# Las URLs siempre llevan el idioma actual o el de le usuarie
|
||||||
def default_url_options
|
def default_url_options
|
||||||
{ locale: params[:locale] || current_usuarie&.lang || I18n.locale }
|
{ locale: current_locale }
|
||||||
end
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize Post
|
authorize Post
|
||||||
|
|
||||||
@site = find_site
|
|
||||||
@category = params.dig(:category)
|
|
||||||
@layout = params.dig(:layout)
|
|
||||||
@locale = locale
|
|
||||||
|
|
||||||
# XXX: Cada vez que cambiamos un Post tocamos el sitio con lo que es
|
# XXX: Cada vez que cambiamos un Post tocamos el sitio con lo que es
|
||||||
# más simple saber si hubo cambios.
|
# más simple saber si hubo cambios.
|
||||||
if @category || @layout || stale?(@site)
|
return unless stale?([current_usuarie, site, filter_params])
|
||||||
@posts = @site.posts(lang: locale)
|
|
||||||
@posts = @posts.where(categories: @category) if @category
|
|
||||||
@posts = @posts.where(layout: @layout) if @layout
|
|
||||||
@posts = PostPolicy::Scope.new(current_usuarie, @posts).resolve
|
|
||||||
|
|
||||||
@category_name = if uuid?(@category)
|
# Todos los artículos de este sitio para el idioma actual
|
||||||
@site.posts(lang: locale).find(@category, uuid: true)&.title&.value
|
@posts = site.indexed_posts.where(locale: locale)
|
||||||
else
|
# De este tipo
|
||||||
@category
|
@posts = @posts.where(layout: filter_params[:layout]) if filter_params[:layout]
|
||||||
end
|
# Que estén dentro de la categoría
|
||||||
|
@posts = @posts.in_category(filter_params[:category]) if filter_params[:category]
|
||||||
|
# Aplicar los parámetros de búsqueda
|
||||||
|
@posts = @posts.search(locale, filter_params[:q]) if filter_params[:q].present?
|
||||||
|
# A los que este usuarie tiene acceso
|
||||||
|
@posts = PostPolicy::Scope.new(current_usuarie, @posts).resolve
|
||||||
|
|
||||||
# Filtrar los posts que les invitades no pueden ver
|
# Filtrar los posts que les invitades no pueden ver
|
||||||
@usuarie = @site.usuarie? current_usuarie
|
@usuarie = site.usuarie? current_usuarie
|
||||||
|
|
||||||
# Orden descendiente por número y luego por fecha
|
|
||||||
@posts.sort_by!(:order, :date).reverse!
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@site = find_site
|
authorize post
|
||||||
@post = @site.posts(lang: locale).find params[:id]
|
breadcrumb post.title.value, ''
|
||||||
|
fresh_when post
|
||||||
authorize @post
|
|
||||||
@locale = locale
|
|
||||||
|
|
||||||
fresh_when @post
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Genera una previsualización del artículo.
|
# Genera una previsualización del artículo.
|
||||||
#
|
|
||||||
# TODO: No todos los artículos tienen previsualización!
|
|
||||||
def preview
|
def preview
|
||||||
@site = find_site
|
authorize post
|
||||||
@post = @site.posts(lang: locale).find params[:post_id]
|
|
||||||
|
|
||||||
authorize @post
|
render html: post.render
|
||||||
|
|
||||||
render html: @post.render
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
authorize Post
|
authorize Post
|
||||||
@site = find_site
|
@post = site.posts(lang: locale).build(layout: params[:layout])
|
||||||
@post = @site.posts.build(lang: locale, layout: params[:layout])
|
|
||||||
@locale = locale
|
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
authorize Post
|
authorize Post
|
||||||
@site = find_site
|
service = PostService.new(site: site,
|
||||||
service = PostService.new(site: @site,
|
|
||||||
usuarie: current_usuarie,
|
usuarie: current_usuarie,
|
||||||
params: params)
|
params: params)
|
||||||
@post = service.create
|
@post = service.create
|
||||||
|
|
||||||
if @post.persisted?
|
if @post.persisted?
|
||||||
@site.touch
|
site.touch
|
||||||
forget_content
|
forget_content
|
||||||
|
|
||||||
redirect_to site_post_path(@site, @post)
|
redirect_to site_post_path(@site, @post)
|
||||||
|
@ -90,30 +77,24 @@ class PostsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
@site = find_site
|
authorize post
|
||||||
@post = @site.posts(lang: locale).find params[:id]
|
breadcrumb post.title.value, site_post_path(site, post, locale: locale), match: :exact
|
||||||
|
breadcrumb 'posts.edit', ''
|
||||||
authorize @post
|
|
||||||
|
|
||||||
@locale = locale
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@site = find_site
|
authorize post
|
||||||
@post = @site.posts(lang: locale).find params[:id]
|
|
||||||
|
|
||||||
authorize @post
|
service = PostService.new(site: site,
|
||||||
|
post: post,
|
||||||
service = PostService.new(site: @site,
|
|
||||||
post: @post,
|
|
||||||
usuarie: current_usuarie,
|
usuarie: current_usuarie,
|
||||||
params: params)
|
params: params)
|
||||||
|
|
||||||
if service.update.persisted?
|
if service.update.persisted?
|
||||||
@site.touch
|
site.touch
|
||||||
forget_content
|
forget_content
|
||||||
|
|
||||||
redirect_to site_post_path(@site, @post)
|
redirect_to site_post_path(site, post)
|
||||||
else
|
else
|
||||||
render 'posts/edit'
|
render 'posts/edit'
|
||||||
end
|
end
|
||||||
|
@ -121,34 +102,30 @@ class PostsController < ApplicationController
|
||||||
|
|
||||||
# Eliminar artículos
|
# Eliminar artículos
|
||||||
def destroy
|
def destroy
|
||||||
@site = find_site
|
authorize post
|
||||||
@post = @site.posts(lang: locale).find params[:id]
|
|
||||||
|
|
||||||
authorize @post
|
service = PostService.new(site: site,
|
||||||
|
post: post,
|
||||||
service = PostService.new(site: @site,
|
|
||||||
post: @post,
|
|
||||||
usuarie: current_usuarie,
|
usuarie: current_usuarie,
|
||||||
params: params)
|
params: params)
|
||||||
|
|
||||||
# TODO: Notificar si se pudo o no
|
# TODO: Notificar si se pudo o no
|
||||||
service.destroy
|
service.destroy
|
||||||
@site.touch
|
site.touch
|
||||||
redirect_to site_posts_path(@site)
|
redirect_to site_posts_path(site, locale: post.lang.value)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Reordenar los artículos
|
# Reordenar los artículos
|
||||||
def reorder
|
def reorder
|
||||||
@site = find_site
|
authorize site
|
||||||
authorize @site
|
|
||||||
|
|
||||||
service = PostService.new(site: @site,
|
service = PostService.new(site: site,
|
||||||
usuarie: current_usuarie,
|
usuarie: current_usuarie,
|
||||||
params: params)
|
params: params)
|
||||||
|
|
||||||
service.reorder
|
service.reorder
|
||||||
@site.touch
|
site.touch
|
||||||
redirect_to site_posts_path(@site)
|
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Devuelve el idioma solicitado a través de un parámetro, validando
|
# Devuelve el idioma solicitado a través de un parámetro, validando
|
||||||
|
@ -159,7 +136,7 @@ class PostsController < ApplicationController
|
||||||
# solicite a le usuarie crear el nuevo idioma y que esto lo agregue al
|
# solicite a le usuarie crear el nuevo idioma y que esto lo agregue al
|
||||||
# _config.yml del sitio en lugar de mezclar idiomas.
|
# _config.yml del sitio en lugar de mezclar idiomas.
|
||||||
def locale
|
def locale
|
||||||
@site&.locales&.find(-> { I18n.locale }) do |l|
|
@locale ||= site&.locales&.find(-> { site&.default_locale }) do |l|
|
||||||
l.to_s == params[:locale]
|
l.to_s == params[:locale]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -169,4 +146,24 @@ class PostsController < ApplicationController
|
||||||
def forget_content
|
def forget_content
|
||||||
flash[:js] = { target: 'editor', action: 'forget-content', keys: (params[:storage_keys] || []).to_json }
|
flash[:js] = { target: 'editor', action: 'forget-content', keys: (params[:storage_keys] || []).to_json }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Los parámetros de filtros que vamos a mantener en todas las URLs,
|
||||||
|
# solo los que no estén vacíos.
|
||||||
|
#
|
||||||
|
# @return [Hash]
|
||||||
|
def filter_params
|
||||||
|
@filter_params ||= params.permit(:q, :category, :layout).to_hash.select do |_, v|
|
||||||
|
v.present?
|
||||||
|
end.transform_keys(&:to_sym)
|
||||||
|
end
|
||||||
|
|
||||||
|
def site
|
||||||
|
@site ||= find_site
|
||||||
|
end
|
||||||
|
|
||||||
|
def post
|
||||||
|
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,6 +7,9 @@ class SitesController < ApplicationController
|
||||||
|
|
||||||
before_action :authenticate_usuarie!
|
before_action :authenticate_usuarie!
|
||||||
|
|
||||||
|
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
|
||||||
|
breadcrumb 'sites.index', :sites_path, match: :exact
|
||||||
|
|
||||||
# Ver un listado de sitios
|
# Ver un listado de sitios
|
||||||
def index
|
def index
|
||||||
authorize Site
|
authorize Site
|
||||||
|
@ -20,10 +23,12 @@ class SitesController < ApplicationController
|
||||||
def show
|
def show
|
||||||
authorize site
|
authorize site
|
||||||
|
|
||||||
redirect_to site_posts_path(site)
|
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
breadcrumb 'sites.new', :new_site_path
|
||||||
|
|
||||||
@site = Site.new
|
@site = Site.new
|
||||||
authorize @site
|
authorize @site
|
||||||
|
|
||||||
|
@ -35,7 +40,7 @@ class SitesController < ApplicationController
|
||||||
params: site_params)
|
params: site_params)
|
||||||
|
|
||||||
if (@site = service.create).persisted?
|
if (@site = service.create).persisted?
|
||||||
redirect_to site_posts_path(@site)
|
redirect_to site_posts_path(@site, locale: @site.default_locale)
|
||||||
else
|
else
|
||||||
render 'new'
|
render 'new'
|
||||||
end
|
end
|
||||||
|
@ -43,6 +48,10 @@ class SitesController < ApplicationController
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
authorize site
|
authorize site
|
||||||
|
|
||||||
|
breadcrumb site.title, site_posts_path(site, locale: site.default_locale), match: :exact
|
||||||
|
breadcrumb 'sites.edit', site_path(site)
|
||||||
|
|
||||||
SiteService.new(site: site).build_deploys
|
SiteService.new(site: site).build_deploys
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -53,7 +62,7 @@ class SitesController < ApplicationController
|
||||||
usuarie: current_usuarie)
|
usuarie: current_usuarie)
|
||||||
|
|
||||||
if service.update.valid?
|
if service.update.valid?
|
||||||
redirect_to site_posts_path(site)
|
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||||
else
|
else
|
||||||
render 'edit'
|
render 'edit'
|
||||||
end
|
end
|
||||||
|
@ -63,9 +72,10 @@ class SitesController < ApplicationController
|
||||||
authorize site
|
authorize site
|
||||||
|
|
||||||
# XXX: Convertir en una máquina de estados?
|
# XXX: Convertir en una máquina de estados?
|
||||||
DeployJob.perform_async site.id if site.enqueue!
|
site.enqueue!
|
||||||
|
DeployJob.perform_async site.id
|
||||||
|
|
||||||
redirect_to site_posts_path(site)
|
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reorder_posts
|
def reorder_posts
|
||||||
|
@ -85,7 +95,7 @@ class SitesController < ApplicationController
|
||||||
flash[:danger] = I18n.t('errors.posts.reorder')
|
flash[:danger] = I18n.t('errors.posts.reorder')
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to site_posts_path(site)
|
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch
|
def fetch
|
||||||
|
@ -97,7 +107,7 @@ class SitesController < ApplicationController
|
||||||
def merge
|
def merge
|
||||||
authorize site
|
authorize site
|
||||||
|
|
||||||
if site.repository.merge(current_usuarie)
|
if SiteService.new(site: site, usuarie: current_usuarie).merge
|
||||||
flash[:success] = I18n.t('sites.fetch.merge.success')
|
flash[:success] = I18n.t('sites.fetch.merge.success')
|
||||||
else
|
else
|
||||||
flash[:error] = I18n.t('sites.fetch.merge.error')
|
flash[:error] = I18n.t('sites.fetch.merge.error')
|
||||||
|
|
|
@ -3,16 +3,166 @@
|
||||||
# Estadísticas del sitio
|
# Estadísticas del sitio
|
||||||
class StatsController < ApplicationController
|
class StatsController < ApplicationController
|
||||||
include Pundit
|
include Pundit
|
||||||
|
include ActionView::Helpers::DateHelper
|
||||||
|
|
||||||
before_action :authenticate_usuarie!
|
before_action :authenticate_usuarie!
|
||||||
|
before_action :authorize_stats
|
||||||
|
|
||||||
|
EXTRA_OPTIONS = {
|
||||||
|
builds: {},
|
||||||
|
space_used: { bytes: true },
|
||||||
|
build_time: {}
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# XXX: Permitir a Chart.js inyectar su propio CSS
|
||||||
|
content_security_policy only: :index do |policy|
|
||||||
|
policy.style_src :self, :unsafe_inline
|
||||||
|
policy.script_src :self, :unsafe_inline
|
||||||
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
@chart_params = { interval: interval }
|
||||||
|
hostnames
|
||||||
|
last_stat
|
||||||
|
chart_options
|
||||||
|
normalized_urls
|
||||||
|
end
|
||||||
|
|
||||||
|
# Genera un gráfico de visitas por dominio asociado a este sitio
|
||||||
|
def host
|
||||||
|
return unless stale? [last_stat, hostnames, interval]
|
||||||
|
|
||||||
|
stats = Rollup.where_dimensions(host: hostnames).multi_series('host', interval: interval).tap do |series|
|
||||||
|
series.each do |serie|
|
||||||
|
serie[:name] = serie.dig(:dimensions, 'host')
|
||||||
|
serie[:data].transform_values! do |value|
|
||||||
|
value * nodes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: stats
|
||||||
|
end
|
||||||
|
|
||||||
|
def resources
|
||||||
|
return unless stale? [last_stat, interval, resource]
|
||||||
|
|
||||||
|
options = {
|
||||||
|
interval: interval,
|
||||||
|
dimensions: {
|
||||||
|
deploy_id: @site.deploys.where(type: 'DeployLocal').pluck(:id).first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render json: Rollup.series(resource, **options)
|
||||||
|
end
|
||||||
|
|
||||||
|
def uris
|
||||||
|
return unless stale? [last_stat, hostnames, interval, normalized_urls]
|
||||||
|
|
||||||
|
options = { host: hostnames, uri: normalized_paths }
|
||||||
|
stats = Rollup.where_dimensions(**options).multi_series('host|uri', interval: interval).tap do |series|
|
||||||
|
series.each do |serie|
|
||||||
|
serie[:name] = serie[:dimensions].slice('host', 'uri').values.join.sub('/index.html', '/')
|
||||||
|
serie[:data].transform_values! do |value|
|
||||||
|
value * nodes
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
render json: stats
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def last_stat
|
||||||
|
@last_stat ||= Stat.last
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_stats
|
||||||
@site = find_site
|
@site = find_site
|
||||||
authorize SiteStat.new(@site)
|
authorize SiteStat.new(@site)
|
||||||
|
end
|
||||||
|
|
||||||
# Solo queremos el promedio de tiempo de compilación, no de
|
# TODO: Eliminar cuando mergeemos referer-origin
|
||||||
# instalación de dependencias.
|
def hostnames
|
||||||
stats = @site.build_stats.jekyll
|
@hostnames ||= [@site.hostname, @site.alternative_hostnames].flatten
|
||||||
@build_avg = stats.average(:seconds).to_f.round(2)
|
end
|
||||||
@build_max = stats.maximum(:seconds).to_f.round(2)
|
|
||||||
|
# Normalizar las URLs
|
||||||
|
#
|
||||||
|
# @return [Array]
|
||||||
|
def normalized_urls
|
||||||
|
@normalized_urls ||= params.permit(:urls).try(:[],
|
||||||
|
:urls)&.split("\n")&.map(&:strip)&.select(&:present?)&.select do |uri|
|
||||||
|
uri.start_with? 'https://'
|
||||||
|
end&.map do |u|
|
||||||
|
# XXX: Eliminar
|
||||||
|
# @see {https://0xacab.org/sutty/containers/nginx/-/merge_requests/1}
|
||||||
|
next u unless u.end_with? '/'
|
||||||
|
|
||||||
|
"#{u}index.html"
|
||||||
|
end&.uniq || [@site.url, @site.urls].flatten.uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalized_paths
|
||||||
|
@normalized_paths ||= normalized_urls.map do |u|
|
||||||
|
"/#{u.split('/', 4).last}"
|
||||||
|
end.map do |u|
|
||||||
|
URI.decode_www_form_component u
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Opciones por defecto para los gráficos.
|
||||||
|
#
|
||||||
|
# La invitación a volver dentro de X tiempo es para dar un estimado de
|
||||||
|
# cuándo habrá información disponible, porque Rollup genera intervalos
|
||||||
|
# completos (¿aunque dice que no?)
|
||||||
|
#
|
||||||
|
# La diferencia se calcula sumando el intervalo a la hora de última
|
||||||
|
# toma de estadísticas y restando el tiempo que pasó desde ese
|
||||||
|
# momento.
|
||||||
|
def chart_options
|
||||||
|
time = (last_stat&.created_at || Time.now) + 1.try(interval)
|
||||||
|
please_return_at = { please_return_at: distance_of_time_in_words(Time.now, time) }
|
||||||
|
|
||||||
|
@chart_options ||= {
|
||||||
|
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>)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Obtiene y valida los intervalos
|
||||||
|
#
|
||||||
|
# @return [Symbol]
|
||||||
|
def interval
|
||||||
|
@interval ||= begin
|
||||||
|
i = params[:interval]&.to_sym
|
||||||
|
Stat::INTERVALS.include?(i) ? i : :day
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resource
|
||||||
|
@resource ||= begin
|
||||||
|
r = params[:resource].to_sym
|
||||||
|
Stat::RESOURCES.include?(r) ? r : :builds
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Obtiene la cantidad de nodos de Sutty, para poder calcular la
|
||||||
|
# cantidad de visitas.
|
||||||
|
#
|
||||||
|
# Como repartimos las visitas por nodo rotando las IPs en el
|
||||||
|
# nameserver y los resolvedores de DNS eligen un nameserver
|
||||||
|
# aleatoriamente, la cantidad de visitas se reparte
|
||||||
|
# equitativamente.
|
||||||
|
#
|
||||||
|
# XXX: Remover cuando podamos centralizar los AccessLog
|
||||||
|
#
|
||||||
|
# @return [Integer]
|
||||||
|
def nodes
|
||||||
|
@nodes ||= ENV.fetch('NODES', 1).to_i
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,12 +7,18 @@ class UsuariesController < ApplicationController
|
||||||
include Pundit
|
include Pundit
|
||||||
before_action :authenticate_usuarie!
|
before_action :authenticate_usuarie!
|
||||||
|
|
||||||
|
# TODO: Traer los comunes desde ApplicationController
|
||||||
|
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
|
||||||
|
breadcrumb 'sites.index', :sites_path, match: :exact
|
||||||
|
breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact
|
||||||
|
|
||||||
# Mostrar todes les usuaries e invitades de un sitio
|
# Mostrar todes les usuaries e invitades de un sitio
|
||||||
def index
|
def index
|
||||||
@site = find_site
|
site_usuarie = SiteUsuarie.new(site, current_usuarie)
|
||||||
site_usuarie = SiteUsuarie.new(@site, current_usuarie)
|
|
||||||
authorize site_usuarie
|
authorize site_usuarie
|
||||||
|
|
||||||
|
breadcrumb 'usuaries.index', ''
|
||||||
|
|
||||||
@policy = policy(site_usuarie)
|
@policy = policy(site_usuarie)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -156,4 +162,8 @@ class UsuariesController < ApplicationController
|
||||||
'invitade'
|
'invitade'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def site
|
||||||
|
@site ||= find_site
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,18 +1,23 @@
|
||||||
import { storeContent, restoreContent, forgetContent } from 'editor/storage'
|
import { storeContent, restoreContent, forgetContent } from "editor/storage";
|
||||||
import {
|
import {
|
||||||
isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt,
|
isDirectChild,
|
||||||
setAuxiliaryToolbar, parentBlockNames, clearSelected,
|
moveChildren,
|
||||||
} from 'editor/utils'
|
safeGetSelection,
|
||||||
import { types, getValidChildren, getType } from 'editor/types'
|
safeGetRangeAt,
|
||||||
import { setupButtons as setupMarksButtons } from 'editor/types/marks'
|
setAuxiliaryToolbar,
|
||||||
import { setupButtons as setupBlocksButtons } from 'editor/types/blocks'
|
parentBlockNames,
|
||||||
import { setupButtons as setupParentBlocksButtons } from 'editor/types/parentBlocks'
|
clearSelected,
|
||||||
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from 'editor/types/link'
|
} from "editor/utils";
|
||||||
|
import { types, getValidChildren, getType } from "editor/types";
|
||||||
|
import { setupButtons as setupMarksButtons } from "editor/types/marks";
|
||||||
|
import { setupButtons as setupBlocksButtons } from "editor/types/blocks";
|
||||||
|
import { setupButtons as setupParentBlocksButtons } from "editor/types/parentBlocks";
|
||||||
|
import { setupAuxiliaryToolbar as setupLinkAuxiliaryToolbar } from "editor/types/link";
|
||||||
import {
|
import {
|
||||||
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
|
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
|
||||||
setupButtons as setupMultimediaButtons,
|
setupButtons as setupMultimediaButtons,
|
||||||
} from 'editor/types/multimedia'
|
} from "editor/types/multimedia";
|
||||||
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types/mark'
|
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark";
|
||||||
|
|
||||||
// Esta funcion corrije errores que pueden haber como:
|
// Esta funcion corrije errores que pueden haber como:
|
||||||
// * que un nodo que no tiene 'text' permitido no tenga children (se les
|
// * que un nodo que no tiene 'text' permitido no tenga children (se les
|
||||||
|
@ -22,79 +27,76 @@ import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types
|
||||||
// * convierte <i> y <b> en <em> y <strong>
|
// * convierte <i> y <b> en <em> y <strong>
|
||||||
// Lo hace para que siga la estructura del documento y que no se borren por
|
// Lo hace para que siga la estructura del documento y que no se borren por
|
||||||
// cleanContent luego.
|
// cleanContent luego.
|
||||||
function fixContent (editor: Editor, node: Element = editor.contentEl): void {
|
function fixContent(editor: Editor, node: Element = editor.contentEl): void {
|
||||||
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
|
if (node.tagName === "SCRIPT" || node.tagName === "STYLE") {
|
||||||
node.parentElement?.removeChild(node)
|
node.parentElement?.removeChild(node);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.tagName === 'I') {
|
if (node.tagName === "I") {
|
||||||
const el = document.createElement('em')
|
const el = document.createElement("em");
|
||||||
moveChildren(node, el, null)
|
moveChildren(node, el, null);
|
||||||
node.parentElement?.replaceChild(el, node)
|
node.parentElement?.replaceChild(el, node);
|
||||||
node = el
|
node = el;
|
||||||
}
|
}
|
||||||
if (node.tagName === 'B') {
|
if (node.tagName === "B") {
|
||||||
const el = document.createElement('strong')
|
const el = document.createElement("strong");
|
||||||
moveChildren(node, el, null)
|
moveChildren(node, el, null);
|
||||||
node.parentElement?.replaceChild(el, node)
|
node.parentElement?.replaceChild(el, node);
|
||||||
node = el
|
node = el;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node instanceof HTMLImageElement) {
|
if (node instanceof HTMLImageElement) {
|
||||||
node.dataset.multimediaInner = ''
|
node.dataset.multimediaInner = "";
|
||||||
const figureEl = types.multimedia.create(editor)
|
const figureEl = types.multimedia.create(editor);
|
||||||
|
|
||||||
let targetEl = node.parentElement
|
let targetEl = node.parentElement;
|
||||||
if (!targetEl) throw new Error('No encontré lx objetivo')
|
if (!targetEl) throw new Error("No encontré lx objetivo");
|
||||||
while (true) {
|
while (true) {
|
||||||
const type = getType(targetEl)
|
const type = getType(targetEl);
|
||||||
if (!type) throw new Error('lx objetivo tiene tipo')
|
if (!type) throw new Error("lx objetivo tiene tipo");
|
||||||
if (type.type.allowedChildren.includes('multimedia')) break
|
if (type.type.allowedChildren.includes("multimedia")) break;
|
||||||
if (!targetEl.parentElement) throw new Error('No encontré lx objetivo')
|
if (!targetEl.parentElement) throw new Error("No encontré lx objetivo");
|
||||||
targetEl = targetEl.parentElement
|
targetEl = targetEl.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parentEl = [...targetEl.childNodes].find(
|
let parentEl = [...targetEl.childNodes].find((el) => el.contains(node));
|
||||||
el => el.contains(node)
|
if (!parentEl) throw new Error("no encontré lx pariente");
|
||||||
)
|
targetEl.insertBefore(figureEl, parentEl);
|
||||||
if (!parentEl) throw new Error('no encontré lx pariente')
|
|
||||||
targetEl.insertBefore(figureEl, parentEl)
|
|
||||||
|
|
||||||
const innerEl = figureEl.querySelector('[data-multimedia-inner]')
|
const innerEl = figureEl.querySelector("[data-multimedia-inner]");
|
||||||
if (!innerEl) throw new Error('Raro.')
|
if (!innerEl) throw new Error("Raro.");
|
||||||
figureEl.replaceChild(node, innerEl)
|
figureEl.replaceChild(node, innerEl);
|
||||||
|
|
||||||
node = figureEl
|
node = figureEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _type = getType(node)
|
const _type = getType(node);
|
||||||
if (!_type) return
|
if (!_type) return;
|
||||||
|
|
||||||
const { typeName, type } = _type
|
const { typeName, type } = _type;
|
||||||
|
|
||||||
if (type.allowedChildren !== 'ignore-children') {
|
if (type.allowedChildren !== "ignore-children") {
|
||||||
const sel = safeGetSelection(editor)
|
const sel = safeGetSelection(editor);
|
||||||
const range = sel && safeGetRangeAt(sel)
|
const range = sel && safeGetRangeAt(sel);
|
||||||
|
|
||||||
if (getValidChildren(node, type).length == 0) {
|
if (getValidChildren(node, type).length == 0) {
|
||||||
if (typeof type.handleEmpty !== 'string') {
|
if (typeof type.handleEmpty !== "string") {
|
||||||
const el = type.handleEmpty.create(editor)
|
const el = type.handleEmpty.create(editor);
|
||||||
// mover cosas que pueden haber
|
// mover cosas que pueden haber
|
||||||
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
|
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
|
||||||
// creamos acá
|
// creamos acá
|
||||||
moveChildren(node, el, null)
|
moveChildren(node, el, null);
|
||||||
node.appendChild(el)
|
node.appendChild(el);
|
||||||
if (range?.intersectsNode(node))
|
if (range?.intersectsNode(node)) sel?.collapse(el);
|
||||||
sel?.collapse(el)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
for (const child of node.childNodes) {
|
||||||
for (const child of node.childNodes) {
|
if (!(child instanceof Element)) continue;
|
||||||
if (!(child instanceof Element)) continue
|
fixContent(editor, child);
|
||||||
fixContent(editor, child)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Esta funcion hace que los elementos del editor sigan la estructura.
|
// Esta funcion hace que los elementos del editor sigan la estructura.
|
||||||
|
@ -102,205 +104,236 @@ function fixContent (editor: Editor, node: Element = editor.contentEl): void {
|
||||||
// Edge cases:
|
// Edge cases:
|
||||||
// * no borramos los <br> por que se requieren para que los navegadores
|
// * no borramos los <br> por que se requieren para que los navegadores
|
||||||
// funcionen bien al escribir. no se deberían mostrar de todas maneras
|
// funcionen bien al escribir. no se deberían mostrar de todas maneras
|
||||||
function cleanContent (editor: Editor, node: Element = editor.contentEl): void {
|
function cleanContent(editor: Editor, node: Element = editor.contentEl): void {
|
||||||
const _type = getType(node)
|
const _type = getType(node);
|
||||||
if (!_type) {
|
if (!_type) {
|
||||||
node.parentElement?.removeChild(node)
|
node.parentElement?.removeChild(node);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type } = _type
|
const { type } = _type;
|
||||||
|
|
||||||
if (type.allowedChildren !== 'ignore-children') {
|
if (type.allowedChildren !== "ignore-children") {
|
||||||
for (const child of node.childNodes) {
|
for (const child of node.childNodes) {
|
||||||
if (child.nodeType === Node.TEXT_NODE
|
if (
|
||||||
&& !type.allowedChildren.includes('text')
|
child.nodeType === Node.TEXT_NODE &&
|
||||||
) {
|
!type.allowedChildren.includes("text")
|
||||||
node.removeChild(child)
|
) {
|
||||||
continue
|
node.removeChild(child);
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (!(child instanceof Element)) continue
|
if (!(child instanceof Element)) continue;
|
||||||
|
|
||||||
const childType = getType(child)
|
const childType = getType(child);
|
||||||
if (childType?.typeName === 'br') continue
|
if (childType?.typeName === "br") continue;
|
||||||
if (!childType || !type.allowedChildren.includes(childType.typeName)) {
|
if (!childType || !type.allowedChildren.includes(childType.typeName)) {
|
||||||
// XXX: esto extrae las cosas de adentro para que no sea destructivo
|
// XXX: esto extrae las cosas de adentro para que no sea destructivo
|
||||||
moveChildren(child, node, child)
|
moveChildren(child, node, child);
|
||||||
node.removeChild(child)
|
node.removeChild(child);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanContent(editor, child)
|
cleanContent(editor, child);
|
||||||
}
|
}
|
||||||
|
|
||||||
// solo contar children válido para ese nodo
|
// solo contar children válido para ese nodo
|
||||||
const validChildrenLength = getValidChildren(node, type).length
|
const validChildrenLength = getValidChildren(node, type).length;
|
||||||
|
|
||||||
const sel = safeGetSelection(editor)
|
const sel = safeGetSelection(editor);
|
||||||
const range = sel && safeGetRangeAt(sel)
|
const range = sel && safeGetRangeAt(sel);
|
||||||
if (type.handleEmpty === 'remove'
|
if (
|
||||||
&& validChildrenLength == 0
|
type.handleEmpty === "remove" &&
|
||||||
//&& (!range || !range.intersectsNode(node))
|
validChildrenLength == 0
|
||||||
) {
|
//&& (!range || !range.intersectsNode(node))
|
||||||
node.parentNode?.removeChild(node)
|
) {
|
||||||
return
|
node.parentNode?.removeChild(node);
|
||||||
}
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function routine (editor: Editor): void {
|
function routine(editor: Editor): void {
|
||||||
try {
|
try {
|
||||||
fixContent(editor)
|
fixContent(editor);
|
||||||
cleanContent(editor)
|
cleanContent(editor);
|
||||||
storeContent(editor)
|
storeContent(editor);
|
||||||
|
|
||||||
editor.htmlEl.value = editor.contentEl.innerHTML
|
editor.htmlEl.value = editor.contentEl.innerHTML;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Hubo un problema corriendo la rutina', editor, error)
|
console.error("Hubo un problema corriendo la rutina", editor, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Editor {
|
export interface Editor {
|
||||||
editorEl: HTMLElement,
|
editorEl: HTMLElement;
|
||||||
toolbarEl: HTMLElement,
|
toolbarEl: HTMLElement;
|
||||||
toolbar: {
|
toolbar: {
|
||||||
auxiliary: {
|
auxiliary: {
|
||||||
mark: {
|
mark: {
|
||||||
parentEl: HTMLElement,
|
parentEl: HTMLElement;
|
||||||
colorEl: HTMLInputElement,
|
colorEl: HTMLInputElement;
|
||||||
},
|
textColorEl: HTMLInputElement;
|
||||||
multimedia: {
|
};
|
||||||
parentEl: HTMLElement,
|
multimedia: {
|
||||||
fileEl: HTMLInputElement,
|
parentEl: HTMLElement;
|
||||||
uploadEl: HTMLButtonElement,
|
fileEl: HTMLInputElement;
|
||||||
altEl: HTMLInputElement,
|
uploadEl: HTMLButtonElement;
|
||||||
removeEl: HTMLButtonElement,
|
altEl: HTMLInputElement;
|
||||||
},
|
removeEl: HTMLButtonElement;
|
||||||
link: {
|
};
|
||||||
parentEl: HTMLElement,
|
link: {
|
||||||
urlEl: HTMLInputElement,
|
parentEl: HTMLElement;
|
||||||
},
|
urlEl: HTMLInputElement;
|
||||||
},
|
};
|
||||||
},
|
};
|
||||||
contentEl: HTMLElement,
|
};
|
||||||
wordAlertEl: HTMLElement,
|
contentEl: HTMLElement;
|
||||||
htmlEl: HTMLTextAreaElement,
|
wordAlertEl: HTMLElement;
|
||||||
|
htmlEl: HTMLTextAreaElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
|
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
|
||||||
const el = parentEl.querySelector<T>(selector)
|
const el = parentEl.querySelector<T>(selector);
|
||||||
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``)
|
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``);
|
||||||
return el
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupEditor (editorEl: HTMLElement): void {
|
function setupEditor(editorEl: HTMLElement): void {
|
||||||
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
|
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
|
||||||
document.execCommand('defaultParagraphSeparator', false, 'p')
|
document.execCommand("defaultParagraphSeparator", false, "p");
|
||||||
|
|
||||||
const editor: Editor = {
|
const editor: Editor = {
|
||||||
editorEl,
|
editorEl,
|
||||||
toolbarEl: getSel(editorEl, '.editor-toolbar'),
|
toolbarEl: getSel(editorEl, ".editor-toolbar"),
|
||||||
toolbar: {
|
toolbar: {
|
||||||
auxiliary: {
|
auxiliary: {
|
||||||
mark: {
|
mark: {
|
||||||
parentEl: getSel(editorEl, '[data-editor-auxiliary=mark]'),
|
parentEl: getSel(editorEl, "[data-editor-auxiliary=mark]"),
|
||||||
colorEl: getSel(editorEl, '[data-editor-auxiliary=mark] [name=mark-color]'),
|
colorEl: getSel(
|
||||||
},
|
editorEl,
|
||||||
multimedia: {
|
"[data-editor-auxiliary=mark] [name=mark-color]"
|
||||||
parentEl: getSel(editorEl, '[data-editor-auxiliary=multimedia]'),
|
),
|
||||||
fileEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file]'),
|
textColorEl: getSel(
|
||||||
uploadEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]'),
|
editorEl,
|
||||||
altEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-alt]'),
|
"[data-editor-auxiliary=mark] [name=mark-text-color]"
|
||||||
removeEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-remove]'),
|
),
|
||||||
},
|
},
|
||||||
link: {
|
multimedia: {
|
||||||
parentEl: getSel(editorEl, '[data-editor-auxiliary=link]'),
|
parentEl: getSel(editorEl, "[data-editor-auxiliary=multimedia]"),
|
||||||
urlEl: getSel(editorEl, '[data-editor-auxiliary=link] [name=link-url]'),
|
fileEl: getSel(
|
||||||
},
|
editorEl,
|
||||||
},
|
"[data-editor-auxiliary=multimedia] [name=multimedia-file]"
|
||||||
},
|
),
|
||||||
contentEl: getSel(editorEl, '.editor-content'),
|
uploadEl: getSel(
|
||||||
wordAlertEl: getSel(editorEl, '.editor-aviso-word'),
|
editorEl,
|
||||||
htmlEl: getSel(editorEl, 'textarea'),
|
"[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]"
|
||||||
}
|
),
|
||||||
console.debug('iniciando editor', editor)
|
altEl: getSel(
|
||||||
|
editorEl,
|
||||||
|
"[data-editor-auxiliary=multimedia] [name=multimedia-alt]"
|
||||||
|
),
|
||||||
|
removeEl: getSel(
|
||||||
|
editorEl,
|
||||||
|
"[data-editor-auxiliary=multimedia] [name=multimedia-remove]"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
parentEl: getSel(editorEl, "[data-editor-auxiliary=link]"),
|
||||||
|
urlEl: getSel(
|
||||||
|
editorEl,
|
||||||
|
"[data-editor-auxiliary=link] [name=link-url]"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
contentEl: getSel(editorEl, ".editor-content"),
|
||||||
|
wordAlertEl: getSel(editorEl, ".editor-aviso-word"),
|
||||||
|
htmlEl: getSel(editorEl, "textarea"),
|
||||||
|
};
|
||||||
|
console.debug("iniciando editor", editor);
|
||||||
|
|
||||||
// Recuperar el contenido si hay algo guardado, si tuviéramos un campo
|
// Recuperar el contenido si hay algo guardado, si tuviéramos un campo
|
||||||
// de última edición podríamos saber si el artículo fue editado
|
// de última edición podríamos saber si el artículo fue editado
|
||||||
// después o la versión local es la última.
|
// después o la versión local es la última.
|
||||||
//
|
//
|
||||||
// TODO: Preguntar si se lo quiere recuperar.
|
// TODO: Preguntar si se lo quiere recuperar.
|
||||||
restoreContent(editor)
|
restoreContent(editor);
|
||||||
|
|
||||||
// Word alert
|
// Word alert
|
||||||
editor.contentEl.addEventListener('paste', () => {
|
editor.contentEl.addEventListener("paste", () => {
|
||||||
editor.wordAlertEl.style.display = 'block'
|
editor.wordAlertEl.style.display = "block";
|
||||||
})
|
});
|
||||||
|
|
||||||
// Setup routine listeners
|
// Setup routine listeners
|
||||||
const observer = new MutationObserver(() => routine(editor))
|
const observer = new MutationObserver(() => routine(editor));
|
||||||
observer.observe(editor.contentEl, {
|
observer.observe(editor.contentEl, {
|
||||||
childList: true,
|
childList: true,
|
||||||
attributes: true,
|
attributes: true,
|
||||||
subtree: true,
|
subtree: true,
|
||||||
characterData: true,
|
characterData: true,
|
||||||
})
|
});
|
||||||
|
|
||||||
document.addEventListener("selectionchange", () => routine(editor))
|
document.addEventListener("selectionchange", () => routine(editor));
|
||||||
|
|
||||||
// Capture onClick
|
// Capture onClick
|
||||||
editor.contentEl.addEventListener('click', event => {
|
editor.contentEl.addEventListener(
|
||||||
const target = event.target! as Element
|
"click",
|
||||||
const type = getType(target)
|
(event) => {
|
||||||
if (!type || !type.type.onClick) {
|
const target = event.target! as Element;
|
||||||
setAuxiliaryToolbar(editor, null)
|
const type = getType(target);
|
||||||
clearSelected(editor)
|
if (!type || !type.type.onClick) {
|
||||||
return true
|
setAuxiliaryToolbar(editor, null);
|
||||||
}
|
clearSelected(editor);
|
||||||
type.type.onClick(editor, target)
|
return true;
|
||||||
return false
|
}
|
||||||
}, true)
|
type.type.onClick(editor, target);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
// Clean seleted
|
// Clean seleted
|
||||||
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
|
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
|
||||||
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
|
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
|
||||||
|
|
||||||
// Setup botones
|
// Setup botones
|
||||||
setupMarksButtons(editor)
|
setupMarksButtons(editor);
|
||||||
setupBlocksButtons(editor)
|
setupBlocksButtons(editor);
|
||||||
setupParentBlocksButtons(editor)
|
setupParentBlocksButtons(editor);
|
||||||
setupMultimediaButtons(editor)
|
setupMultimediaButtons(editor);
|
||||||
|
|
||||||
setupLinkAuxiliaryToolbar(editor)
|
setupLinkAuxiliaryToolbar(editor);
|
||||||
setupMultimediaAuxiliaryToolbar(editor)
|
setupMultimediaAuxiliaryToolbar(editor);
|
||||||
setupMarkAuxiliaryToolbar(editor)
|
setupMarkAuxiliaryToolbar(editor);
|
||||||
|
|
||||||
// Finally...
|
// Finally...
|
||||||
routine(editor)
|
routine(editor);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("turbolinks:load", () => {
|
document.addEventListener("turbolinks:load", () => {
|
||||||
const flash = document.querySelector<HTMLElement>('.js-flash')
|
const flash = document.querySelector<HTMLElement>(".js-flash");
|
||||||
|
|
||||||
if (flash) {
|
if (flash) {
|
||||||
const keys = JSON.parse(flash.dataset.keys || '[]')
|
const keys = JSON.parse(flash.dataset.keys || "[]");
|
||||||
|
|
||||||
switch (flash.dataset.target) {
|
switch (flash.dataset.target) {
|
||||||
case 'editor':
|
case "editor":
|
||||||
switch (flash.dataset.action) {
|
switch (flash.dataset.action) {
|
||||||
case 'forget-content':
|
case "forget-content":
|
||||||
keys.forEach(forgetContent)
|
keys.forEach(forgetContent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const editorEl of document.querySelectorAll<HTMLElement>('.editor[data-editor]')) {
|
for (const editorEl of document.querySelectorAll<HTMLElement>(
|
||||||
try {
|
".editor[data-editor]"
|
||||||
setupEditor(editorEl)
|
)) {
|
||||||
} catch (error) {
|
try {
|
||||||
// TODO: mostrar error
|
setupEditor(editorEl);
|
||||||
console.error('no se pudo iniciar el editor, error completo', error)
|
} catch (error) {
|
||||||
}
|
// TODO: mostrar error
|
||||||
}
|
console.error("no se pudo iniciar el editor, error completo", error);
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Guarda una copia local de los cambios para poder recuperarlos
|
* Guarda una copia local de los cambios para poder recuperarlos
|
||||||
|
@ -6,27 +6,33 @@ import { Editor } from 'editor/editor'
|
||||||
*
|
*
|
||||||
* Usamos la URL completa sin anchors.
|
* Usamos la URL completa sin anchors.
|
||||||
*/
|
*/
|
||||||
function getStorageKey (editor: Editor): string {
|
function getStorageKey(editor: Editor): string {
|
||||||
const keyEl = editor.editorEl.querySelector<HTMLInputElement>('[data-target="storage-key"]')
|
const keyEl = editor.editorEl.querySelector<HTMLInputElement>(
|
||||||
if (!keyEl) throw new Error('No encuentro la llave para guardar los artículos')
|
'[data-target="storage-key"]'
|
||||||
return keyEl.value
|
);
|
||||||
|
if (!keyEl)
|
||||||
|
throw new Error("No encuentro la llave para guardar los artículos");
|
||||||
|
return keyEl.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function forgetContent (storedKey: string): void {
|
export function forgetContent(storedKey: string): void {
|
||||||
window.localStorage.removeItem(storedKey)
|
window.localStorage.removeItem(storedKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storeContent (editor: Editor): void {
|
export function storeContent(editor: Editor): void {
|
||||||
if (editor.contentEl.innerText.trim().length === 0) return
|
if (editor.contentEl.innerText.trim().length === 0) return;
|
||||||
|
|
||||||
window.localStorage.setItem(getStorageKey(editor), editor.contentEl.innerHTML)
|
window.localStorage.setItem(
|
||||||
|
getStorageKey(editor),
|
||||||
|
editor.contentEl.innerHTML
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreContent (editor: Editor): void {
|
export function restoreContent(editor: Editor): void {
|
||||||
const content = window.localStorage.getItem(getStorageKey(editor))
|
const content = window.localStorage.getItem(getStorageKey(editor));
|
||||||
|
|
||||||
if (!content) return
|
if (!content) return;
|
||||||
if (content.trim().length === 0) return
|
if (content.trim().length === 0) return;
|
||||||
|
|
||||||
editor.contentEl.innerHTML = content
|
editor.contentEl.innerHTML = content;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,126 +1,140 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
import { marks } from 'editor/types/marks'
|
import { marks } from "editor/types/marks";
|
||||||
import { blocks, li, EditorBlock } from 'editor/types/blocks'
|
import { blocks, li, EditorBlock } from "editor/types/blocks";
|
||||||
import { parentBlocks } from 'editor/types/parentBlocks'
|
import { parentBlocks } from "editor/types/parentBlocks";
|
||||||
import { multimedia } from 'editor/types/multimedia'
|
import { multimedia } from "editor/types/multimedia";
|
||||||
import { blockNames, parentBlockNames, safeGetRangeAt, safeGetSelection } from 'editor/utils'
|
import {
|
||||||
|
blockNames,
|
||||||
|
parentBlockNames,
|
||||||
|
safeGetRangeAt,
|
||||||
|
safeGetSelection,
|
||||||
|
} from "editor/utils";
|
||||||
|
|
||||||
export interface EditorNode {
|
export interface EditorNode {
|
||||||
selector: string,
|
selector: string;
|
||||||
// la string es el nombre en la gran lista de types O 'text'
|
// la string es el nombre en la gran lista de types O 'text'
|
||||||
// XXX: esto es un hack para no poner EditorNode dentro de EditorNode,
|
// XXX: esto es un hack para no poner EditorNode dentro de EditorNode,
|
||||||
// quizás podemos hacer que esto sea una función que retorna bool
|
// quizás podemos hacer que esto sea una función que retorna bool
|
||||||
allowedChildren: string[] | 'ignore-children',
|
allowedChildren: string[] | "ignore-children";
|
||||||
|
|
||||||
// * si es 'do-nothing', no hace nada si está vacío (esto es para cuando
|
// * si es 'do-nothing', no hace nada si está vacío (esto es para cuando
|
||||||
// permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío)
|
// permitís 'text' entonces se puede tipear adentro, ej: párrafo vacío)
|
||||||
// * si es 'remove', sacamos el coso si está vacío.
|
// * si es 'remove', sacamos el coso si está vacío.
|
||||||
// ej: strong: { handleNothing: 'remove' }
|
// ej: strong: { handleNothing: 'remove' }
|
||||||
// * si es un block, insertamos el bloque y movemos la selección ahí
|
// * si es un block, insertamos el bloque y movemos la selección ahí
|
||||||
// ej: ul: { handleNothing: li }
|
// ej: ul: { handleNothing: li }
|
||||||
handleEmpty: 'do-nothing' | 'remove' | EditorBlock,
|
handleEmpty: "do-nothing" | "remove" | EditorBlock;
|
||||||
|
|
||||||
// esta función puede ser llamada para cosas que no necesariamente sea la
|
// esta función puede ser llamada para cosas que no necesariamente sea la
|
||||||
// creación del nodo con el botón; por ejemplo, al intentar recuperar
|
// creación del nodo con el botón; por ejemplo, al intentar recuperar
|
||||||
// el formato. esto es importante por que, por ejemplo, no deberíamos
|
// el formato. esto es importante por que, por ejemplo, no deberíamos
|
||||||
// cambiar la selección acá.
|
// cambiar la selección acá.
|
||||||
create: (editor: Editor) => HTMLElement,
|
create: (editor: Editor) => HTMLElement;
|
||||||
|
|
||||||
onClick?: (editor: Editor, target: Element) => void,
|
onClick?: (editor: Editor, target: Element) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const types: { [propName: string]: EditorNode } = {
|
export const types: { [propName: string]: EditorNode } = {
|
||||||
...marks,
|
...marks,
|
||||||
...blocks,
|
...blocks,
|
||||||
li,
|
li,
|
||||||
...parentBlocks,
|
...parentBlocks,
|
||||||
contentEl: {
|
contentEl: {
|
||||||
selector: '.editor-content',
|
selector: ".editor-content",
|
||||||
allowedChildren: [...blockNames, ...parentBlockNames, 'multimedia'],
|
allowedChildren: [...blockNames, ...parentBlockNames, "multimedia"],
|
||||||
handleEmpty: blocks.paragraph,
|
handleEmpty: blocks.paragraph,
|
||||||
create: () => { throw new Error('se intentó crear contentEl') }
|
create: () => {
|
||||||
},
|
throw new Error("se intentó crear contentEl");
|
||||||
br: {
|
},
|
||||||
selector: 'br',
|
},
|
||||||
allowedChildren: [],
|
br: {
|
||||||
handleEmpty: 'do-nothing',
|
selector: "br",
|
||||||
create: () => { throw new Error('se intentó crear br') }
|
allowedChildren: [],
|
||||||
},
|
handleEmpty: "do-nothing",
|
||||||
multimedia,
|
create: () => {
|
||||||
}
|
throw new Error("se intentó crear br");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
multimedia,
|
||||||
|
};
|
||||||
|
|
||||||
export function getType (node: Element): { typeName: string, type: EditorNode } | null {
|
export function getType(
|
||||||
for (let [typeName, type] of Object.entries(types)) {
|
node: Element
|
||||||
if (node.matches(type.selector)) {
|
): { typeName: string; type: EditorNode } | null {
|
||||||
return { typeName, type }
|
for (let [typeName, type] of Object.entries(types)) {
|
||||||
}
|
if (node.matches(type.selector)) {
|
||||||
}
|
return { typeName, type };
|
||||||
|
}
|
||||||
return null
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// encuentra el primer pariente que pueda tener al type, y retorna un array
|
// encuentra el primer pariente que pueda tener al type, y retorna un array
|
||||||
// donde
|
// donde
|
||||||
// array[0] = elemento que matchea el type
|
// array[0] = elemento que matchea el type
|
||||||
// array[array.len - 1] = primer elemento seleccionado
|
// array[array.len - 1] = primer elemento seleccionado
|
||||||
export function getValidParentInSelection (args: {
|
export function getValidParentInSelection(args: {
|
||||||
editor: Editor,
|
editor: Editor;
|
||||||
type: string,
|
type: string;
|
||||||
}): Element[] {
|
}): Element[] {
|
||||||
const sel = safeGetSelection(args.editor)
|
const sel = safeGetSelection(args.editor);
|
||||||
if (!sel) throw new Error('No se donde insertar esto')
|
if (!sel) throw new Error("No se donde insertar esto");
|
||||||
const range = safeGetRangeAt(sel)
|
const range = safeGetRangeAt(sel);
|
||||||
if (!range) throw new Error('No se donde insertar esto')
|
if (!range) throw new Error("No se donde insertar esto");
|
||||||
|
|
||||||
let list: Element[] = []
|
let list: Element[] = [];
|
||||||
|
|
||||||
if (!sel.anchorNode) {
|
|
||||||
throw new Error('No se donde insertar esto')
|
|
||||||
} else if (sel.anchorNode instanceof Element) {
|
|
||||||
list = [sel.anchorNode]
|
|
||||||
} else if (sel.anchorNode.parentElement) {
|
|
||||||
list = [sel.anchorNode.parentElement]
|
|
||||||
} else {
|
|
||||||
throw new Error('No se donde insertar esto')
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
if (!sel.anchorNode) {
|
||||||
const el = list[0]
|
throw new Error("No se donde insertar esto");
|
||||||
if (!args.editor.contentEl.contains(el)
|
} else if (sel.anchorNode instanceof Element) {
|
||||||
&& el != args.editor.contentEl)
|
list = [sel.anchorNode];
|
||||||
throw new Error('No se donde insertar esto')
|
} else if (sel.anchorNode.parentElement) {
|
||||||
const type = getType(el)
|
list = [sel.anchorNode.parentElement];
|
||||||
|
} else {
|
||||||
|
throw new Error("No se donde insertar esto");
|
||||||
|
}
|
||||||
|
|
||||||
if (type) {
|
while (true) {
|
||||||
//if (type.typeName === 'contentEl') break
|
const el = list[0];
|
||||||
//if (parentBlockNames.includes(type.typeName)) break
|
if (!args.editor.contentEl.contains(el) && el != args.editor.contentEl)
|
||||||
if ((type.type.allowedChildren instanceof Array)
|
throw new Error("No se donde insertar esto");
|
||||||
&& type.type.allowedChildren.includes(args.type)) break
|
const type = getType(el);
|
||||||
}
|
|
||||||
if (el.parentElement) {
|
if (type) {
|
||||||
list = [el.parentElement, ...list]
|
//if (type.typeName === 'contentEl') break
|
||||||
} else {
|
//if (parentBlockNames.includes(type.typeName)) break
|
||||||
throw new Error('No se donde insertar esto')
|
if (
|
||||||
}
|
type.type.allowedChildren instanceof Array &&
|
||||||
}
|
type.type.allowedChildren.includes(args.type)
|
||||||
|
)
|
||||||
return list
|
break;
|
||||||
|
}
|
||||||
|
if (el.parentElement) {
|
||||||
|
list = [el.parentElement, ...list];
|
||||||
|
} else {
|
||||||
|
throw new Error("No se donde insertar esto");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getValidChildren (node: Element, type: EditorNode): Node[] {
|
export function getValidChildren(node: Element, type: EditorNode): Node[] {
|
||||||
if (type.allowedChildren === 'ignore-children')
|
if (type.allowedChildren === "ignore-children")
|
||||||
throw new Error('se llamó a getValidChildren con un type que no lo permite!')
|
throw new Error(
|
||||||
return [...node.childNodes].filter(n => {
|
"se llamó a getValidChildren con un type que no lo permite!"
|
||||||
// si permite texto y esto es un texto, es válido
|
);
|
||||||
if (n.nodeType === Node.TEXT_NODE)
|
return [...node.childNodes].filter((n) => {
|
||||||
return type.allowedChildren.includes('text') && n.textContent?.length
|
// si permite texto y esto es un texto, es válido
|
||||||
|
if (n.nodeType === Node.TEXT_NODE)
|
||||||
|
return type.allowedChildren.includes("text") && n.textContent?.length;
|
||||||
|
|
||||||
// si no es un elemento, no es válido
|
// si no es un elemento, no es válido
|
||||||
if (!(n instanceof Element))
|
if (!(n instanceof Element)) return false;
|
||||||
return false
|
|
||||||
|
|
||||||
const t = getType(n)
|
const t = getType(n);
|
||||||
if (!t) return false
|
if (!t) return false;
|
||||||
return type.allowedChildren.includes(t.typeName)
|
return type.allowedChildren.includes(t.typeName);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,72 +1,76 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
import {
|
import {
|
||||||
safeGetSelection, safeGetRangeAt,
|
safeGetSelection,
|
||||||
moveChildren,
|
safeGetRangeAt,
|
||||||
markNames, blockNames, parentBlockNames,
|
moveChildren,
|
||||||
} from 'editor/utils'
|
markNames,
|
||||||
import { EditorNode, getType, getValidParentInSelection } from 'editor/types'
|
blockNames,
|
||||||
|
parentBlockNames,
|
||||||
|
} from "editor/utils";
|
||||||
|
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
|
||||||
|
|
||||||
export interface EditorBlock extends EditorNode {
|
export interface EditorBlock extends EditorNode {}
|
||||||
|
|
||||||
|
function makeBlock(tag: string): EditorBlock {
|
||||||
|
return {
|
||||||
|
selector: tag,
|
||||||
|
allowedChildren: [...markNames, "text"],
|
||||||
|
handleEmpty: "do-nothing",
|
||||||
|
create: () => document.createElement(tag),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeBlock (tag: string): EditorBlock {
|
export const li: EditorBlock = makeBlock("li");
|
||||||
return {
|
|
||||||
selector: tag,
|
|
||||||
allowedChildren: [...markNames, 'text'],
|
|
||||||
handleEmpty: 'do-nothing',
|
|
||||||
create: () => document.createElement(tag),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const li: EditorBlock = makeBlock('li')
|
|
||||||
|
|
||||||
// XXX: si agregás algo acá, agregalo a blockNames
|
// XXX: si agregás algo acá, agregalo a blockNames
|
||||||
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
|
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
|
||||||
export const blocks: { [propName: string]: EditorBlock } = {
|
export const blocks: { [propName: string]: EditorBlock } = {
|
||||||
paragraph: makeBlock('p'),
|
paragraph: makeBlock("p"),
|
||||||
h1: makeBlock('h1'),
|
h1: makeBlock("h1"),
|
||||||
h2: makeBlock('h2'),
|
h2: makeBlock("h2"),
|
||||||
h3: makeBlock('h3'),
|
h3: makeBlock("h3"),
|
||||||
h4: makeBlock('h4'),
|
h4: makeBlock("h4"),
|
||||||
h5: makeBlock('h5'),
|
h5: makeBlock("h5"),
|
||||||
h6: makeBlock('h6'),
|
h6: makeBlock("h6"),
|
||||||
unordered_list: {
|
unordered_list: {
|
||||||
...makeBlock('ul'),
|
...makeBlock("ul"),
|
||||||
allowedChildren: ['li'],
|
allowedChildren: ["li"],
|
||||||
handleEmpty: li,
|
handleEmpty: li,
|
||||||
},
|
},
|
||||||
ordered_list: {
|
ordered_list: {
|
||||||
...makeBlock('ol'),
|
...makeBlock("ol"),
|
||||||
allowedChildren: ['li'],
|
allowedChildren: ["li"],
|
||||||
handleEmpty: li,
|
handleEmpty: li,
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export function setupButtons (editor: Editor): void {
|
export function setupButtons(editor: Editor): void {
|
||||||
for (const [ name, type ] of Object.entries(blocks)) {
|
for (const [name, type] of Object.entries(blocks)) {
|
||||||
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="block-${name}"]`)
|
const buttonEl = editor.toolbarEl.querySelector(
|
||||||
if (!buttonEl) continue
|
`[data-editor-button="block-${name}"]`
|
||||||
buttonEl.addEventListener("click", event => {
|
);
|
||||||
event.preventDefault()
|
if (!buttonEl) continue;
|
||||||
|
buttonEl.addEventListener("click", (event) => {
|
||||||
const list = getValidParentInSelection({ editor, type: name })
|
event.preventDefault();
|
||||||
|
|
||||||
// No borrar cosas como multimedia
|
const list = getValidParentInSelection({ editor, type: name });
|
||||||
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
|
|
||||||
return
|
// No borrar cosas como multimedia
|
||||||
}
|
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
|
||||||
|
return;
|
||||||
let replacementType = list[1].matches(type.selector)
|
}
|
||||||
? blocks.paragraph
|
|
||||||
: type
|
let replacementType = list[1].matches(type.selector)
|
||||||
|
? blocks.paragraph
|
||||||
const el = replacementType.create(editor)
|
: type;
|
||||||
replacementType.onClick && replacementType.onClick(editor, el)
|
|
||||||
moveChildren(list[1], el, null)
|
const el = replacementType.create(editor);
|
||||||
list[0].replaceChild(el, list[1])
|
replacementType.onClick && replacementType.onClick(editor, el);
|
||||||
window.getSelection()?.collapse(el)
|
moveChildren(list[1], el, null);
|
||||||
|
list[0].replaceChild(el, list[1]);
|
||||||
return false
|
window.getSelection()?.collapse(el);
|
||||||
})
|
|
||||||
}
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +1,37 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
import { EditorNode } from 'editor/types'
|
import { EditorNode } from "editor/types";
|
||||||
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils'
|
import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
|
||||||
|
|
||||||
function select (editor: Editor, el: HTMLAnchorElement): void {
|
function select(editor: Editor, el: HTMLAnchorElement): void {
|
||||||
clearSelected(editor)
|
clearSelected(editor);
|
||||||
el.dataset.editorSelected = ''
|
el.dataset.editorSelected = "";
|
||||||
editor.toolbar.auxiliary.link.urlEl.value = el.href
|
editor.toolbar.auxiliary.link.urlEl.value = el.href;
|
||||||
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl)
|
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const link: EditorNode = {
|
export const link: EditorNode = {
|
||||||
selector: 'a',
|
selector: "a",
|
||||||
allowedChildren: [...markNames.filter(n => n !== 'link'), 'text'],
|
allowedChildren: [...markNames.filter((n) => n !== "link"), "text"],
|
||||||
handleEmpty: 'remove',
|
handleEmpty: "remove",
|
||||||
create: () => document.createElement('a'),
|
create: () => document.createElement("a"),
|
||||||
onClick (editor, el) {
|
onClick(editor, el) {
|
||||||
if (!(el instanceof HTMLAnchorElement))
|
if (!(el instanceof HTMLAnchorElement)) throw new Error("oh no");
|
||||||
throw new Error('oh no')
|
select(editor, el);
|
||||||
select(editor, el)
|
},
|
||||||
},
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function setupAuxiliaryToolbar (editor: Editor): void {
|
export function setupAuxiliaryToolbar(editor: Editor): void {
|
||||||
editor.toolbar.auxiliary.link.urlEl.addEventListener('input', event => {
|
editor.toolbar.auxiliary.link.urlEl.addEventListener("input", (event) => {
|
||||||
const url = editor.toolbar.auxiliary.link.urlEl.value
|
const url = editor.toolbar.auxiliary.link.urlEl.value;
|
||||||
const selectedEl = editor.contentEl
|
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
|
||||||
.querySelector<HTMLAnchorElement>('a[data-editor-selected]')
|
"a[data-editor-selected]"
|
||||||
if (!selectedEl)
|
);
|
||||||
throw new Error('No pude encontrar el link para setear el enlace')
|
if (!selectedEl)
|
||||||
|
throw new Error("No pude encontrar el link para setear el enlace");
|
||||||
selectedEl.href = url
|
|
||||||
})
|
selectedEl.href = url;
|
||||||
editor.toolbar.auxiliary.link.urlEl.addEventListener('keydown', event => {
|
});
|
||||||
if (event.keyCode == 13) event.preventDefault()
|
editor.toolbar.auxiliary.link.urlEl.addEventListener("keydown", (event) => {
|
||||||
})
|
if (event.keyCode == 13) event.preventDefault();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,49 +1,66 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
import { EditorNode } from 'editor/types'
|
import { EditorNode } from "editor/types";
|
||||||
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils'
|
import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
|
||||||
|
|
||||||
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2)
|
const hex = (x: string) => ("0" + parseInt(x).toString(16)).slice(-2);
|
||||||
// https://stackoverflow.com/a/3627747
|
// https://stackoverflow.com/a/3627747
|
||||||
// TODO: cambiar por una solución más copada
|
// TODO: cambiar por una solución más copada
|
||||||
function rgbToHex (rgb: string): string {
|
function rgbToHex(rgb: string): string {
|
||||||
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
|
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
|
||||||
if (!matches) throw new Error('no pude parsear el rgb()')
|
if (!matches) throw new Error("no pude parsear el rgb()");
|
||||||
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3])
|
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function select (editor: Editor, el: HTMLElement): void {
|
function select(editor: Editor, el: HTMLElement): void {
|
||||||
clearSelected(editor)
|
clearSelected(editor);
|
||||||
el.dataset.editorSelected = ''
|
el.dataset.editorSelected = "";
|
||||||
editor.toolbar.auxiliary.mark.colorEl.value
|
editor.toolbar.auxiliary.mark.colorEl.value = el.style.backgroundColor
|
||||||
= el.style.backgroundColor
|
? rgbToHex(el.style.backgroundColor)
|
||||||
? rgbToHex(el.style.backgroundColor)
|
: "#f206f9";
|
||||||
: '#f206f9'
|
editor.toolbar.auxiliary.mark.textColorEl.value = el.style.color
|
||||||
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl)
|
? rgbToHex(el.style.color)
|
||||||
|
: "#000000";
|
||||||
|
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mark: EditorNode = {
|
export const mark: EditorNode = {
|
||||||
selector: 'mark',
|
selector: "mark",
|
||||||
allowedChildren: [...markNames.filter(n => n !== 'mark'), 'text'],
|
allowedChildren: [...markNames.filter((n) => n !== "mark"), "text"],
|
||||||
handleEmpty: 'remove',
|
handleEmpty: "remove",
|
||||||
create: () => document.createElement('mark'),
|
create: () => document.createElement("mark"),
|
||||||
onClick (editor, el) {
|
onClick(editor, el) {
|
||||||
if (!(el instanceof HTMLElement))
|
if (!(el instanceof HTMLElement)) throw new Error("oh no");
|
||||||
throw new Error('oh no')
|
select(editor, el);
|
||||||
select(editor, el)
|
},
|
||||||
},
|
};
|
||||||
}
|
|
||||||
|
|
||||||
export function setupAuxiliaryToolbar (editor: Editor): void {
|
export function setupAuxiliaryToolbar(editor: Editor): void {
|
||||||
editor.toolbar.auxiliary.mark.colorEl.addEventListener('input', event => {
|
editor.toolbar.auxiliary.mark.colorEl.addEventListener("input", (event) => {
|
||||||
const color = editor.toolbar.auxiliary.mark.colorEl.value
|
const color = editor.toolbar.auxiliary.mark.colorEl.value;
|
||||||
const selectedEl = editor.contentEl
|
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
|
||||||
.querySelector<HTMLElement>('mark[data-editor-selected]')
|
"mark[data-editor-selected]"
|
||||||
if (!selectedEl)
|
);
|
||||||
throw new Error('No pude encontrar el mark para setear el color')
|
if (!selectedEl)
|
||||||
|
throw new Error("No pude encontrar el mark para setear el color");
|
||||||
selectedEl.style.backgroundColor = color
|
|
||||||
})
|
selectedEl.style.backgroundColor = color;
|
||||||
editor.toolbar.auxiliary.mark.colorEl.addEventListener('keydown', event => {
|
});
|
||||||
if (event.keyCode == 13) event.preventDefault()
|
editor.toolbar.auxiliary.mark.textColorEl.addEventListener(
|
||||||
})
|
"input",
|
||||||
|
(event) => {
|
||||||
|
const color = editor.toolbar.auxiliary.mark.textColorEl.value;
|
||||||
|
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
|
||||||
|
"mark[data-editor-selected]"
|
||||||
|
);
|
||||||
|
if (!selectedEl)
|
||||||
|
throw new Error(
|
||||||
|
"No pude encontrar el mark para setear el color del text"
|
||||||
|
);
|
||||||
|
|
||||||
|
selectedEl.style.color = color;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
editor.toolbar.auxiliary.mark.colorEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.keyCode == 13) event.preventDefault();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,96 +1,102 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
import { EditorNode } from 'editor/types'
|
import { EditorNode } from "editor/types";
|
||||||
import {
|
import {
|
||||||
safeGetSelection, safeGetRangeAt,
|
safeGetSelection,
|
||||||
moveChildren,
|
safeGetRangeAt,
|
||||||
markNames,
|
moveChildren,
|
||||||
} from 'editor/utils'
|
markNames,
|
||||||
import { link } from 'editor/types/link'
|
} from "editor/utils";
|
||||||
import { mark } from 'editor/types/mark'
|
import { link } from "editor/types/link";
|
||||||
|
import { mark } from "editor/types/mark";
|
||||||
|
|
||||||
function makeMark (name: string, tag: string): EditorNode {
|
function makeMark(name: string, tag: string): EditorNode {
|
||||||
return {
|
return {
|
||||||
selector: tag,
|
selector: tag,
|
||||||
allowedChildren: [...markNames.filter(n => n !== name), 'text'],
|
allowedChildren: [...markNames.filter((n) => n !== name), "text"],
|
||||||
handleEmpty: 'remove',
|
handleEmpty: "remove",
|
||||||
create: () => document.createElement(tag),
|
create: () => document.createElement(tag),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// XXX: si agregás algo acá, agregalo a markNames
|
// XXX: si agregás algo acá, agregalo a markNames
|
||||||
export const marks: { [propName: string]: EditorNode } = {
|
export const marks: { [propName: string]: EditorNode } = {
|
||||||
bold: makeMark('bold', 'strong'),
|
bold: makeMark("bold", "strong"),
|
||||||
italic: makeMark('italic', 'em'),
|
italic: makeMark("italic", "em"),
|
||||||
deleted: makeMark('deleted', 'del'),
|
deleted: makeMark("deleted", "del"),
|
||||||
underline: makeMark('underline', 'u'),
|
underline: makeMark("underline", "u"),
|
||||||
sub: makeMark('sub', 'sub'),
|
sub: makeMark("sub", "sub"),
|
||||||
super: makeMark('super', 'sup'),
|
super: makeMark("super", "sup"),
|
||||||
mark,
|
mark,
|
||||||
link,
|
link,
|
||||||
small: makeMark('small', 'small'),
|
small: makeMark("small", "small"),
|
||||||
}
|
};
|
||||||
|
|
||||||
function recursiveFilterSelection (
|
function recursiveFilterSelection(
|
||||||
node: Element,
|
node: Element,
|
||||||
selection: Selection,
|
selection: Selection,
|
||||||
selector: string,
|
selector: string
|
||||||
): Element[] {
|
): Element[] {
|
||||||
let output: Element[] = []
|
let output: Element[] = [];
|
||||||
for (const child of [...node.children]) {
|
for (const child of [...node.children]) {
|
||||||
if (child.matches(selector)
|
if (child.matches(selector) && selection.containsNode(child))
|
||||||
&& selection.containsNode(child)
|
output.push(child);
|
||||||
) output.push(child)
|
output = [
|
||||||
output = [...output, ...recursiveFilterSelection(child, selection, selector)]
|
...output,
|
||||||
}
|
...recursiveFilterSelection(child, selection, selector),
|
||||||
return output
|
];
|
||||||
|
}
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupButtons (editor: Editor): void {
|
export function setupButtons(editor: Editor): void {
|
||||||
for (const [ name, type ] of Object.entries(marks)) {
|
for (const [name, type] of Object.entries(marks)) {
|
||||||
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="mark-${name}"]`)
|
const buttonEl = editor.toolbarEl.querySelector(
|
||||||
if (!buttonEl) continue
|
`[data-editor-button="mark-${name}"]`
|
||||||
buttonEl.addEventListener("click", event => {
|
);
|
||||||
event.preventDefault()
|
if (!buttonEl) continue;
|
||||||
|
buttonEl.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
const sel = safeGetSelection(editor)
|
const sel = safeGetSelection(editor);
|
||||||
if (!sel) return
|
if (!sel) return;
|
||||||
const range = safeGetRangeAt(sel)
|
const range = safeGetRangeAt(sel);
|
||||||
if (!range) return
|
if (!range) return;
|
||||||
|
|
||||||
let parentEl = range.commonAncestorContainer
|
let parentEl = range.commonAncestorContainer;
|
||||||
while (!(parentEl instanceof Element)) {
|
while (!(parentEl instanceof Element)) {
|
||||||
if (!parentEl.parentElement) return
|
if (!parentEl.parentElement) return;
|
||||||
parentEl = parentEl.parentElement
|
parentEl = parentEl.parentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingMarks = recursiveFilterSelection(
|
const existingMarks = recursiveFilterSelection(
|
||||||
parentEl,
|
parentEl,
|
||||||
sel,
|
sel,
|
||||||
type.selector,
|
type.selector
|
||||||
)
|
);
|
||||||
console.debug('marks encontradas:', existingMarks)
|
console.debug("marks encontradas:", existingMarks);
|
||||||
|
|
||||||
if (existingMarks.length > 0) {
|
if (existingMarks.length > 0) {
|
||||||
const mark = existingMarks[0]
|
const mark = existingMarks[0];
|
||||||
if (!mark.parentElement)
|
if (!mark.parentElement) throw new Error(":/");
|
||||||
throw new Error(':/')
|
moveChildren(mark, mark.parentElement, mark);
|
||||||
moveChildren(mark, mark.parentElement, mark)
|
mark.parentElement.removeChild(mark);
|
||||||
mark.parentElement.removeChild(mark)
|
} else {
|
||||||
} else {
|
if (range.commonAncestorContainer === editor.contentEl)
|
||||||
if (range.commonAncestorContainer === editor.contentEl)
|
// TODO: mostrar error
|
||||||
// TODO: mostrar error
|
return console.error(
|
||||||
return console.error("No puedo marcar cosas a través de distintos bloques!")
|
"No puedo marcar cosas a través de distintos bloques!"
|
||||||
|
);
|
||||||
|
|
||||||
const tagEl = type.create(editor)
|
const tagEl = type.create(editor);
|
||||||
type.onClick && type.onClick(editor, tagEl)
|
type.onClick && type.onClick(editor, tagEl);
|
||||||
|
|
||||||
tagEl.appendChild(range.extractContents())
|
tagEl.appendChild(range.extractContents());
|
||||||
|
|
||||||
range.insertNode(tagEl)
|
range.insertNode(tagEl);
|
||||||
range.selectNode(tagEl)
|
range.selectNode(tagEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,206 +1,230 @@
|
||||||
import * as ActiveStorage from '@rails/activestorage'
|
import * as ActiveStorage from "@rails/activestorage";
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
import { EditorNode, getValidParentInSelection } from 'editor/types'
|
import { EditorNode, getValidParentInSelection } from "editor/types";
|
||||||
import {
|
import {
|
||||||
safeGetSelection, safeGetRangeAt,
|
safeGetSelection,
|
||||||
markNames, parentBlockNames,
|
safeGetRangeAt,
|
||||||
setAuxiliaryToolbar, clearSelected,
|
markNames,
|
||||||
} from 'editor/utils'
|
parentBlockNames,
|
||||||
|
setAuxiliaryToolbar,
|
||||||
|
clearSelected,
|
||||||
|
} from "editor/utils";
|
||||||
|
|
||||||
function uploadFile (file: File): Promise<string> {
|
function uploadFile(file: File): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const upload = new ActiveStorage.DirectUpload(
|
const upload = new ActiveStorage.DirectUpload(
|
||||||
file,
|
file,
|
||||||
origin + '/rails/active_storage/direct_uploads',
|
origin + "/rails/active_storage/direct_uploads"
|
||||||
)
|
);
|
||||||
|
|
||||||
upload.create((error: any, blob: any) => {
|
upload.create((error: any, blob: any) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(error)
|
reject(error);
|
||||||
} else {
|
} else {
|
||||||
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`
|
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`;
|
||||||
resolve(url)
|
resolve(url);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAlt (multimediaInnerEl: HTMLElement): string | null {
|
function getAlt(multimediaInnerEl: HTMLElement): string | null {
|
||||||
switch (multimediaInnerEl.tagName) {
|
switch (multimediaInnerEl.tagName) {
|
||||||
case 'VIDEO':
|
case "VIDEO":
|
||||||
case 'AUDIO':
|
case "AUDIO":
|
||||||
return multimediaInnerEl.getAttribute('aria-label')
|
return multimediaInnerEl.getAttribute("aria-label");
|
||||||
case 'IMG':
|
case "IMG":
|
||||||
return (multimediaInnerEl as HTMLImageElement).alt
|
return (multimediaInnerEl as HTMLImageElement).alt;
|
||||||
case 'IFRAME':
|
case "IFRAME":
|
||||||
return multimediaInnerEl.title
|
return multimediaInnerEl.title;
|
||||||
default:
|
default:
|
||||||
throw new Error('no pude conseguir el alt')
|
throw new Error("no pude conseguir el alt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function setAlt (multimediaInnerEl: HTMLElement, value: string): void {
|
function setAlt(multimediaInnerEl: HTMLElement, value: string): void {
|
||||||
switch (multimediaInnerEl.tagName) {
|
switch (multimediaInnerEl.tagName) {
|
||||||
case 'VIDEO':
|
case "VIDEO":
|
||||||
case 'AUDIO':
|
case "AUDIO":
|
||||||
multimediaInnerEl.setAttribute('aria-label', value)
|
multimediaInnerEl.setAttribute("aria-label", value);
|
||||||
break
|
break;
|
||||||
case 'IMG':
|
case "IMG":
|
||||||
(multimediaInnerEl as HTMLImageElement).alt = value
|
(multimediaInnerEl as HTMLImageElement).alt = value;
|
||||||
break
|
break;
|
||||||
case 'IFRAME':
|
case "IFRAME":
|
||||||
multimediaInnerEl.title = value
|
multimediaInnerEl.title = value;
|
||||||
break
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error('no pude setear el alt')
|
throw new Error("no pude setear el alt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function select (editor: Editor, el: HTMLElement): void {
|
function select(editor: Editor, el: HTMLElement): void {
|
||||||
clearSelected(editor)
|
clearSelected(editor);
|
||||||
el.dataset.editorSelected = ''
|
el.dataset.editorSelected = "";
|
||||||
|
|
||||||
const innerEl = el.querySelector<HTMLElement>('[data-multimedia-inner]')
|
const innerEl = el.querySelector<HTMLElement>("[data-multimedia-inner]");
|
||||||
if (!innerEl) throw new Error('No hay multimedia válida')
|
if (!innerEl) throw new Error("No hay multimedia válida");
|
||||||
if (innerEl.tagName === "P") {
|
if (innerEl.tagName === "P") {
|
||||||
editor.toolbar.auxiliary.multimedia.altEl.value = "";
|
editor.toolbar.auxiliary.multimedia.altEl.value = "";
|
||||||
editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
|
editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
|
||||||
} else {
|
} else {
|
||||||
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
|
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
|
||||||
editor.toolbar.auxiliary.multimedia.altEl.disabled = false;
|
editor.toolbar.auxiliary.multimedia.altEl.disabled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl)
|
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.multimedia.parentEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const multimedia: EditorNode = {
|
export const multimedia: EditorNode = {
|
||||||
selector: 'figure[data-multimedia]',
|
selector: "figure[data-multimedia]",
|
||||||
allowedChildren: 'ignore-children',
|
allowedChildren: "ignore-children",
|
||||||
handleEmpty: 'remove',
|
handleEmpty: "remove",
|
||||||
create: () => {
|
create: () => {
|
||||||
const figureEl = document.createElement('figure')
|
const figureEl = document.createElement("figure");
|
||||||
figureEl.dataset.multimedia = ''
|
figureEl.dataset.multimedia = "";
|
||||||
figureEl.contentEditable = 'false'
|
figureEl.contentEditable = "false";
|
||||||
|
|
||||||
const placeholderEl = document.createElement('p')
|
const placeholderEl = document.createElement("p");
|
||||||
placeholderEl.dataset.multimediaInner = ''
|
placeholderEl.dataset.multimediaInner = "";
|
||||||
// TODO i18n
|
// TODO i18n
|
||||||
placeholderEl.append('¡Clickeame para subir un archivo!')
|
placeholderEl.append("¡Clickeame para subir un archivo!");
|
||||||
figureEl.appendChild(placeholderEl)
|
figureEl.appendChild(placeholderEl);
|
||||||
|
|
||||||
const descriptionEl = document.createElement('figcaption')
|
const descriptionEl = document.createElement("figcaption");
|
||||||
descriptionEl.contentEditable = 'true'
|
descriptionEl.contentEditable = "true";
|
||||||
// TODO i18n
|
// TODO i18n
|
||||||
descriptionEl.append('Escribí acá la descripción del archivo.')
|
descriptionEl.append("Escribí acá la descripción del archivo.");
|
||||||
figureEl.appendChild(descriptionEl)
|
figureEl.appendChild(descriptionEl);
|
||||||
|
|
||||||
return figureEl
|
return figureEl;
|
||||||
},
|
},
|
||||||
onClick (editor, el) {
|
onClick(editor, el) {
|
||||||
if (!(el instanceof HTMLElement))
|
if (!(el instanceof HTMLElement)) throw new Error("oh no");
|
||||||
throw new Error('oh no')
|
select(editor, el);
|
||||||
select(editor, el)
|
},
|
||||||
},
|
};
|
||||||
}
|
function createElementWithFile(url: string, type: string): HTMLElement {
|
||||||
function createElementWithFile (url: string, type: string): HTMLElement {
|
if (type.match(/^image\/.+$/)) {
|
||||||
if (type.match(/^image\/.+$/)) {
|
const el = document.createElement("img");
|
||||||
const el = document.createElement('img')
|
el.dataset.multimediaInner = "";
|
||||||
el.dataset.multimediaInner = ''
|
el.src = url;
|
||||||
el.src = url
|
return el;
|
||||||
return el
|
} else if (type.match(/^video\/.+$/)) {
|
||||||
} else if (type.match(/^video\/.+$/)) {
|
const el = document.createElement("video");
|
||||||
const el = document.createElement('video')
|
el.controls = true;
|
||||||
el.controls = true
|
el.dataset.multimediaInner = "";
|
||||||
el.dataset.multimediaInner = ''
|
el.src = url;
|
||||||
el.src = url
|
return el;
|
||||||
return el
|
} else if (type.match(/^audio\/.+$/)) {
|
||||||
} else if (type.match(/^audio\/.+$/)) {
|
const el = document.createElement("audio");
|
||||||
const el = document.createElement('audio')
|
el.controls = true;
|
||||||
el.controls = true
|
el.dataset.multimediaInner = "";
|
||||||
el.dataset.multimediaInner = ''
|
el.src = url;
|
||||||
el.src = url
|
return el;
|
||||||
return el
|
} else if (type.match(/^application\/pdf$/)) {
|
||||||
} else if (type.match(/^application\/pdf$/)) {
|
const el = document.createElement("iframe");
|
||||||
const el = document.createElement('iframe')
|
el.dataset.multimediaInner = "";
|
||||||
el.dataset.multimediaInner = ''
|
el.src = url;
|
||||||
el.src = url
|
return el;
|
||||||
return el
|
} else {
|
||||||
} else {
|
// TODO: chequear si el archivo es válido antes de subir
|
||||||
// TODO: chequear si el archivo es válido antes de subir
|
throw new Error("Tipo de archivo no reconocido");
|
||||||
throw new Error('Tipo de archivo no reconocido')
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupAuxiliaryToolbar (editor: Editor): void {
|
export function setupAuxiliaryToolbar(editor: Editor): void {
|
||||||
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener('click', event => {
|
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener(
|
||||||
const files = editor.toolbar.auxiliary.multimedia.fileEl.files
|
"click",
|
||||||
if (!files || !files.length) throw new Error('no hay archivos para subir')
|
(event) => {
|
||||||
const file = files[0]
|
const files = editor.toolbar.auxiliary.multimedia.fileEl.files;
|
||||||
|
if (!files || !files.length)
|
||||||
|
throw new Error("no hay archivos para subir");
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
const selectedEl = editor.contentEl
|
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
|
||||||
.querySelector<HTMLElement>('figure[data-editor-selected]')
|
"figure[data-editor-selected]"
|
||||||
if (!selectedEl)
|
);
|
||||||
throw new Error('No pude encontrar el elemento para setear el archivo')
|
if (!selectedEl)
|
||||||
|
throw new Error("No pude encontrar el elemento para setear el archivo");
|
||||||
|
|
||||||
selectedEl.dataset.editorLoading = ''
|
selectedEl.dataset.editorLoading = "";
|
||||||
uploadFile(file)
|
uploadFile(file)
|
||||||
.then(url => {
|
.then((url) => {
|
||||||
const innerEl = selectedEl.querySelector('[data-multimedia-inner]')
|
const innerEl = selectedEl.querySelector("[data-multimedia-inner]");
|
||||||
if (!innerEl) throw new Error('No hay multimedia a reemplazar')
|
if (!innerEl) throw new Error("No hay multimedia a reemplazar");
|
||||||
|
|
||||||
const el = createElementWithFile(url, file.type)
|
const el = createElementWithFile(url, file.type);
|
||||||
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value)
|
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value);
|
||||||
selectedEl.replaceChild(el, innerEl)
|
selectedEl.replaceChild(el, innerEl);
|
||||||
|
|
||||||
select(editor, selectedEl)
|
select(editor, selectedEl);
|
||||||
|
|
||||||
delete selectedEl.dataset.editorError
|
delete selectedEl.dataset.editorError;
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
console.error(err)
|
console.error(err);
|
||||||
// TODO: mostrar error
|
// TODO: mostrar error
|
||||||
selectedEl.dataset.editorError = ''
|
selectedEl.dataset.editorError = "";
|
||||||
})
|
})
|
||||||
.finally(() => { delete selectedEl.dataset.editorLoading })
|
.finally(() => {
|
||||||
})
|
delete selectedEl.dataset.editorLoading;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener('click', event => {
|
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener(
|
||||||
const selectedEl = editor.contentEl
|
"click",
|
||||||
.querySelector<HTMLElement>('figure[data-editor-selected]')
|
(event) => {
|
||||||
if (!selectedEl)
|
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
|
||||||
throw new Error('No pude encontrar el elemento para borrar')
|
"figure[data-editor-selected]"
|
||||||
|
);
|
||||||
|
if (!selectedEl)
|
||||||
|
throw new Error("No pude encontrar el elemento para borrar");
|
||||||
|
|
||||||
selectedEl.parentElement?.removeChild(selectedEl)
|
selectedEl.parentElement?.removeChild(selectedEl);
|
||||||
setAuxiliaryToolbar(editor, null)
|
setAuxiliaryToolbar(editor, null);
|
||||||
})
|
}
|
||||||
|
);
|
||||||
|
|
||||||
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('input', event => {
|
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
|
||||||
const selectedEl = editor.contentEl
|
"input",
|
||||||
.querySelector<HTMLAnchorElement>('figure[data-editor-selected]')
|
(event) => {
|
||||||
if (!selectedEl)
|
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
|
||||||
throw new Error('No pude encontrar el multimedia para setear el alt')
|
"figure[data-editor-selected]"
|
||||||
|
);
|
||||||
|
if (!selectedEl)
|
||||||
|
throw new Error("No pude encontrar el multimedia para setear el alt");
|
||||||
|
|
||||||
const innerEl = selectedEl.querySelector<HTMLElement>('[data-multimedia-inner]')
|
const innerEl = selectedEl.querySelector<HTMLElement>(
|
||||||
if (!innerEl) throw new Error('No hay multimedia a para setear el alt')
|
"[data-multimedia-inner]"
|
||||||
|
);
|
||||||
|
if (!innerEl) throw new Error("No hay multimedia a para setear el alt");
|
||||||
|
|
||||||
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value)
|
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value);
|
||||||
})
|
}
|
||||||
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('keydown', event => {
|
);
|
||||||
if (event.keyCode == 13) event.preventDefault()
|
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
|
||||||
})
|
"keydown",
|
||||||
|
(event) => {
|
||||||
|
if (event.keyCode == 13) event.preventDefault();
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupButtons (editor: Editor): void {
|
export function setupButtons(editor: Editor): void {
|
||||||
const buttonEl = editor.toolbarEl.querySelector('[data-editor-button="multimedia"]')
|
const buttonEl = editor.toolbarEl.querySelector(
|
||||||
if (!buttonEl) throw new Error('No encontre el botón de multimedia')
|
'[data-editor-button="multimedia"]'
|
||||||
buttonEl.addEventListener('click', event => {
|
);
|
||||||
event.preventDefault()
|
if (!buttonEl) throw new Error("No encontre el botón de multimedia");
|
||||||
|
buttonEl.addEventListener("click", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
const list = getValidParentInSelection({ editor, type: 'multimedia' })
|
const list = getValidParentInSelection({ editor, type: "multimedia" });
|
||||||
|
|
||||||
const el = multimedia.create(editor)
|
const el = multimedia.create(editor);
|
||||||
list[0].insertBefore(el, list[1].nextElementSibling)
|
list[0].insertBefore(el, list[1].nextElementSibling);
|
||||||
select(editor, el)
|
select(editor, el);
|
||||||
|
|
||||||
return false
|
return false;
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,70 +1,78 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
import {
|
import {
|
||||||
safeGetSelection, safeGetRangeAt,
|
safeGetSelection,
|
||||||
moveChildren,
|
safeGetRangeAt,
|
||||||
blockNames, parentBlockNames,
|
moveChildren,
|
||||||
} from 'editor/utils'
|
blockNames,
|
||||||
import { EditorNode, getType, getValidParentInSelection } from 'editor/types'
|
parentBlockNames,
|
||||||
|
} from "editor/utils";
|
||||||
|
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
|
||||||
|
|
||||||
function makeParentBlock (tag: string, create: EditorNode["create"]): EditorNode {
|
function makeParentBlock(
|
||||||
return {
|
tag: string,
|
||||||
selector: tag,
|
create: EditorNode["create"]
|
||||||
allowedChildren: [...blockNames, 'multimedia'],
|
): EditorNode {
|
||||||
handleEmpty: 'remove',
|
return {
|
||||||
create,
|
selector: tag,
|
||||||
}
|
allowedChildren: [...blockNames, "multimedia"],
|
||||||
|
handleEmpty: "remove",
|
||||||
|
create,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: añadir blockquote
|
// TODO: añadir blockquote
|
||||||
// XXX: si agregás algo acá, probablemente le quieras hacer un botón
|
// XXX: si agregás algo acá, probablemente le quieras hacer un botón
|
||||||
// en app/views/posts/attributes/_content.haml
|
// en app/views/posts/attributes/_content.haml
|
||||||
export const parentBlocks: { [propName: string]: EditorNode } = {
|
export const parentBlocks: { [propName: string]: EditorNode } = {
|
||||||
left: makeParentBlock('div[data-align=left]', () => {
|
left: makeParentBlock("div[data-align=left]", () => {
|
||||||
const el = document.createElement('div')
|
const el = document.createElement("div");
|
||||||
el.dataset.align = 'left'
|
el.dataset.align = "left";
|
||||||
return el
|
el.style.textAlign = "left";
|
||||||
}),
|
return el;
|
||||||
center: makeParentBlock('div[data-align=center]', () => {
|
}),
|
||||||
const el = document.createElement('div')
|
center: makeParentBlock("div[data-align=center]", () => {
|
||||||
el.dataset.align = 'center'
|
const el = document.createElement("div");
|
||||||
return el
|
el.dataset.align = "center";
|
||||||
}),
|
el.style.textAlign = "center";
|
||||||
right: makeParentBlock('div[data-align=right]', () => {
|
return el;
|
||||||
const el = document.createElement('div')
|
}),
|
||||||
el.dataset.align = 'right'
|
right: makeParentBlock("div[data-align=right]", () => {
|
||||||
return el
|
const el = document.createElement("div");
|
||||||
}),
|
el.dataset.align = "right";
|
||||||
}
|
el.style.textAlign = "right";
|
||||||
|
return el;
|
||||||
export function setupButtons (editor: Editor): void {
|
}),
|
||||||
for (const [ name, type ] of Object.entries(parentBlocks)) {
|
};
|
||||||
const buttonEl = editor.toolbarEl.querySelector(
|
|
||||||
`[data-editor-button="parentBlock-${name}"]`
|
export function setupButtons(editor: Editor): void {
|
||||||
)
|
for (const [name, type] of Object.entries(parentBlocks)) {
|
||||||
if (!buttonEl) continue
|
const buttonEl = editor.toolbarEl.querySelector(
|
||||||
buttonEl.addEventListener("click", event => {
|
`[data-editor-button="parentBlock-${name}"]`
|
||||||
event.preventDefault()
|
);
|
||||||
|
if (!buttonEl) continue;
|
||||||
// TODO: Esto solo mueve el bloque en el que está el final de la selección
|
buttonEl.addEventListener("click", (event) => {
|
||||||
// (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl
|
event.preventDefault();
|
||||||
// para encontrar los bloques que están seleccionados y moverlos/cambiarles
|
|
||||||
// el parentBlock)
|
// TODO: Esto solo mueve el bloque en el que está el final de la selección
|
||||||
|
// (anchorNode). quizás lo podemos hacer al revés (iterar desde contentEl
|
||||||
const list = getValidParentInSelection({ editor, type: name })
|
// para encontrar los bloques que están seleccionados y moverlos/cambiarles
|
||||||
|
// el parentBlock)
|
||||||
const replacementEl = type.create(editor)
|
|
||||||
if (list[0] == editor.contentEl) {
|
const list = getValidParentInSelection({ editor, type: name });
|
||||||
// no está en un parentBlock
|
|
||||||
editor.contentEl.insertBefore(replacementEl, list[1])
|
const replacementEl = type.create(editor);
|
||||||
replacementEl.appendChild(list[1])
|
if (list[0] == editor.contentEl) {
|
||||||
} else {
|
// no está en un parentBlock
|
||||||
// está en un parentBlock
|
editor.contentEl.insertBefore(replacementEl, list[1]);
|
||||||
moveChildren(list[0], replacementEl, null)
|
replacementEl.appendChild(list[1]);
|
||||||
editor.contentEl.replaceChild(replacementEl, list[0])
|
} else {
|
||||||
}
|
// está en un parentBlock
|
||||||
window.getSelection()?.collapse(replacementEl)
|
moveChildren(list[0], replacementEl, null);
|
||||||
|
editor.contentEl.replaceChild(replacementEl, list[0]);
|
||||||
return false
|
}
|
||||||
})
|
window.getSelection()?.collapse(replacementEl);
|
||||||
}
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,77 +1,101 @@
|
||||||
import { Editor } from 'editor/editor'
|
import { Editor } from "editor/editor";
|
||||||
|
|
||||||
export const blockNames = ['paragraph', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'unordered_list', 'ordered_list']
|
export const blockNames = [
|
||||||
export const markNames = ['bold', 'italic', 'deleted', 'underline', 'sub', 'super', 'mark', 'link', 'small']
|
"paragraph",
|
||||||
export const parentBlockNames = ['left', 'center', 'right']
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
"unordered_list",
|
||||||
|
"ordered_list",
|
||||||
|
];
|
||||||
|
export const markNames = [
|
||||||
|
"bold",
|
||||||
|
"italic",
|
||||||
|
"deleted",
|
||||||
|
"underline",
|
||||||
|
"sub",
|
||||||
|
"super",
|
||||||
|
"mark",
|
||||||
|
"link",
|
||||||
|
"small",
|
||||||
|
];
|
||||||
|
export const parentBlockNames = ["left", "center", "right"];
|
||||||
|
|
||||||
export function moveChildren (from: Element, to: Element, toRef: Node | null) {
|
export function moveChildren(from: Element, to: Element, toRef: Node | null) {
|
||||||
while (from.firstChild) to.insertBefore(from.firstChild, toRef)
|
while (from.firstChild) to.insertBefore(from.firstChild, toRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDirectChild (node: Node, supposedChild: Node): boolean {
|
export function isDirectChild(node: Node, supposedChild: Node): boolean {
|
||||||
for (const child of node.childNodes) {
|
for (const child of node.childNodes) {
|
||||||
if (child == supposedChild) return true
|
if (child == supposedChild) return true;
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safeGetSelection (editor: Editor): Selection | null {
|
export function safeGetSelection(editor: Editor): Selection | null {
|
||||||
const sel = window.getSelection()
|
const sel = window.getSelection();
|
||||||
if (!sel) return null
|
if (!sel) return null;
|
||||||
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
|
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
|
||||||
// deberíamos mostrar un error?
|
// deberíamos mostrar un error?
|
||||||
if (
|
if (
|
||||||
!editor.contentEl.contains(sel.anchorNode)
|
!editor.contentEl.contains(sel.anchorNode) ||
|
||||||
|| !editor.contentEl.contains(sel.focusNode)
|
!editor.contentEl.contains(sel.focusNode) ||
|
||||||
|| sel.anchorNode == editor.contentEl
|
sel.anchorNode == editor.contentEl ||
|
||||||
|| sel.focusNode == editor.contentEl
|
sel.focusNode == editor.contentEl
|
||||||
) return null
|
)
|
||||||
return sel
|
return null;
|
||||||
|
return sel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safeGetRangeAt (selection: Selection, num = 0): Range | null {
|
export function safeGetRangeAt(selection: Selection, num = 0): Range | null {
|
||||||
try {
|
try {
|
||||||
return selection.getRangeAt(num)
|
return selection.getRangeAt(num);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SplitNode {
|
interface SplitNode {
|
||||||
range: Range,
|
range: Range;
|
||||||
node: Node,
|
node: Node;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function splitNode (node: Element, range: Range): [SplitNode, SplitNode] {
|
export function splitNode(node: Element, range: Range): [SplitNode, SplitNode] {
|
||||||
const [left, right] = [
|
const [left, right] = [
|
||||||
{ range: document.createRange(), node: node.cloneNode(false) },
|
{ range: document.createRange(), node: node.cloneNode(false) },
|
||||||
{ range: document.createRange(), node: node.cloneNode(false) },
|
{ range: document.createRange(), node: node.cloneNode(false) },
|
||||||
]
|
];
|
||||||
|
|
||||||
if (node.firstChild) left.range.setStartBefore(node.firstChild)
|
if (node.firstChild) left.range.setStartBefore(node.firstChild);
|
||||||
left.range.setEnd(range.startContainer, range.startOffset)
|
left.range.setEnd(range.startContainer, range.startOffset);
|
||||||
left.range.surroundContents(left.node)
|
left.range.surroundContents(left.node);
|
||||||
|
|
||||||
right.range.setStart(range.endContainer, range.endOffset)
|
right.range.setStart(range.endContainer, range.endOffset);
|
||||||
if (node.lastChild) right.range.setEndAfter(node.lastChild)
|
if (node.lastChild) right.range.setEndAfter(node.lastChild);
|
||||||
right.range.surroundContents(right.node)
|
right.range.surroundContents(right.node);
|
||||||
|
|
||||||
if (!node.parentElement)
|
if (!node.parentElement)
|
||||||
throw new Error('No pude separar los nodos por que no tiene parentNode')
|
throw new Error("No pude separar los nodos por que no tiene parentNode");
|
||||||
|
|
||||||
moveChildren(node, node.parentElement, node)
|
moveChildren(node, node.parentElement, node);
|
||||||
node.parentElement.removeChild(node)
|
node.parentElement.removeChild(node);
|
||||||
|
|
||||||
return [left, right]
|
return [left, right];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuxiliaryToolbar (editor: Editor, bar: HTMLElement | null): void {
|
export function setAuxiliaryToolbar(
|
||||||
for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) {
|
editor: Editor,
|
||||||
delete parentEl.dataset.editorAuxiliaryActive
|
bar: HTMLElement | null
|
||||||
}
|
): void {
|
||||||
if (bar) bar.dataset.editorAuxiliaryActive = 'active'
|
for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) {
|
||||||
|
delete parentEl.dataset.editorAuxiliaryActive;
|
||||||
|
}
|
||||||
|
if (bar) bar.dataset.editorAuxiliaryActive = "active";
|
||||||
}
|
}
|
||||||
export function clearSelected (editor: Editor): void {
|
export function clearSelected(editor: Editor): void {
|
||||||
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
|
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
|
||||||
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
|
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import 'etc'
|
||||||
import Rails from '@rails/ujs'
|
import Rails from '@rails/ujs'
|
||||||
import Turbolinks from 'turbolinks'
|
import Turbolinks from 'turbolinks'
|
||||||
import * as ActiveStorage from '@rails/activestorage'
|
import * as ActiveStorage from '@rails/activestorage'
|
||||||
|
import 'chartkick/chart.js'
|
||||||
|
|
||||||
Rails.start()
|
Rails.start()
|
||||||
Turbolinks.start()
|
Turbolinks.start()
|
||||||
|
|
|
@ -30,15 +30,17 @@ class BacktraceJob < ApplicationJob
|
||||||
|
|
||||||
# Encuentra el código fuente del error
|
# Encuentra el código fuente del error
|
||||||
source = data.dig('sourcesContent', data['sources']&.index(backtrace['file']))&.split("\n")
|
source = data.dig('sourcesContent', data['sources']&.index(backtrace['file']))&.split("\n")
|
||||||
backtrace['function'] = source[backtrace['line'] - 1] if source.present?
|
# XXX: Elimina la sangría aunque cambie las columnas porque
|
||||||
|
# eso lo vamos a ver en el archivo fuente directo.
|
||||||
|
backtrace['function'] = source[backtrace['line'] - 1].strip if source.present?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
begin
|
begin
|
||||||
raise BacktraceException, "#{origin}: #{params['errors']&.first&.dig('message')}"
|
raise BacktraceException, "#{origin}: #{message}"
|
||||||
rescue BacktraceException => e
|
rescue BacktraceException => e
|
||||||
ExceptionNotifier.notify_exception(e, data: { site: site.name, params: params, _backtrace: true })
|
ExceptionNotifier.notify_exception(e, data: { site: site.name, params: params, javascript_backtrace: true })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -102,4 +104,9 @@ class BacktraceJob < ApplicationJob
|
||||||
rescue URI::Error
|
rescue URI::Error
|
||||||
params.dig('context', 'url')
|
params.dig('context', 'url')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [String,Nil]
|
||||||
|
def message
|
||||||
|
@message ||= params['errors']&.first&.dig('message')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,16 +5,33 @@ class DeployJob < ApplicationJob
|
||||||
class DeployException < StandardError; end
|
class DeployException < StandardError; end
|
||||||
|
|
||||||
# rubocop:disable Metrics/MethodLength
|
# rubocop:disable Metrics/MethodLength
|
||||||
def perform(site, notify = true)
|
def perform(site, notify = true, time = Time.now)
|
||||||
ActiveRecord::Base.connection_pool.with_connection do
|
ActiveRecord::Base.connection_pool.with_connection do
|
||||||
@site = Site.find(site)
|
@site = Site.find(site)
|
||||||
@site.update_attribute :status, 'building'
|
|
||||||
|
# Si ya hay una tarea corriendo, aplazar esta. Si estuvo
|
||||||
|
# esperando más de 10 minutos, recuperar el estado anterior.
|
||||||
|
#
|
||||||
|
# Como el trabajo actual se aplaza al siguiente, arrastrar la
|
||||||
|
# hora original para poder ir haciendo timeouts.
|
||||||
|
if @site.building?
|
||||||
|
if 10.minutes.ago >= time
|
||||||
|
@site.update status: 'waiting'
|
||||||
|
raise DeployException,
|
||||||
|
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
|
||||||
|
end
|
||||||
|
|
||||||
|
DeployJob.perform_in(60, site, notify, time)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@site.update status: 'building'
|
||||||
# Asegurarse que DeployLocal sea el primero!
|
# Asegurarse que DeployLocal sea el primero!
|
||||||
@deployed = { deploy_local: deploy_locally }
|
@deployed = { deploy_local: deploy_locally }
|
||||||
|
|
||||||
# No es opcional
|
# No es opcional
|
||||||
unless @deployed[:deploy_local]
|
unless @deployed[:deploy_local]
|
||||||
@site.update_attribute :status, 'waiting'
|
@site.update status: 'waiting'
|
||||||
notify_usuaries if notify
|
notify_usuaries if notify
|
||||||
|
|
||||||
# Hacer fallar la tarea
|
# Hacer fallar la tarea
|
||||||
|
@ -22,8 +39,11 @@ class DeployJob < ApplicationJob
|
||||||
end
|
end
|
||||||
|
|
||||||
deploy_others
|
deploy_others
|
||||||
|
|
||||||
|
# Volver a la espera
|
||||||
|
@site.update status: 'waiting'
|
||||||
|
|
||||||
notify_usuaries if notify
|
notify_usuaries if notify
|
||||||
@site.update_attribute :status, 'waiting'
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
# rubocop:enable Metrics/MethodLength
|
# rubocop:enable Metrics/MethodLength
|
||||||
|
|
260
app/jobs/gitlab_notifier_job.rb
Normal file
260
app/jobs/gitlab_notifier_job.rb
Normal file
|
@ -0,0 +1,260 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Notifica excepciones a una instancia de Gitlab, como incidencias
|
||||||
|
# nuevas o como comentarios a las incidencias pre-existentes.
|
||||||
|
class GitlabNotifierJob < ApplicationJob
|
||||||
|
include ExceptionNotifier::BacktraceCleaner
|
||||||
|
|
||||||
|
# Variables que vamos a acceder luego
|
||||||
|
attr_reader :exception, :options, :issue_data, :cached
|
||||||
|
|
||||||
|
queue_as :low_priority
|
||||||
|
|
||||||
|
# @param [Exception] la excepción lanzada
|
||||||
|
# @param [Hash] opciones de ExceptionNotifier
|
||||||
|
def perform(exception, **options)
|
||||||
|
@exception = exception
|
||||||
|
@options = options
|
||||||
|
@issue_data = { count: 1 }
|
||||||
|
# Necesitamos saber si el issue ya existía
|
||||||
|
@cached = false
|
||||||
|
|
||||||
|
# Traemos los datos desde la caché si existen, sino generamos un
|
||||||
|
# issue nuevo e inicializamos la caché
|
||||||
|
@issue_data = Rails.cache.fetch(cache_key) do
|
||||||
|
issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
|
||||||
|
@cached = true
|
||||||
|
|
||||||
|
{
|
||||||
|
count: 1,
|
||||||
|
issue: issue['iid'],
|
||||||
|
user_agents: [user_agent].compact,
|
||||||
|
params: [request&.filtered_parameters].compact,
|
||||||
|
urls: [url].compact
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# No seguimos actualizando si acabamos de generar el issue
|
||||||
|
return if cached
|
||||||
|
|
||||||
|
# Incrementar la cuenta de veces que ocurrió
|
||||||
|
issue_data[:count] += 1
|
||||||
|
# Guardar información útil
|
||||||
|
issue_data[:urls] << url unless issue_data[:urls].include? url
|
||||||
|
issue_data[:user_agents] << user_agent unless issue_data[:user_agents].include? user_agent
|
||||||
|
|
||||||
|
# Editar el título para que incluya la cuenta de eventos
|
||||||
|
client.edit_issue(iid: issue_data[:issue], title: title, state_event: 'reopen')
|
||||||
|
|
||||||
|
# Agregar un comentario con la información posiblemente nueva
|
||||||
|
client.new_note(iid: issue_data[:issue], body: body)
|
||||||
|
|
||||||
|
# Guardar para después
|
||||||
|
Rails.cache.write(cache_key, issue_data)
|
||||||
|
# Si este trabajo genera una excepción va a entrar en un loop, así que
|
||||||
|
# la notificamos por correo
|
||||||
|
rescue Exception => e
|
||||||
|
email_notification.call(e)
|
||||||
|
email_notification.call(exception, options)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Notificar por correo
|
||||||
|
#
|
||||||
|
# @return [ExceptionNotifier::EmailNotifier]
|
||||||
|
def email_notification
|
||||||
|
@email_notification ||= ExceptionNotifier::EmailNotifier.new(email_prefix: '[ERROR] ', sender_address: ENV['DEFAULT_FROM'], exception_recipients: ENV['EXCEPTION_TO'])
|
||||||
|
end
|
||||||
|
|
||||||
|
# La llave en la cache tiene en cuenta la excepción, el mensaje, la
|
||||||
|
# ruta del backtrace y los errores de JS
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def cache_key
|
||||||
|
@cache_key ||= [
|
||||||
|
exception.class.name,
|
||||||
|
Digest::SHA1.hexdigest(exception.message),
|
||||||
|
Digest::SHA1.hexdigest(backtrace&.first.to_s),
|
||||||
|
Digest::SHA1.hexdigest(options.dig(:data, :params, 'errors').to_s)
|
||||||
|
].join('/')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Define si es una excepción de javascript o local
|
||||||
|
#
|
||||||
|
# @see BacktraceJob
|
||||||
|
# @return [Boolean]
|
||||||
|
def javascript?
|
||||||
|
@javascript ||= options.dig(:data, :javascript_backtrace).present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Título
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def title
|
||||||
|
@title ||= ''.dup.tap do |t|
|
||||||
|
t << "[#{exception.class}] " unless javascript?
|
||||||
|
t << exception.message
|
||||||
|
t << " [#{issue_data[:count]}]"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Descripción
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def description
|
||||||
|
@description ||= ''.dup.tap do |d|
|
||||||
|
d << request_section
|
||||||
|
d << javascript_section
|
||||||
|
d << javascript_footer
|
||||||
|
d << backtrace_section
|
||||||
|
d << data_section
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Comentario
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def body
|
||||||
|
@body ||= ''.dup.tap do |b|
|
||||||
|
b << request_section
|
||||||
|
b << javascript_footer
|
||||||
|
b << data_section
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cadena de archivos donde se produjo el error
|
||||||
|
#
|
||||||
|
# @return [Array,Nil]
|
||||||
|
def backtrace
|
||||||
|
@backtrace ||= exception.backtrace ? clean_backtrace(exception) : nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Entorno del error
|
||||||
|
#
|
||||||
|
# @return [Hash]
|
||||||
|
def env
|
||||||
|
options[:env]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Genera una petición a partir del entorno
|
||||||
|
#
|
||||||
|
# @return [ActionDispatch::Request]
|
||||||
|
def request
|
||||||
|
@request ||= ActionDispatch::Request.new(env) if env.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Cliente de la API de Gitlab
|
||||||
|
#
|
||||||
|
# @return [GitlabApiClient]
|
||||||
|
def client
|
||||||
|
@client ||= GitlabApiClient.new
|
||||||
|
end
|
||||||
|
|
||||||
|
# Muestra información de la petición
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def request_section
|
||||||
|
return '' unless request
|
||||||
|
|
||||||
|
<<~REQUEST
|
||||||
|
|
||||||
|
# Request
|
||||||
|
|
||||||
|
```
|
||||||
|
#{request.request_method} #{url}
|
||||||
|
|
||||||
|
#{pp request.filtered_parameters}
|
||||||
|
```
|
||||||
|
|
||||||
|
REQUEST
|
||||||
|
end
|
||||||
|
|
||||||
|
# Muestra información de JavaScript
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def javascript_section
|
||||||
|
return '' unless javascript?
|
||||||
|
|
||||||
|
options.dig(:data, :params, 'errors')&.map do |error|
|
||||||
|
# Algunos errores no son excepciones (?)
|
||||||
|
error['type'] = 'undefined' if error['type'].blank?
|
||||||
|
|
||||||
|
<<~JAVASCRIPT
|
||||||
|
|
||||||
|
## #{error['type']}: #{error['message']}
|
||||||
|
|
||||||
|
```
|
||||||
|
#{Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values)}
|
||||||
|
```
|
||||||
|
|
||||||
|
JAVASCRIPT
|
||||||
|
end&.join
|
||||||
|
end
|
||||||
|
|
||||||
|
# Muestra información de la visita que generó el error en JS
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def javascript_footer
|
||||||
|
return '' unless javascript?
|
||||||
|
|
||||||
|
<<~JAVASCRIPT
|
||||||
|
|
||||||
|
#{user_agent}
|
||||||
|
|
||||||
|
<#{url}>
|
||||||
|
|
||||||
|
JAVASCRIPT
|
||||||
|
end
|
||||||
|
|
||||||
|
# Muestra el historial del error en Ruby
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def backtrace_section
|
||||||
|
return '' if javascript?
|
||||||
|
return '' unless backtrace
|
||||||
|
|
||||||
|
<<~BACKTRACE
|
||||||
|
|
||||||
|
## Backtrace
|
||||||
|
|
||||||
|
```
|
||||||
|
#{backtrace.join("\n")}
|
||||||
|
```
|
||||||
|
|
||||||
|
BACKTRACE
|
||||||
|
end
|
||||||
|
|
||||||
|
# Muestra datos extra de la visita
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def data_section
|
||||||
|
return '' unless options[:data]
|
||||||
|
|
||||||
|
<<~DATA
|
||||||
|
|
||||||
|
## Data
|
||||||
|
|
||||||
|
```
|
||||||
|
#{pp options[:data]}
|
||||||
|
```
|
||||||
|
|
||||||
|
DATA
|
||||||
|
end
|
||||||
|
|
||||||
|
# Obtiene el UA de este error
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def user_agent
|
||||||
|
@user_agent ||= options.dig(:data, :params, 'context', 'userAgent') if javascript?
|
||||||
|
@user_agent ||= request.headers['user-agent'] if request
|
||||||
|
@user_agent
|
||||||
|
end
|
||||||
|
|
||||||
|
# Obtiene la URL actual
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def url
|
||||||
|
@url ||= request&.url || options.dig(:data, :params, 'context', 'url')
|
||||||
|
end
|
||||||
|
end
|
55
app/jobs/periodic_job.rb
Normal file
55
app/jobs/periodic_job.rb
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Una tarea que se corre periódicamente
|
||||||
|
class PeriodicJob < ApplicationJob
|
||||||
|
class RunAgainException < StandardError; end
|
||||||
|
|
||||||
|
STARTING_INTERVAL = Stat::INTERVALS.first
|
||||||
|
|
||||||
|
# Tener el sitio a mano
|
||||||
|
attr_reader :site
|
||||||
|
|
||||||
|
# Descartar y notificar si pasó algo más.
|
||||||
|
#
|
||||||
|
# XXX: En realidad deberíamos seguir reintentando?
|
||||||
|
discard_on(StandardError) do |_, error|
|
||||||
|
ExceptionNotifier.notify_exception(error)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Correr indefinidamente una vez por hora.
|
||||||
|
#
|
||||||
|
# XXX: El orden importa, si el descarte viene después, nunca se va a
|
||||||
|
# reintentar.
|
||||||
|
retry_on(PeriodicJob::RunAgainException, wait: 1.try(STARTING_INTERVAL), attempts: Float::INFINITY, jitter: 0)
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Las clases que implementen esta tienen que usar este método al
|
||||||
|
# terminar.
|
||||||
|
def run_again!
|
||||||
|
raise PeriodicJob::RunAgainException, 'Reintentando'
|
||||||
|
end
|
||||||
|
|
||||||
|
# El intervalo de inicio
|
||||||
|
#
|
||||||
|
# @return [Symbol]
|
||||||
|
def starting_interval
|
||||||
|
STARTING_INTERVAL
|
||||||
|
end
|
||||||
|
|
||||||
|
# La última recolección de estadísticas o empezar desde el principio
|
||||||
|
# de los tiempos.
|
||||||
|
#
|
||||||
|
# @return [Stat]
|
||||||
|
def last_stat
|
||||||
|
@last_stat ||= site.stats.where(name: stat_name).last ||
|
||||||
|
site.stats.build(created_at: Time.new(1970, 1, 1))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devuelve el comienzo del intervalo
|
||||||
|
#
|
||||||
|
# @return [Time]
|
||||||
|
def beginning_of_interval
|
||||||
|
@beginning_of_interval ||= last_stat.created_at.try(:"beginning_of_#{starting_interval}")
|
||||||
|
end
|
||||||
|
end
|
67
app/jobs/stat_collection_job.rb
Normal file
67
app/jobs/stat_collection_job.rb
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Genera resúmenes de información para poder mostrar estadísticas y se
|
||||||
|
# corre regularmente a sí misma.
|
||||||
|
class StatCollectionJob < ApplicationJob
|
||||||
|
STAT_NAME = 'stat_collection_job'
|
||||||
|
|
||||||
|
def perform(site_id:, once: true)
|
||||||
|
@site = Site.find site_id
|
||||||
|
|
||||||
|
scope.rollup('builds', **options)
|
||||||
|
|
||||||
|
scope.rollup('space_used', **options) do |rollup|
|
||||||
|
rollup.average(:bytes)
|
||||||
|
end
|
||||||
|
|
||||||
|
scope.rollup('build_time', **options) do |rollup|
|
||||||
|
rollup.average(:seconds)
|
||||||
|
end
|
||||||
|
|
||||||
|
# XXX: Es correcto promediar promedios?
|
||||||
|
Stat::INTERVALS.reduce do |previous, current|
|
||||||
|
rollup(name: 'builds', interval_previous: previous, interval: current)
|
||||||
|
rollup(name: 'space_used', interval_previous: previous, interval: current, operation: :average)
|
||||||
|
rollup(name: 'build_time', interval_previous: previous, interval: current, operation: :average)
|
||||||
|
|
||||||
|
current
|
||||||
|
end
|
||||||
|
|
||||||
|
# Registrar que se hicieron todas las recolecciones
|
||||||
|
site.stats.create! name: STAT_NAME
|
||||||
|
|
||||||
|
run_again! unless once
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Genera un rollup recursivo en base al período anterior y aplica una
|
||||||
|
# operación.
|
||||||
|
#
|
||||||
|
# @return [NilClass]
|
||||||
|
def rollup(name:, interval_previous:, interval:, operation: :sum)
|
||||||
|
Rollup.where(name: name, interval: interval_previous)
|
||||||
|
.where_dimensions(site_id: site.id)
|
||||||
|
.group("dimensions->'site_id'")
|
||||||
|
.rollup(name, interval: interval, update: true) do |rollup|
|
||||||
|
rollup.try(:operation, :value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Los registros a procesar
|
||||||
|
#
|
||||||
|
# @return [ActiveRecord::Relation]
|
||||||
|
def scope
|
||||||
|
@scope ||= site.build_stats
|
||||||
|
.jekyll
|
||||||
|
.where('created_at => ?', beginning_of_interval)
|
||||||
|
.group(:site_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Las opciones por defecto
|
||||||
|
#
|
||||||
|
# @return [Hash]
|
||||||
|
def options
|
||||||
|
@options ||= { interval: starting_interval, update: true }
|
||||||
|
end
|
||||||
|
end
|
106
app/jobs/uri_collection_job.rb
Normal file
106
app/jobs/uri_collection_job.rb
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Procesar una lista de URIs para una lista de dominios. Esto nos
|
||||||
|
# permite procesar estadísticas a demanda.
|
||||||
|
#
|
||||||
|
# Hay varias cosas acá que van a convertirse en métodos propios, como la
|
||||||
|
# detección de URIs de un sitio (aunque la versión actual detecta todas
|
||||||
|
# las páginas y no solo las de posts como tenemos planeado, hay que
|
||||||
|
# resolver eso).
|
||||||
|
#
|
||||||
|
# Los hostnames de un sitio van a poder obtenerse a partir de
|
||||||
|
# Site#hostnames con la garantía de que son únicos.
|
||||||
|
class UriCollectionJob < PeriodicJob
|
||||||
|
# Ignoramos imágenes porque suelen ser demasiadas y no aportan a las
|
||||||
|
# estadísticas.
|
||||||
|
IMAGES = %w[.png .jpg .jpeg .gif .webp].freeze
|
||||||
|
STAT_NAME = 'uri_collection_job'
|
||||||
|
|
||||||
|
def perform(site_id:, once: true)
|
||||||
|
@site = Site.find site_id
|
||||||
|
|
||||||
|
hostnames.each do |hostname|
|
||||||
|
uris.each do |uri|
|
||||||
|
return if File.exist? Rails.root.join('tmp', 'uri_collection_job_stop')
|
||||||
|
|
||||||
|
AccessLog.where(host: hostname, uri: uri)
|
||||||
|
.where('created_at >= ?', beginning_of_interval)
|
||||||
|
.completed_requests
|
||||||
|
.non_robots
|
||||||
|
.group(:host, :uri)
|
||||||
|
.rollup('host|uri', interval: starting_interval, update: true)
|
||||||
|
|
||||||
|
# Reducir las estadísticas calculadas aplicando un rollup sobre el
|
||||||
|
# intervalo más amplio.
|
||||||
|
Stat::INTERVALS.reduce do |previous, current|
|
||||||
|
Rollup.where(name: 'host|uri', interval: previous)
|
||||||
|
.where_dimensions(host: hostname, uri: uri)
|
||||||
|
.group("dimensions->'host'", "dimensions->'uri'")
|
||||||
|
.rollup('host|uri', interval: current, update: true) do |rollup|
|
||||||
|
rollup.sum(:value)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devolver el intervalo actual
|
||||||
|
current
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recordar la última vez que se corrió la tarea
|
||||||
|
site.stats.create! name: STAT_NAME
|
||||||
|
|
||||||
|
run_again! unless once
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def stat_name
|
||||||
|
STAT_NAME
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [String]
|
||||||
|
#
|
||||||
|
# TODO: Cambiar al mergear origin-referer
|
||||||
|
def destination
|
||||||
|
@destination ||= site.deploys.find_by(type: 'DeployLocal').destination
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: Cambiar al mergear origin-referer
|
||||||
|
#
|
||||||
|
# @return [Array]
|
||||||
|
def hostnames
|
||||||
|
@hostnames ||= site.deploys.map do |deploy|
|
||||||
|
case deploy
|
||||||
|
when DeployLocal
|
||||||
|
site.hostname
|
||||||
|
when DeployWww
|
||||||
|
deploy.fqdn
|
||||||
|
when DeployAlternativeDomain
|
||||||
|
deploy.hostname.dup.tap do |h|
|
||||||
|
h.replace(h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}")
|
||||||
|
end
|
||||||
|
when DeployHiddenService
|
||||||
|
deploy.onion
|
||||||
|
end
|
||||||
|
end.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recolecta todas las URIs menos imágenes
|
||||||
|
#
|
||||||
|
# @return [Array]
|
||||||
|
def uris
|
||||||
|
@uris ||= Dir.chdir destination do
|
||||||
|
(Dir.glob('**/*.html') + Dir.glob('public/**/*').reject do |p|
|
||||||
|
File.directory? p
|
||||||
|
end.reject do |p|
|
||||||
|
p = p.downcase
|
||||||
|
|
||||||
|
IMAGES.any? do |i|
|
||||||
|
p.end_with? i
|
||||||
|
end
|
||||||
|
end).map do |uri|
|
||||||
|
"/#{uri}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
17
app/lib/exception_notifier/gitlab_notifier.rb
Normal file
17
app/lib/exception_notifier/gitlab_notifier.rb
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module ExceptionNotifier
|
||||||
|
# Notifica las excepciones como incidencias en Gitlab
|
||||||
|
class GitlabNotifier
|
||||||
|
def initialize(_); end
|
||||||
|
|
||||||
|
# Recibe la excepción y empieza la tarea de notificación en segundo
|
||||||
|
# plano.
|
||||||
|
#
|
||||||
|
# @param [Exception]
|
||||||
|
# @param [Hash]
|
||||||
|
def call(exception, **options)
|
||||||
|
GitlabNotifierJob.perform_async(exception, **options)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
59
app/lib/gitlab_api_client.rb
Normal file
59
app/lib/gitlab_api_client.rb
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'httparty'
|
||||||
|
|
||||||
|
class GitlabApiClient
|
||||||
|
include HTTParty
|
||||||
|
|
||||||
|
# TODO: Hacer configurable por sitio
|
||||||
|
base_uri ENV.fetch('GITLAB_URI', 'https://0xacab.org')
|
||||||
|
# No seguir redirecciones. Si nos olvidamos https:// en la dirección,
|
||||||
|
# las redirecciones nos pueden llevar a cualquier lado y obtener
|
||||||
|
# resultados diferentes.
|
||||||
|
no_follow true
|
||||||
|
|
||||||
|
# Trae todos los proyectos. Como estamos usando un Project Token,
|
||||||
|
# siempre va a traer uno solo.
|
||||||
|
#
|
||||||
|
# @return [HTTParty::Response]
|
||||||
|
def projects
|
||||||
|
self.class.get('/api/v4/projects', { query: { membership: true }, headers: headers })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Obtiene el identificador del proyecto
|
||||||
|
#
|
||||||
|
# @return [Integer]
|
||||||
|
def project_id
|
||||||
|
@project_id ||= ENV['GITLAB_PROJECT'] || projects&.first&.dig('id')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Crea un issue
|
||||||
|
#
|
||||||
|
# @see https://docs.gitlab.com/ee/api/issues.html#new-issue
|
||||||
|
# @return [HTTParty::Response]
|
||||||
|
def new_issue(**args)
|
||||||
|
self.class.post("/api/v4/projects/#{project_id}/issues", { body: args, headers: headers })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Modifica un issue
|
||||||
|
#
|
||||||
|
# @see https://docs.gitlab.com/ee/api/issues.html#edit-issue
|
||||||
|
# @return [HTTParty::Response]
|
||||||
|
def edit_issue(iid:, **args)
|
||||||
|
self.class.put("/api/v4/projects/#{project_id}/issues/#{iid}", { body: args, headers: headers })
|
||||||
|
end
|
||||||
|
|
||||||
|
# Crea un comentario
|
||||||
|
#
|
||||||
|
# @see https://docs.gitlab.com/ee/api/notes.html#create-new-issue-note
|
||||||
|
# @return [HTTParty::Response]
|
||||||
|
def new_note(iid:, **args)
|
||||||
|
self.class.post("/api/v4/projects/#{project_id}/issues/#{iid}/notes", { body: args, headers: headers })
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def headers(extra = {})
|
||||||
|
{ 'Authorization' => "Bearer #{ENV['GITLAB_TOKEN']}" }.merge(extra)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,4 +1,12 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class AccessLog < ApplicationRecord
|
class AccessLog < ApplicationRecord
|
||||||
|
# Las peticiones completas son las que terminaron bien y se
|
||||||
|
# respondieron con 200 OK o 304 Not Modified
|
||||||
|
#
|
||||||
|
# @see {https://en.wikipedia.org/wiki/List_of_HTTP_status_codes}
|
||||||
|
scope :completed_requests, -> { where(request_method: 'GET', request_completion: 'OK', status: [200, 304]) }
|
||||||
|
scope :non_robots, -> { where(crawler: false) }
|
||||||
|
scope :robots, -> { where(crawler: true) }
|
||||||
|
scope :pages, -> { where(sent_http_content_type: ['text/html', 'text/html; charset=utf-8', 'text/html; charset=UTF-8']) }
|
||||||
end
|
end
|
||||||
|
|
|
@ -62,10 +62,15 @@ class DeployLocal < Deploy
|
||||||
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
|
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
|
||||||
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
|
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
|
||||||
'JEKYLL_ENV' => Rails.env,
|
'JEKYLL_ENV' => Rails.env,
|
||||||
'LANG' => ENV['LANG']
|
'LANG' => ENV['LANG'],
|
||||||
|
'YARN_CACHE_FOLDER' => yarn_cache_dir
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def yarn_cache_dir
|
||||||
|
Rails.root.join('_yarn_cache').to_s
|
||||||
|
end
|
||||||
|
|
||||||
def yarn_lock
|
def yarn_lock
|
||||||
File.join(site.path, 'yarn.lock')
|
File.join(site.path, 'yarn.lock')
|
||||||
end
|
end
|
||||||
|
@ -80,9 +85,9 @@ class DeployLocal < Deploy
|
||||||
|
|
||||||
# Corre yarn dentro del repositorio
|
# Corre yarn dentro del repositorio
|
||||||
def yarn
|
def yarn
|
||||||
return unless yarn_lock?
|
return true unless yarn_lock?
|
||||||
|
|
||||||
run 'yarn'
|
run 'yarn install --production'
|
||||||
end
|
end
|
||||||
|
|
||||||
def bundle
|
def bundle
|
||||||
|
|
46
app/models/indexed_post.rb
Normal file
46
app/models/indexed_post.rb
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# La representación indexable de un artículo
|
||||||
|
class IndexedPost < ApplicationRecord
|
||||||
|
include PgSearch::Model
|
||||||
|
|
||||||
|
# La traducción del locale según Sutty al locale según PostgreSQL
|
||||||
|
DICTIONARIES = {
|
||||||
|
es: 'spanish',
|
||||||
|
en: 'english'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
# TODO: Los indexed posts tienen que estar scopeados al idioma actual,
|
||||||
|
# no buscar sobre todos
|
||||||
|
pg_search_scope :search,
|
||||||
|
lambda { |locale, query|
|
||||||
|
{
|
||||||
|
against: :content,
|
||||||
|
query: query,
|
||||||
|
using: {
|
||||||
|
tsearch: {
|
||||||
|
dictionary: IndexedPost.to_dictionary(locale: locale),
|
||||||
|
tsvector_column: 'indexed_content'
|
||||||
|
},
|
||||||
|
trigram: {
|
||||||
|
word_similarity: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Trae los IndexedPost en el orden en que van a terminar en el sitio.
|
||||||
|
default_scope -> { order(order: :desc, created_at: :desc) }
|
||||||
|
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) }
|
||||||
|
|
||||||
|
belongs_to :site
|
||||||
|
|
||||||
|
# Convertir locale a direccionario de PG
|
||||||
|
#
|
||||||
|
# @param [String,Symbol]
|
||||||
|
# @return [String]
|
||||||
|
def self.to_dictionary(locale:)
|
||||||
|
DICTIONARIES[locale.to_sym] || 'simple'
|
||||||
|
end
|
||||||
|
end
|
|
@ -13,6 +13,26 @@ class MetadataArray < MetadataTemplate
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Solo los datos públicos se indexan, aunque MetadataArray no se cifra
|
||||||
|
# aun, dejamos esto preparado para la posteridad.
|
||||||
|
def indexable?
|
||||||
|
true && !private?
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
value.join(', ')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Obtiene el valor desde el documento, convirtiéndolo a Array si no lo
|
||||||
|
# era ya, por retrocompabilidad.
|
||||||
|
#
|
||||||
|
# @return [Array]
|
||||||
|
def document_value
|
||||||
|
[super].flatten(1).compact
|
||||||
|
end
|
||||||
|
|
||||||
|
alias indexable_values value
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# TODO: Sanitizar otros valores
|
# TODO: Sanitizar otros valores
|
||||||
|
|
|
@ -3,13 +3,6 @@
|
||||||
# Almacena el UUID de otro Post y actualiza el valor en el Post
|
# Almacena el UUID de otro Post y actualiza el valor en el Post
|
||||||
# relacionado.
|
# relacionado.
|
||||||
class MetadataBelongsTo < MetadataRelatedPosts
|
class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
def value_was=(new_value)
|
|
||||||
@belongs_to = nil
|
|
||||||
@belonged_to = nil
|
|
||||||
|
|
||||||
super(new_value)
|
|
||||||
end
|
|
||||||
|
|
||||||
# TODO: Convertir algunos tipos de valores en módulos para poder
|
# TODO: Convertir algunos tipos de valores en módulos para poder
|
||||||
# implementar varios tipos de campo sin repetir código
|
# implementar varios tipos de campo sin repetir código
|
||||||
#
|
#
|
||||||
|
@ -20,6 +13,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Obtiene el valor desde el documento.
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def document_value
|
||||||
|
document.data[name.to_s]
|
||||||
|
end
|
||||||
|
|
||||||
def validate
|
def validate
|
||||||
super
|
super
|
||||||
|
|
||||||
|
@ -39,10 +39,14 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
|
|
||||||
# Si estamos cambiando la relación, tenemos que eliminar la relación
|
# Si estamos cambiando la relación, tenemos que eliminar la relación
|
||||||
# anterior
|
# anterior
|
||||||
belonged_to[inverse].value.delete post.uuid.value if changed? && belonged_to.present?
|
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
|
# No duplicar las relaciones
|
||||||
belongs_to[inverse].value << post.uuid.value unless belongs_to.blank? || included?
|
belongs_to[inverse].value = (belongs_to[inverse].value.dup << post.uuid.value) unless belongs_to.blank? || included?
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
@ -63,20 +67,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
end
|
end
|
||||||
|
|
||||||
# El Post relacionado con este artículo
|
# El Post relacionado con este artículo
|
||||||
#
|
|
||||||
# XXX: Memoizamos usando el valor para tener el valor siempre
|
|
||||||
# actualizado.
|
|
||||||
def belongs_to
|
def belongs_to
|
||||||
return if value.blank?
|
posts.find(value, uuid: true) if value.present?
|
||||||
|
|
||||||
@belongs_to ||= posts.find(value, uuid: true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# El artículo relacionado anterior
|
# El artículo relacionado anterior
|
||||||
def belonged_to
|
def belonged_to
|
||||||
return if value_was.blank?
|
posts.find(value_was, uuid: true) if value_was.present?
|
||||||
|
|
||||||
@belonged_to ||= posts.find(value_was, uuid: true)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def related_posts?
|
def related_posts?
|
||||||
|
@ -87,6 +84,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
||||||
@related_methods ||= %i[belongs_to belonged_to].freeze
|
@related_methods ||= %i[belongs_to belonged_to].freeze
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def indexable_values
|
||||||
|
belongs_to&.title&.value
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def post_exists?
|
def post_exists?
|
||||||
|
|
|
@ -25,10 +25,19 @@ class MetadataBoolean < MetadataTemplate
|
||||||
# * false
|
# * false
|
||||||
# * true
|
# * true
|
||||||
def value
|
def value
|
||||||
return document.data.fetch(name.to_s, default_value) if self[:value].nil?
|
case self[:value]
|
||||||
return self[:value] unless self[:value].is_a? String
|
when NilClass
|
||||||
|
document.data.fetch(name.to_s, default_value)
|
||||||
|
when String
|
||||||
|
true_values.include? self[:value]
|
||||||
|
else
|
||||||
|
self[:value]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
self[:value] = true_values.include? self[:value]
|
# Siempre guardar el valor de este campo a menos que sea nulo
|
||||||
|
def empty?
|
||||||
|
value.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
|
@ -15,14 +15,26 @@ class MetadataContent < MetadataTemplate
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def document_value
|
||||||
|
document.content
|
||||||
|
end
|
||||||
|
|
||||||
|
def indexable?
|
||||||
|
true && !private?
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_s
|
||||||
|
sanitizer.sanitize value, tags: [], attributes: []
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Detectar si el contenido estaba en Markdown y pasarlo a HTML
|
# Detectar si el contenido estaba en Markdown y pasarlo a HTML
|
||||||
def legacy_content
|
def legacy_content
|
||||||
return unless document.content
|
return unless document_value
|
||||||
return document.content if /^\s*</ =~ document.content
|
return document_value if /^\s*</ =~ document_value
|
||||||
|
|
||||||
CommonMarker.render_doc(document.content, %i[FOOTNOTES UNSAFE], %i[table strikethrough autolink]).to_html
|
CommonMarker.render_doc(document_value, %i[FOOTNOTES UNSAFE], %i[table strikethrough autolink]).to_html
|
||||||
end
|
end
|
||||||
|
|
||||||
# Limpiar el HTML que recibimos
|
# Limpiar el HTML que recibimos
|
||||||
|
@ -44,7 +56,7 @@ class MetadataContent < MetadataTemplate
|
||||||
uri = URI element['src']
|
uri = URI element['src']
|
||||||
|
|
||||||
# No permitimos recursos externos
|
# No permitimos recursos externos
|
||||||
element.remove unless uri.hostname.end_with? Site.domain
|
element.remove unless uri.scheme == 'https' && uri.hostname.end_with?(Site.domain)
|
||||||
rescue URI::Error
|
rescue URI::Error
|
||||||
element.remove
|
element.remove
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,17 +7,58 @@ class MetadataDocumentDate < MetadataTemplate
|
||||||
Date.today.to_time
|
Date.today.to_time
|
||||||
end
|
end
|
||||||
|
|
||||||
def value_from_document
|
# @return [Time]
|
||||||
|
def document_value
|
||||||
|
return nil if post.new?
|
||||||
|
|
||||||
document.date
|
document.date
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def indexable?
|
||||||
|
true && !private?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Siempre es obligatorio
|
||||||
|
def required
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate
|
||||||
|
super
|
||||||
|
|
||||||
|
errors << I18n.t('metadata.date.invalid_format') unless valid_format?
|
||||||
|
|
||||||
|
errors.empty?
|
||||||
|
end
|
||||||
|
|
||||||
# El valor puede ser un Date, Time o una String en el formato
|
# El valor puede ser un Date, Time o una String en el formato
|
||||||
# "yyyy-mm-dd"
|
# "yyyy-mm-dd"
|
||||||
|
#
|
||||||
|
# XXX: Date.iso8601 acepta fechas en el futuro lejano, como 20000,
|
||||||
|
# pero Jekyll las limita a cuatro cifras, así que vamos a mantener
|
||||||
|
# eso.
|
||||||
|
#
|
||||||
|
# @see {https://github.com/jekyll/jekyll/blob/master/lib/jekyll/document.rb#L15}
|
||||||
def value
|
def value
|
||||||
return (self[:value] = value_from_document || default_value) if self[:value].nil?
|
self[:value] =
|
||||||
|
case self[:value]
|
||||||
|
when String
|
||||||
|
begin
|
||||||
|
Date.iso8601(self[:value]).to_time
|
||||||
|
rescue Date::Error
|
||||||
|
document_value || default_value
|
||||||
|
end
|
||||||
|
else
|
||||||
|
self[:value] || document_value || default_value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
self[:value] = Date.iso8601(self[:value]).to_time if self[:value].is_a? String
|
private
|
||||||
|
|
||||||
self[:value]
|
def valid_format?
|
||||||
|
return true if self[:value].is_a?(Time)
|
||||||
|
|
||||||
|
@valid_format_re ||= /\A\d{2,4}-\d{1,2}-\d{1,2}\z/
|
||||||
|
@valid_format_re =~ self[:value].to_s
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -16,6 +16,7 @@ class MetadataFile < MetadataTemplate
|
||||||
def validate
|
def validate
|
||||||
super
|
super
|
||||||
|
|
||||||
|
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid?
|
||||||
errors << I18n.t("metadata.#{type}.path_required") if path_missing?
|
errors << I18n.t("metadata.#{type}.path_required") if path_missing?
|
||||||
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
|
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
|
||||||
|
|
||||||
|
|
31
app/models/metadata_float.rb
Normal file
31
app/models/metadata_float.rb
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Un campo numérico de punto flotante
|
||||||
|
class MetadataFloat < MetadataTemplate
|
||||||
|
# Nada
|
||||||
|
def default_value
|
||||||
|
super || nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def save
|
||||||
|
return true unless changed?
|
||||||
|
|
||||||
|
self[:value] = value.to_f
|
||||||
|
self[:value] = encrypt(value) if private?
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
# Indicarle al navegador que acepte números decimales
|
||||||
|
#
|
||||||
|
# @return [Float]
|
||||||
|
def step
|
||||||
|
0.05
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def decrypt(value)
|
||||||
|
super(value).to_f
|
||||||
|
end
|
||||||
|
end
|
|
@ -18,6 +18,7 @@ class MetadataHasAndBelongsToMany < MetadataHasMany
|
||||||
#
|
#
|
||||||
# Buscamos en belongs_to la relación local, si se eliminó hay que
|
# Buscamos en belongs_to la relación local, si se eliminó hay que
|
||||||
# quitarla de la relación remota, sino hay que agregarla.
|
# quitarla de la relación remota, sino hay que agregarla.
|
||||||
|
#
|
||||||
def save
|
def save
|
||||||
# XXX: No usamos super
|
# XXX: No usamos super
|
||||||
self[:value] = sanitize value
|
self[:value] = sanitize value
|
||||||
|
@ -25,27 +26,21 @@ class MetadataHasAndBelongsToMany < MetadataHasMany
|
||||||
return true unless changed?
|
return true unless changed?
|
||||||
return true unless inverse?
|
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|
|
(had_many - has_many).each do |remove|
|
||||||
remove[inverse]&.value&.delete post.uuid.value
|
remove[inverse].value = remove[inverse].value.reject do |rej|
|
||||||
|
rej == post.uuid.value
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
(has_many - had_many).each do |add|
|
(has_many - had_many).each do |add|
|
||||||
next unless add[inverse]
|
next unless add[inverse]
|
||||||
next if add[inverse].value.include? post.uuid.value
|
next if add[inverse].value.include? post.uuid.value
|
||||||
|
|
||||||
add[inverse].value << post.uuid.value
|
add[inverse].value = (add[inverse].value.dup << post.uuid.value)
|
||||||
end
|
end
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
# Igual que en MetadataRelatedPosts
|
|
||||||
# TODO: Mover a un módulo
|
|
||||||
def sanitize(uuid)
|
|
||||||
super(uuid.map do |u|
|
|
||||||
u.to_s.gsub(/[^a-f0-9\-]/i, '')
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,32 +6,18 @@
|
||||||
# Localmente tenemos un Array de UUIDs. Remotamente tenemos una String
|
# Localmente tenemos un Array de UUIDs. Remotamente tenemos una String
|
||||||
# apuntando a un Post, que se mantiene actualizado como el actual.
|
# apuntando a un Post, que se mantiene actualizado como el actual.
|
||||||
class MetadataHasMany < MetadataRelatedPosts
|
class MetadataHasMany < MetadataRelatedPosts
|
||||||
# Invalidar la relación anterior
|
|
||||||
def value_was=(new_value)
|
|
||||||
@had_many = nil
|
|
||||||
@has_many = nil
|
|
||||||
|
|
||||||
super(new_value)
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate
|
|
||||||
super
|
|
||||||
|
|
||||||
errors << I18n.t('metadata.has_many.missing_posts') unless posts_exist?
|
|
||||||
|
|
||||||
errors.empty?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Todos los Post relacionados
|
# Todos los Post relacionados
|
||||||
def has_many
|
def has_many
|
||||||
@has_many ||= posts.where(uuid: value)
|
return default_value if value.blank?
|
||||||
|
|
||||||
|
posts.where(uuid: value)
|
||||||
end
|
end
|
||||||
|
|
||||||
# La relación anterior
|
# La relación anterior
|
||||||
def had_many
|
def had_many
|
||||||
return [] if value_was.blank?
|
return default_value if value_was.blank?
|
||||||
|
|
||||||
@had_many ||= posts.where(uuid: value_was)
|
posts.where(uuid: value_was)
|
||||||
end
|
end
|
||||||
|
|
||||||
def inverse?
|
def inverse?
|
||||||
|
@ -71,8 +57,4 @@ class MetadataHasMany < MetadataRelatedPosts
|
||||||
def related_methods
|
def related_methods
|
||||||
@related_methods ||= %i[has_many had_many].freeze
|
@related_methods ||= %i[has_many had_many].freeze
|
||||||
end
|
end
|
||||||
|
|
||||||
def posts_exist?
|
|
||||||
has_many.size == sanitize(value).size
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
12
app/models/metadata_html.rb
Normal file
12
app/models/metadata_html.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Campos en HTML
|
||||||
|
class MetadataHtml < MetadataContent
|
||||||
|
def front_matter?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def document_value
|
||||||
|
document.data[name.to_s]
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,12 +6,13 @@ class MetadataLang < MetadataTemplate
|
||||||
super || I18n.locale
|
super || I18n.locale
|
||||||
end
|
end
|
||||||
|
|
||||||
def value_from_document
|
# @return [Symbol]
|
||||||
|
def document_value
|
||||||
document.collection.label.to_sym
|
document.collection.label.to_sym
|
||||||
end
|
end
|
||||||
|
|
||||||
def value
|
def value
|
||||||
self[:value] ||= value_from_document || default_value
|
self[:value] ||= document_value || default_value
|
||||||
end
|
end
|
||||||
|
|
||||||
def values
|
def values
|
||||||
|
|
|
@ -9,14 +9,15 @@ class MetadataMarkdownContent < MetadataText
|
||||||
end
|
end
|
||||||
|
|
||||||
def value
|
def value
|
||||||
self[:value] || value_from_document || default_value
|
self[:value] || document_value || default_value
|
||||||
end
|
end
|
||||||
|
|
||||||
def front_matter?
|
def front_matter?
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def value_from_document
|
# @return [String]
|
||||||
|
def document_value
|
||||||
document.content
|
document.content
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -3,12 +3,16 @@
|
||||||
# Este campo representa el archivo donde se almacenan los datos
|
# Este campo representa el archivo donde se almacenan los datos
|
||||||
class MetadataPath < MetadataTemplate
|
class MetadataPath < MetadataTemplate
|
||||||
# :label en este caso es el idioma/colección
|
# :label en este caso es el idioma/colección
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
def default_value
|
def default_value
|
||||||
File.join(site.path, "_#{lang}", "#{date}-#{slug}#{ext}")
|
File.join(site.path, "_#{lang}", "#{date}-#{slug}#{ext}")
|
||||||
end
|
end
|
||||||
|
|
||||||
# El valor no vuelve desde el documento
|
# La ruta del archivo según Jekyll
|
||||||
def value_from_document
|
#
|
||||||
|
# @return [String]
|
||||||
|
def document_value
|
||||||
document.path
|
document.path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,12 @@
|
||||||
|
|
||||||
# Este metadato permite generar rutas manuales.
|
# Este metadato permite generar rutas manuales.
|
||||||
class MetadataPermalink < MetadataString
|
class MetadataPermalink < MetadataString
|
||||||
|
# El valor por defecto una vez creado es la URL que le asigne Jekyll,
|
||||||
|
# de forma que nunca cambia aunque se cambie el título.
|
||||||
|
def default_value
|
||||||
|
document.url.sub(%r{\A/}, '') unless post.new?
|
||||||
|
end
|
||||||
|
|
||||||
# Los permalinks nunca pueden ser privados
|
# Los permalinks nunca pueden ser privados
|
||||||
def private?
|
def private?
|
||||||
false
|
false
|
||||||
|
|
24
app/models/metadata_predefined_value.rb
Normal file
24
app/models/metadata_predefined_value.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Un campo de texto seleccionado de una lista de valores posibles
|
||||||
|
class MetadataPredefinedValue < MetadataString
|
||||||
|
# Obtiene todos los valores desde el layout, en un formato compatible
|
||||||
|
# con options_for_select.
|
||||||
|
#
|
||||||
|
# @return [Hash]
|
||||||
|
def values
|
||||||
|
@values ||= layout.dig(:metadata, name, 'values', I18n.locale.to_s)&.invert || {}
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Solo permite almacenar los valores predefinidos.
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def sanitize(string)
|
||||||
|
v = super string
|
||||||
|
return '' unless values.values.include? v
|
||||||
|
|
||||||
|
v
|
||||||
|
end
|
||||||
|
end
|
|
@ -18,11 +18,19 @@ class MetadataRelatedPosts < MetadataArray
|
||||||
false
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def indexable?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def indexable_values
|
||||||
|
posts.where(uuid: value).map(&:title).map(&:value)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Obtiene todos los posts y opcionalmente los filtra
|
# Obtiene todos los posts y opcionalmente los filtra
|
||||||
def posts
|
def posts
|
||||||
@posts ||= site.posts(lang: lang).where(**filter)
|
site.posts(lang: lang).where(**filter)
|
||||||
end
|
end
|
||||||
|
|
||||||
def title(post)
|
def title(post)
|
||||||
|
|
|
@ -7,6 +7,10 @@ class MetadataString < MetadataTemplate
|
||||||
super || ''
|
super || ''
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def indexable?
|
||||||
|
true && !private?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# No se permite HTML en las strings
|
# No se permite HTML en las strings
|
||||||
|
|
|
@ -7,6 +7,11 @@
|
||||||
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
:value, :help, :required, :errors, :post,
|
:value, :help, :required, :errors, :post,
|
||||||
:layout, keyword_init: true) do
|
:layout, keyword_init: true) do
|
||||||
|
# Determina si el campo es indexable
|
||||||
|
def indexable?
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def inspect
|
def inspect
|
||||||
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
|
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
|
||||||
end
|
end
|
||||||
|
@ -14,17 +19,23 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
# Queremos que los artículos nuevos siempre cacheen, si usamos el UUID
|
# Queremos que los artículos nuevos siempre cacheen, si usamos el UUID
|
||||||
# siempre vamos a obtener un item nuevo.
|
# siempre vamos a obtener un item nuevo.
|
||||||
def cache_key
|
def cache_key
|
||||||
return layout.value + '/' + name.to_s if post.new?
|
return "#{layout.value}/#{name}" if post.new?
|
||||||
|
|
||||||
@cache_key ||= 'post/' + post.uuid.value + '/' + name.to_s
|
@cache_key ||= "post/#{post.uuid.value}/#{name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Genera una versión de caché en base a la fecha de modificación del
|
||||||
|
# Post, el valor actual y los valores posibles, de forma que cualquier
|
||||||
|
# cambio permita renovar la caché.
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
def cache_version
|
def cache_version
|
||||||
value.hash.to_s + values.hash.to_s
|
post.cache_version + value.hash.to_s + values.hash.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @return [String]
|
||||||
def cache_key_with_version
|
def cache_key_with_version
|
||||||
cache_key + '-' + cache_version
|
"#{cache_key}-#{cache_version}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# XXX: Deberíamos sanitizar durante la asignación?
|
# XXX: Deberíamos sanitizar durante la asignación?
|
||||||
|
@ -38,11 +49,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
def value_was
|
def value_was
|
||||||
return @value_was if instance_variable_defined? '@value_was'
|
return @value_was if instance_variable_defined? '@value_was'
|
||||||
|
|
||||||
@value_was = value_from_document
|
@value_was = document_value
|
||||||
end
|
|
||||||
|
|
||||||
def value_from_document
|
|
||||||
@value_from_document ||= document.data[name.to_s]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def changed?
|
def changed?
|
||||||
|
@ -74,7 +81,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
# Valor actual o por defecto. Al memoizarlo podemos modificarlo
|
# Valor actual o por defecto. Al memoizarlo podemos modificarlo
|
||||||
# usando otros métodos que el de asignación.
|
# usando otros métodos que el de asignación.
|
||||||
def value
|
def value
|
||||||
self[:value] ||= if (data = value_from_document).present?
|
self[:value] ||= if (data = document_value).present?
|
||||||
private? ? decrypt(data) : data
|
private? ? decrypt(data) : data
|
||||||
else
|
else
|
||||||
default_value
|
default_value
|
||||||
|
@ -205,9 +212,13 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||||
|
|
||||||
box.decrypt_str value.to_s
|
box.decrypt_str value.to_s
|
||||||
rescue Lockbox::DecryptionError => e
|
rescue Lockbox::DecryptionError => e
|
||||||
ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.path.absolute, name: name })
|
if value.to_s.include? ' '
|
||||||
|
value
|
||||||
|
else
|
||||||
|
ExceptionNotifier.notify_exception(e, data: { site: site.name, post: post.path.absolute, name: name })
|
||||||
|
|
||||||
I18n.t('lockbox.help.decryption_error')
|
I18n.t('lockbox.help.decryption_error')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Cifra el valor.
|
# Cifra el valor.
|
||||||
|
|
|
@ -17,6 +17,12 @@ class Post
|
||||||
|
|
||||||
attr_reader :attributes, :errors, :layout, :site, :document
|
attr_reader :attributes, :errors, :layout, :site, :document
|
||||||
|
|
||||||
|
# TODO: Modificar el historial de Git con callbacks en lugar de
|
||||||
|
# services. De esta forma podríamos agregar soporte para distintos
|
||||||
|
# backends.
|
||||||
|
include ActiveRecord::Callbacks
|
||||||
|
include Post::Indexable
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Obtiene el layout sin leer el Document
|
# Obtiene el layout sin leer el Document
|
||||||
#
|
#
|
||||||
|
@ -53,9 +59,7 @@ class Post
|
||||||
public_send(attr)&.value = args[attr] if args.key?(attr)
|
public_send(attr)&.value = args[attr] if args.key?(attr)
|
||||||
end
|
end
|
||||||
|
|
||||||
# XXX: No usamos Post#read porque a esta altura todavía no sabemos
|
document.read! unless new?
|
||||||
# nada del Document
|
|
||||||
document.read! if File.exist? document.path
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def inspect
|
def inspect
|
||||||
|
@ -75,6 +79,9 @@ class Post
|
||||||
# TODO: Cambiar el locale en otro lado
|
# TODO: Cambiar el locale en otro lado
|
||||||
l = lang.value.to_s
|
l = lang.value.to_s
|
||||||
site.jekyll.config['locale'] = site.jekyll.config['lang'] = l
|
site.jekyll.config['locale'] = site.jekyll.config['lang'] = l
|
||||||
|
# XXX: Es necesario leer los layouts para poder renderizar el
|
||||||
|
# sitio
|
||||||
|
site.theme_layouts
|
||||||
|
|
||||||
# Payload básico con traducciones.
|
# Payload básico con traducciones.
|
||||||
document.renderer.payload = {
|
document.renderer.payload = {
|
||||||
|
@ -114,7 +121,7 @@ class Post
|
||||||
end
|
end
|
||||||
|
|
||||||
def cache_version
|
def cache_version
|
||||||
updated_at.utc.to_s(:usec)
|
(updated_at || modified_at).utc.to_s(:usec)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Agregar el timestamp para saber si cambió, siguiendo el módulo
|
# Agregar el timestamp para saber si cambió, siguiendo el módulo
|
||||||
|
@ -131,6 +138,8 @@ class Post
|
||||||
|
|
||||||
# Fecha de última modificación del archivo
|
# Fecha de última modificación del archivo
|
||||||
def updated_at
|
def updated_at
|
||||||
|
return if new?
|
||||||
|
|
||||||
File.mtime(path.absolute)
|
File.mtime(path.absolute)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -195,6 +204,8 @@ class Post
|
||||||
post: self, required: true)
|
post: self, required: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
alias locale lang
|
||||||
|
|
||||||
# TODO: Mover a method_missing
|
# TODO: Mover a method_missing
|
||||||
def uuid
|
def uuid
|
||||||
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
|
@metadata[:uuid] ||= MetadataUuid.new(document: document, site: site, layout: layout, name: :uuid, type: :uuid,
|
||||||
|
@ -232,12 +243,14 @@ class Post
|
||||||
template = public_send attr
|
template = public_send attr
|
||||||
|
|
||||||
unless template.front_matter?
|
unless template.front_matter?
|
||||||
body += "\n\n"
|
body += "\n\n" if body.present?
|
||||||
body += template.value
|
body += template.value
|
||||||
next
|
next
|
||||||
end
|
end
|
||||||
|
|
||||||
next if template.empty?
|
# Queremos mantener los Array en el resultado final para que
|
||||||
|
# siempre respondan a {% for %} en Liquid.
|
||||||
|
next if template.empty? && !template.value.is_a?(Array)
|
||||||
|
|
||||||
[attr.to_s, template.value]
|
[attr.to_s, template.value]
|
||||||
end.compact.to_h
|
end.compact.to_h
|
||||||
|
@ -255,11 +268,17 @@ class Post
|
||||||
end
|
end
|
||||||
|
|
||||||
# Eliminar el artículo del repositorio y de la lista de artículos del
|
# Eliminar el artículo del repositorio y de la lista de artículos del
|
||||||
# sitio
|
# sitio.
|
||||||
|
#
|
||||||
|
# TODO: Si el callback falla deberíamos recuperar el archivo.
|
||||||
|
#
|
||||||
|
# @return [Post]
|
||||||
def destroy
|
def destroy
|
||||||
FileUtils.rm_f path.absolute
|
run_callbacks :destroy do
|
||||||
|
FileUtils.rm_f path.absolute
|
||||||
|
|
||||||
site.delete_post self
|
site.delete_post self
|
||||||
|
end
|
||||||
end
|
end
|
||||||
alias destroy! destroy
|
alias destroy! destroy
|
||||||
|
|
||||||
|
@ -283,10 +302,13 @@ class Post
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
return false unless save_attributes!
|
run_callbacks :save do
|
||||||
return false unless write
|
return false unless save_attributes!
|
||||||
|
return false unless write
|
||||||
|
end
|
||||||
|
|
||||||
# Vuelve a leer el post para tomar los cambios
|
# Vuelve a leer el post para tomar los cambios
|
||||||
|
document.reset
|
||||||
read
|
read
|
||||||
|
|
||||||
written?
|
written?
|
||||||
|
|
76
app/models/post/indexable.rb
Normal file
76
app/models/post/indexable.rb
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Post
|
||||||
|
# Vuelve indexables a los Posts
|
||||||
|
module Indexable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
# Indexa o reindexa el Post
|
||||||
|
after_save :index!
|
||||||
|
after_destroy :remove_from_index!
|
||||||
|
|
||||||
|
# Devuelve una versión indexable del Post
|
||||||
|
#
|
||||||
|
# @return [IndexedPost]
|
||||||
|
def to_index
|
||||||
|
IndexedPost.find_or_create_by(id: uuid.value).tap do |indexed_post|
|
||||||
|
indexed_post.layout = layout.name
|
||||||
|
indexed_post.site_id = site.id
|
||||||
|
indexed_post.path = path.basename
|
||||||
|
indexed_post.locale = locale.value
|
||||||
|
indexed_post.dictionary = IndexedPost.to_dictionary(locale: locale.value)
|
||||||
|
indexed_post.title = title.value
|
||||||
|
indexed_post.front_matter = indexable_front_matter
|
||||||
|
indexed_post.content = indexable_content
|
||||||
|
indexed_post.created_at = date.value
|
||||||
|
indexed_post.order = attribute?(:order) ? order.value : 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Indexa o reindexa el Post
|
||||||
|
#
|
||||||
|
# @return [Boolean]
|
||||||
|
def index!
|
||||||
|
to_index.save
|
||||||
|
end
|
||||||
|
|
||||||
|
def remove_from_index!
|
||||||
|
to_index.destroy.destroyed?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Los metadatos que se almacenan como objetos JSON. Empezamos con
|
||||||
|
# las categorías porque se usan para filtrar en el listado de
|
||||||
|
# artículos.
|
||||||
|
#
|
||||||
|
# @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
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Devuelve un documento indexable en texto plano
|
||||||
|
#
|
||||||
|
# XXX: No memoizamos para permitir actualizaciones, aunque
|
||||||
|
# probablemente se indexe una sola vez.
|
||||||
|
#
|
||||||
|
# @return [String]
|
||||||
|
def indexable_content
|
||||||
|
indexable_attributes.map do |attr|
|
||||||
|
self[attr].to_s.tr("\n", ' ')
|
||||||
|
end.join("\n").squeeze("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def indexable_attributes
|
||||||
|
@indexable_attributes ||= attributes.select do |attr|
|
||||||
|
self[attr].indexable?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -93,8 +93,7 @@ class PostRelation < Array
|
||||||
def where(**args)
|
def where(**args)
|
||||||
return self if args.empty?
|
return self if args.empty?
|
||||||
|
|
||||||
@where ||= {}
|
begin
|
||||||
@where[args.hash.to_s] ||= begin
|
|
||||||
PostRelation.new(site: site, lang: lang).concat(select do |post|
|
PostRelation.new(site: site, lang: lang).concat(select do |post|
|
||||||
result = args.map do |attr, value|
|
result = args.map do |attr, value|
|
||||||
next unless post.attribute?(attr)
|
next unless post.attribute?(attr)
|
||||||
|
|
|
@ -26,7 +26,7 @@ class Site < ApplicationRecord
|
||||||
validates :design_id, presence: true
|
validates :design_id, presence: true
|
||||||
validates_inclusion_of :status, in: %w[waiting enqueued building]
|
validates_inclusion_of :status, in: %w[waiting enqueued building]
|
||||||
validates_presence_of :title
|
validates_presence_of :title
|
||||||
validates :description, length: { in: 50..160 }
|
validates :description, length: { in: 10..160 }
|
||||||
validate :deploy_local_presence
|
validate :deploy_local_presence
|
||||||
validate :compatible_layouts, on: :update
|
validate :compatible_layouts, on: :update
|
||||||
|
|
||||||
|
@ -37,6 +37,7 @@ class Site < ApplicationRecord
|
||||||
belongs_to :design
|
belongs_to :design
|
||||||
belongs_to :licencia
|
belongs_to :licencia
|
||||||
|
|
||||||
|
has_many :stats
|
||||||
has_many :log_entries, dependent: :destroy
|
has_many :log_entries, dependent: :destroy
|
||||||
has_many :deploys, dependent: :destroy
|
has_many :deploys, dependent: :destroy
|
||||||
has_many :build_stats, through: :deploys
|
has_many :build_stats, through: :deploys
|
||||||
|
@ -65,8 +66,8 @@ class Site < ApplicationRecord
|
||||||
|
|
||||||
accepts_nested_attributes_for :deploys, allow_destroy: true
|
accepts_nested_attributes_for :deploys, allow_destroy: true
|
||||||
|
|
||||||
# El sitio en Jekyll
|
# XXX: Es importante incluir luego de los callbacks de :load_jekyll
|
||||||
attr_reader :jekyll
|
include Site::Index
|
||||||
|
|
||||||
# No permitir HTML en estos atributos
|
# No permitir HTML en estos atributos
|
||||||
def title=(title)
|
def title=(title)
|
||||||
|
@ -97,7 +98,7 @@ class Site < ApplicationRecord
|
||||||
# @param slash Boolean Agregar / al final o no
|
# @param slash Boolean Agregar / al final o no
|
||||||
# @return String La URL con o sin / al final
|
# @return String La URL con o sin / al final
|
||||||
def url(slash: true)
|
def url(slash: true)
|
||||||
'https://' + hostname + (slash ? '/' : '')
|
"https://#{hostname}#{slash ? '/' : ''}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Obtiene los dominios alternativos
|
# Obtiene los dominios alternativos
|
||||||
|
@ -105,7 +106,7 @@ class Site < ApplicationRecord
|
||||||
# @return Array
|
# @return Array
|
||||||
def alternative_hostnames
|
def alternative_hostnames
|
||||||
deploys.where(type: 'DeployAlternativeDomain').map(&:hostname).map do |h|
|
deploys.where(type: 'DeployAlternativeDomain').map(&:hostname).map do |h|
|
||||||
h.end_with?('.') ? h[0..-2] : h + '.' + Site.domain
|
h.end_with?('.') ? h[0..-2] : "#{h}.#{Site.domain}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -114,7 +115,7 @@ class Site < ApplicationRecord
|
||||||
# @return Array
|
# @return Array
|
||||||
def alternative_urls(slash: true)
|
def alternative_urls(slash: true)
|
||||||
alternative_hostnames.map do |h|
|
alternative_hostnames.map do |h|
|
||||||
'https://' + h + (slash ? '/' : '')
|
"https://#{h}#{slash ? '/' : ''}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -175,41 +176,30 @@ class Site < ApplicationRecord
|
||||||
end
|
end
|
||||||
alias default_lang default_locale
|
alias default_lang default_locale
|
||||||
|
|
||||||
def read?
|
|
||||||
@read ||= false
|
|
||||||
end
|
|
||||||
|
|
||||||
# Lee el sitio y todos los artículos
|
|
||||||
def read
|
|
||||||
# No hacer nada si ya se leyó antes
|
|
||||||
return if read?
|
|
||||||
|
|
||||||
@jekyll.read
|
|
||||||
@read = true
|
|
||||||
end
|
|
||||||
|
|
||||||
# Trae los datos del directorio _data dentro del sitio
|
# Trae los datos del directorio _data dentro del sitio
|
||||||
#
|
|
||||||
# XXX: Leer directamente sin pasar por Jekyll
|
|
||||||
def data
|
def data
|
||||||
read
|
unless jekyll.data.present?
|
||||||
|
run_in_path do
|
||||||
# Define los valores por defecto según la llave buscada
|
jekyll.reader.read_data
|
||||||
@jekyll.data.default_proc = proc do |data, key|
|
jekyll.data['layouts'] ||= {}
|
||||||
data[key] = case key
|
end
|
||||||
when 'layout' then {}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@jekyll.data
|
jekyll.data
|
||||||
end
|
end
|
||||||
|
|
||||||
# Traer las colecciones. Todos los artículos van a estar dentro de
|
# Traer las colecciones. Todos los artículos van a estar dentro de
|
||||||
# colecciones.
|
# colecciones.
|
||||||
def collections
|
def collections
|
||||||
read
|
unless @read
|
||||||
|
run_in_path do
|
||||||
|
jekyll.reader.read_collections
|
||||||
|
end
|
||||||
|
|
||||||
@jekyll.collections
|
@read = true
|
||||||
|
end
|
||||||
|
|
||||||
|
jekyll.collections
|
||||||
end
|
end
|
||||||
|
|
||||||
# Traer la configuración de forma modificable
|
# Traer la configuración de forma modificable
|
||||||
|
@ -221,10 +211,8 @@ class Site < ApplicationRecord
|
||||||
#
|
#
|
||||||
# @param lang: [String|Symbol] traer los artículos de este idioma
|
# @param lang: [String|Symbol] traer los artículos de este idioma
|
||||||
def posts(lang: nil)
|
def posts(lang: nil)
|
||||||
read
|
# Traemos los posts del idioma actual por defecto o el que haya
|
||||||
|
lang ||= locales.include?(I18n.locale) ? I18n.locale : default_locale
|
||||||
# Traemos los posts del idioma actual por defecto
|
|
||||||
lang ||= I18n.locale
|
|
||||||
lang = lang.to_sym
|
lang = lang.to_sym
|
||||||
|
|
||||||
# Crea un Struct dinámico con los valores de los locales, si
|
# Crea un Struct dinámico con los valores de los locales, si
|
||||||
|
@ -275,7 +263,9 @@ class Site < ApplicationRecord
|
||||||
# NoMethodError
|
# NoMethodError
|
||||||
@layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
|
@layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
|
||||||
@layouts ||= @layouts_struct.new(**data['layouts'].map do |name, metadata|
|
@layouts ||= @layouts_struct.new(**data['layouts'].map do |name, metadata|
|
||||||
[name.to_sym, Layout.new(site: self, name: name.to_sym, meta: metadata.delete('meta')&.with_indifferent_access, metadata: metadata.with_indifferent_access)]
|
[name.to_sym,
|
||||||
|
Layout.new(site: self, name: name.to_sym, meta: metadata.delete('meta')&.with_indifferent_access,
|
||||||
|
metadata: metadata.with_indifferent_access)]
|
||||||
end.to_h)
|
end.to_h)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -293,6 +283,15 @@ class Site < ApplicationRecord
|
||||||
layout_keys.include? layout.to_sym
|
layout_keys.include? layout.to_sym
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Lee los layouts en HTML desde el sitio
|
||||||
|
#
|
||||||
|
# @return [Hash]
|
||||||
|
def theme_layouts
|
||||||
|
run_in_path do
|
||||||
|
jekyll.reader.read_layouts
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Trae todos los valores disponibles para un campo
|
# Trae todos los valores disponibles para un campo
|
||||||
#
|
#
|
||||||
# TODO: Traer recursivamente, si el campo contiene Hash
|
# TODO: Traer recursivamente, si el campo contiene Hash
|
||||||
|
@ -313,14 +312,31 @@ class Site < ApplicationRecord
|
||||||
|
|
||||||
# Poner en la cola de compilación
|
# Poner en la cola de compilación
|
||||||
def enqueue!
|
def enqueue!
|
||||||
!enqueued? && update_attribute(:status, 'enqueued')
|
update(status: 'enqueued') if waiting?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Está en la cola de compilación?
|
# Está en la cola de compilación?
|
||||||
|
#
|
||||||
|
# TODO: definir todos estos métodos dinámicamente, aunque todavía no
|
||||||
|
# tenemos una máquina de estados propiamente dicha.
|
||||||
def enqueued?
|
def enqueued?
|
||||||
status == 'enqueued'
|
status == 'enqueued'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def waiting?
|
||||||
|
status == 'waiting'
|
||||||
|
end
|
||||||
|
|
||||||
|
def building?
|
||||||
|
status == 'building'
|
||||||
|
end
|
||||||
|
|
||||||
|
def jekyll
|
||||||
|
run_in_path do
|
||||||
|
@jekyll ||= Jekyll::Site.new(configuration)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Cargar el sitio Jekyll
|
# Cargar el sitio Jekyll
|
||||||
#
|
#
|
||||||
# TODO: En lugar de leer todo junto de una vez, extraer la carga de
|
# TODO: En lugar de leer todo junto de una vez, extraer la carga de
|
||||||
|
@ -334,10 +350,7 @@ class Site < ApplicationRecord
|
||||||
|
|
||||||
def reload_jekyll!
|
def reload_jekyll!
|
||||||
reset
|
reset
|
||||||
|
jekyll
|
||||||
Dir.chdir(path) do
|
|
||||||
@jekyll = Jekyll::Site.new(configuration)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def reload
|
def reload
|
||||||
|
@ -390,7 +403,7 @@ class Site < ApplicationRecord
|
||||||
|
|
||||||
# Detecta si el tema actual es una gema
|
# Detecta si el tema actual es una gema
|
||||||
def theme_available?
|
def theme_available?
|
||||||
available_themes.include? design.gem
|
available_themes.include? design&.gem
|
||||||
end
|
end
|
||||||
|
|
||||||
# Devuelve el dominio actual
|
# Devuelve el dominio actual
|
||||||
|
@ -404,7 +417,7 @@ class Site < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.default
|
def self.default
|
||||||
find_by(name: Site.domain + '.')
|
find_by(name: "#{Site.domain}.")
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset
|
def reset
|
||||||
|
@ -515,4 +528,8 @@ class Site < ApplicationRecord
|
||||||
errors.add(:design_id,
|
errors.add(:design_id,
|
||||||
I18n.t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.error'))
|
I18n.t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.error'))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def run_in_path(&block)
|
||||||
|
Dir.chdir path, &block
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
24
app/models/site/index.rb
Normal file
24
app/models/site/index.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Indexa todos los artículos de un sitio
|
||||||
|
#
|
||||||
|
# TODO: Hacer opcional
|
||||||
|
class Site
|
||||||
|
module Index
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
# TODO: Debería ser un Job?
|
||||||
|
after_create :index_posts!
|
||||||
|
has_many :indexed_posts, dependent: :destroy
|
||||||
|
|
||||||
|
def index_posts!
|
||||||
|
Site.transaction do
|
||||||
|
docs.each do |post|
|
||||||
|
post.to_index.save
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -44,8 +44,8 @@ class Site
|
||||||
#
|
#
|
||||||
# @return [Integer]
|
# @return [Integer]
|
||||||
def fetch
|
def fetch
|
||||||
if origin.check_connection :fetch
|
if origin.check_connection(:fetch, credentials: credentials)
|
||||||
rugged.fetch(origin)[:received_objects]
|
rugged.fetch(origin, credentials: credentials)[:received_objects]
|
||||||
else
|
else
|
||||||
0
|
0
|
||||||
end
|
end
|
||||||
|
@ -149,6 +149,26 @@ class Site
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Si Sutty tiene una llave privada de tipo ED25519, devuelve las
|
||||||
|
# credenciales necesarias para trabajar con repositorios remotos.
|
||||||
|
#
|
||||||
|
# @return [Nil, Rugged::Credentials::SshKey]
|
||||||
|
def credentials
|
||||||
|
return unless File.exist? private_key
|
||||||
|
|
||||||
|
@credentials ||= Rugged::Credentials::SshKey.new username: 'git', publickey: public_key, privatekey: private_key
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [String]
|
||||||
|
def public_key
|
||||||
|
@public_key ||= Rails.root.join('.ssh', 'id_ed25519.pub').to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
# @return [String]
|
||||||
|
def private_key
|
||||||
|
@private_key ||= Rails.root.join('.ssh', 'id_ed25519').to_s
|
||||||
|
end
|
||||||
|
|
||||||
def relativize(file)
|
def relativize(file)
|
||||||
Pathname.new(file).relative_path_from(Pathname.new(path)).to_s
|
Pathname.new(file).relative_path_from(Pathname.new(path)).to_s
|
||||||
end
|
end
|
||||||
|
|
10
app/models/stat.rb
Normal file
10
app/models/stat.rb
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Registran cuándo fue la última recolección de datos.
|
||||||
|
class Stat < ApplicationRecord
|
||||||
|
# XXX: Los intervalos van en orden de mayor especificidad a menor
|
||||||
|
INTERVALS = %i[day month year].freeze
|
||||||
|
RESOURCES = %i[builds space_used build_time].freeze
|
||||||
|
|
||||||
|
belongs_to :site
|
||||||
|
end
|
|
@ -59,9 +59,7 @@ class PostPolicy
|
||||||
def resolve
|
def resolve
|
||||||
return scope if scope&.first&.site&.usuarie? usuarie
|
return scope if scope&.first&.site&.usuarie? usuarie
|
||||||
|
|
||||||
scope.select do |post|
|
scope.by_usuarie(usuarie.id)
|
||||||
post.usuaries.include? usuarie
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,4 +12,16 @@ class SiteStatPolicy
|
||||||
def index?
|
def index?
|
||||||
site_stat.site.usuarie? usuarie
|
site_stat.site.usuarie? usuarie
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def host?
|
||||||
|
index?
|
||||||
|
end
|
||||||
|
|
||||||
|
def resources?
|
||||||
|
index?
|
||||||
|
end
|
||||||
|
|
||||||
|
def uris?
|
||||||
|
index?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -61,6 +61,18 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
||||||
commit_config(action: :tor)
|
commit_config(action: :tor)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Trae cambios desde la rama remota y reindexa los artículos.
|
||||||
|
#
|
||||||
|
# @return [Boolean]
|
||||||
|
def merge
|
||||||
|
result = site.repository.merge(usuarie)
|
||||||
|
|
||||||
|
# TODO: Implementar callbacks
|
||||||
|
site.try(:index_posts!) if result
|
||||||
|
|
||||||
|
result.present?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Guarda los cambios de la configuración en el repositorio git
|
# Guarda los cambios de la configuración en el repositorio git
|
||||||
|
@ -110,6 +122,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
||||||
# la búsqueda.
|
# la búsqueda.
|
||||||
def change_licencias
|
def change_licencias
|
||||||
site.locales.each do |locale|
|
site.locales.each do |locale|
|
||||||
|
next unless I18n.available_locales.include? locale
|
||||||
|
|
||||||
Mobility.with_locale(locale) do
|
Mobility.with_locale(locale) do
|
||||||
permalink = "#{I18n.t('activerecord.models.licencia').downcase}/"
|
permalink = "#{I18n.t('activerecord.models.licencia').downcase}/"
|
||||||
post = site.posts(lang: locale).find_by(permalink: permalink)
|
post = site.posts(lang: locale).find_by(permalink: permalink)
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
= render 'layouts/breadcrumb',
|
- breadcrumb 'sites.index', sites_path
|
||||||
crumbs: [link_to(t('.index'), sites_path), t('.title')]
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: nil
|
|
||||||
|
|
||||||
= content_for :body do
|
= content_for :body do
|
||||||
- 'black-bg'
|
- 'black-bg'
|
||||||
|
|
||||||
|
|
4
app/views/env/index.js.haml
vendored
4
app/views/env/index.js.haml
vendored
|
@ -1,7 +1,7 @@
|
||||||
= cache @site do
|
= cache @site do
|
||||||
:plain
|
:plain
|
||||||
window.env = {
|
window.env = {
|
||||||
AIRBRAKE_SITE_ID: #{@site&.id || 1},
|
AIRBRAKE_SITE_ID: #{@site.id},
|
||||||
AIRBRAKE_API_KEY: "#{@site&.airbrake_api_key}",
|
AIRBRAKE_API_KEY: "#{@site.airbrake_api_key}",
|
||||||
PANEL_URL: "#{ENV['PANEL_URL']}"
|
PANEL_URL: "#{ENV['PANEL_URL']}"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<% unless @data[:_backtrace] %>
|
<% unless @data[:javascript_backtrace] %>
|
||||||
```
|
```
|
||||||
<%= raw @backtrace.join("\n") %>
|
<%= raw @backtrace.join("\n") %>
|
||||||
```
|
```
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<% if @data[:_backtrace] %>
|
<% if @data[:javascript_backtrace] %>
|
||||||
<% @data.dig(:params, 'errors')&.each do |error| %>
|
<% @data.dig(:params, 'errors')&.each do |error| %>
|
||||||
# <%= error['type'] %>: <%= error['message'] %>
|
# <%= error['type'] %>: <%= error['message'] %>
|
||||||
|
|
||||||
```
|
```
|
||||||
<%= Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values).map(&:strip) %>
|
<%= Terminal::Table.new headings: error['backtrace'].first.keys, rows: error['backtrace'].map(&:values) %>
|
||||||
```
|
```
|
||||||
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
link_to(@site.name, site_path(@site)),
|
|
||||||
t('i18n.index'),
|
|
||||||
t('i18n.edit')]
|
|
||||||
|
|
||||||
= render 'i18n/form'
|
|
|
@ -3,21 +3,14 @@
|
||||||
= inline_svg_tag 'sutty.svg', class: 'black', aria: true,
|
= 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')
|
||||||
|
|
||||||
- if crumbs
|
%nav{ aria: { label: t('.title') } }
|
||||||
%nav{ aria: { label: t('.title') }, role: 'navigation' }
|
%ol.breadcrumb.m-0.flex-wrap
|
||||||
%ol.breadcrumb
|
- breadcrumb_trail do |crumb|
|
||||||
%li.breadcrumb-item
|
%li.breadcrumb-item{ class: crumb.current? ? 'active' : '' }
|
||||||
= link_to edit_usuarie_registration_path,
|
- if crumb.current?
|
||||||
data: { toggle: 'tooltip' },
|
%span.line-clamp-1{ aria: { current: 'page' } }= crumb.name
|
||||||
title: t('help.usuarie.edit') do
|
|
||||||
= current_usuarie.email
|
|
||||||
|
|
||||||
- crumbs.compact.each do |crumb|
|
|
||||||
- if crumb == crumbs.last
|
|
||||||
%li.breadcrumb-item.active{ aria: { current: 'page' } }
|
|
||||||
= crumb
|
|
||||||
- else
|
- else
|
||||||
%li.breadcrumb-item= crumb
|
%span.line-clamp-1= link_to crumb.name, crumb.url
|
||||||
|
|
||||||
- if current_usuarie
|
- if current_usuarie
|
||||||
%ul.navbar-nav
|
%ul.navbar-nav
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
|
|
||||||
%body{ class: yield(:body) }
|
%body{ class: yield(:body) }
|
||||||
.container-fluid#sutty
|
.container-fluid#sutty
|
||||||
|
= render 'layouts/breadcrumb'
|
||||||
= yield
|
= yield
|
||||||
- if flash[:js]
|
- if flash[:js]
|
||||||
.js-flash.d-none{ data: flash[:js] }
|
.js-flash.d-none{ data: flash[:js] }
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
- metadata = post[attribute]
|
- metadata = post[attribute]
|
||||||
- type = metadata.type
|
- type = metadata.type
|
||||||
|
|
||||||
- cache metadata do
|
- cache [metadata, I18n.locale] do
|
||||||
= render("posts/attributes/#{type}",
|
= render("posts/attributes/#{type}",
|
||||||
base: 'post', post: post, attribute: attribute,
|
base: 'post', post: post, attribute: attribute,
|
||||||
metadata: metadata, site: site,
|
metadata: metadata, site: site,
|
||||||
|
|
3
app/views/posts/attribute_ro/_float.haml
Normal file
3
app/views/posts/attribute_ro/_float.haml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
%tr{ id: attribute }
|
||||||
|
%th= post_label_t(attribute, post: post)
|
||||||
|
%td{ dir: dir, lang: locale }= metadata.value
|
3
app/views/posts/attribute_ro/_html.haml
Normal file
3
app/views/posts/attribute_ro/_html.haml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
%tr{ id: attribute }
|
||||||
|
%th= post_label_t(attribute, post: post)
|
||||||
|
%td{ lang: locale, dir: dir }= metadata.value.html_safe
|
3
app/views/posts/attribute_ro/_predefined_value.haml
Normal file
3
app/views/posts/attribute_ro/_predefined_value.haml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
%tr{ id: attribute }
|
||||||
|
%th= post_label_t(attribute, post: post)
|
||||||
|
%td{ dir: dir, lang: locale }= metadata.value
|
|
@ -101,6 +101,8 @@
|
||||||
.form-group{ data: { editor_auxiliary: 'mark' } }
|
.form-group{ data: { editor_auxiliary: 'mark' } }
|
||||||
%label{ for: 'mark-color' }= t('editor.color')
|
%label{ for: 'mark-color' }= t('editor.color')
|
||||||
%input.form-control{ type: 'color', name: 'mark-color' }/
|
%input.form-control{ type: 'color', name: 'mark-color' }/
|
||||||
|
%label{ for: 'mark-text-color' }= t('editor.text-color')
|
||||||
|
%input.form-control{ type: 'color', name: 'mark-text-color' }/
|
||||||
|
|
||||||
%div{ data: { editor_auxiliary: 'multimedia' } }
|
%div{ data: { editor_auxiliary: 'multimedia' } }
|
||||||
.form-group
|
.form-group
|
||||||
|
|
6
app/views/posts/attributes/_float.haml
Normal file
6
app/views/posts/attributes/_float.haml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
.form-group
|
||||||
|
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
|
||||||
|
= number_field base, attribute, value: metadata.value, step: metadata.step,
|
||||||
|
**field_options(attribute, metadata)
|
||||||
|
= render 'posts/attribute_feedback',
|
||||||
|
post: post, attribute: attribute, metadata: metadata
|
6
app/views/posts/attributes/_html.haml
Normal file
6
app/views/posts/attributes/_html.haml
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
-# Editor de contenido
|
||||||
|
= render 'posts/attributes/content',
|
||||||
|
base: 'post', post: post, attribute: attribute,
|
||||||
|
metadata: metadata, site: site,
|
||||||
|
dir: dir, locale: locale,
|
||||||
|
autofocus: (post.attributes.first == attribute)
|
|
@ -1,8 +1,8 @@
|
||||||
.form-group.markdown-content
|
.form-group.markdown-content
|
||||||
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
|
= label_tag "#{base}_#{attribute}", post_label_t(attribute, post: post)
|
||||||
|
= render 'posts/attribute_feedback',
|
||||||
|
post: post, attribute: attribute, metadata: metadata
|
||||||
= text_area_tag "#{base}[#{attribute}]", metadata.value,
|
= text_area_tag "#{base}[#{attribute}]", metadata.value,
|
||||||
dir: dir, lang: locale,
|
dir: dir, lang: locale,
|
||||||
**field_options(attribute, metadata, class: 'content')
|
**field_options(attribute, metadata, class: 'content')
|
||||||
.editor.mt-1
|
.markdown-editor.mt-1
|
||||||
= render 'posts/attribute_feedback',
|
|
||||||
post: post, attribute: attribute, metadata: metadata
|
|
||||||
|
|
7
app/views/posts/attributes/_predefined_value.haml
Normal file
7
app/views/posts/attributes/_predefined_value.haml
Normal 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
|
|
@ -1,10 +1,3 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
link_to(@site.name, site_posts_path(@site)),
|
|
||||||
link_to(t('posts.index'), site_posts_path(@site)),
|
|
||||||
link_to(@post.title.value, site_post_path(@site, @post.id)),
|
|
||||||
t('posts.edit')]
|
|
||||||
|
|
||||||
.row.justify-content-center
|
.row.justify-content-center
|
||||||
.col-md-8
|
.col-md-8
|
||||||
= render 'posts/form', site: @site, post: @post
|
= render 'posts/form', site: @site, post: @post
|
||||||
|
|
|
@ -1,10 +1,3 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
@site.name,
|
|
||||||
link_to(t('posts.index'),
|
|
||||||
site_posts_path(@site)),
|
|
||||||
@category_name]
|
|
||||||
|
|
||||||
%main.row
|
%main.row
|
||||||
%aside.menu.col-md-3
|
%aside.menu.col-md-3
|
||||||
%h1= link_to @site.title, @site.url
|
%h1= link_to @site.title, @site.url
|
||||||
|
@ -14,15 +7,13 @@
|
||||||
%table.mb-3
|
%table.mb-3
|
||||||
- @site.layouts.each do |layout|
|
- @site.layouts.each do |layout|
|
||||||
- next if layout.hidden?
|
- next if layout.hidden?
|
||||||
- filter = params[:layout] == layout.value
|
|
||||||
%tr
|
%tr
|
||||||
%th= layout.humanized_name
|
%th= layout.humanized_name
|
||||||
%td.pl-3= link_to t('posts.add'),
|
%td.pl-3= link_to t('posts.add'), new_site_post_path(@site, layout: layout.value), class: 'btn btn-secondary btn-sm'
|
||||||
new_site_post_path(@site, layout: layout.name),
|
- if @filter_params[:layout] == layout.name.to_s
|
||||||
class: 'badge badge-secondary'
|
%td= link_to t('posts.remove_filter'), site_posts_path(@site, **@filter_params.merge(layout: nil)), class: 'btn btn-primary btn-sm'
|
||||||
%td= link_to t(filter ? 'posts.remove_filter' : 'posts.filter'),
|
- else
|
||||||
site_posts_path(@site, layout: (filter ? nil : layout.value)),
|
%td= link_to t('posts.filter'), site_posts_path(@site, **@filter_params.merge(layout: layout.value)), class: 'btn btn-secondary btn-sm'
|
||||||
class: 'badge badge-' + (filter ? 'primary' : 'secondary')
|
|
||||||
|
|
||||||
- if policy(@site).edit?
|
- if policy(@site).edit?
|
||||||
= link_to t('sites.edit.btn', site: @site.title), edit_site_path(@site), class: 'btn'
|
= link_to t('sites.edit.btn', site: @site.title), edit_site_path(@site), class: 'btn'
|
||||||
|
@ -48,87 +39,102 @@
|
||||||
|
|
||||||
%section.col
|
%section.col
|
||||||
= render 'layouts/flash'
|
= render 'layouts/flash'
|
||||||
|
.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
|
||||||
|
%input.form-control.border.border-magenta{ type: 'search', placeholder: 'Buscar', 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
|
||||||
|
×
|
||||||
- if @posts.empty?
|
- if @posts.empty?
|
||||||
%h2= t('posts.none')
|
%h2= t('posts.empty')
|
||||||
- else
|
- else
|
||||||
= form_tag site_posts_reorder_path, method: :post do
|
= form_tag site_posts_reorder_path, method: :post do
|
||||||
.d-flex.justify-content-between.align-items-center
|
%input{ type: 'hidden', name: 'post[lang]', value: @locale }
|
||||||
-#
|
|
||||||
TODO: Pensar una interfaz mejor para cuando haya más de tres
|
|
||||||
idiomas
|
|
||||||
- unless @site.locales.length == 1
|
|
||||||
.locales
|
|
||||||
- @site.locales.each do |locale|
|
|
||||||
= link_to t("locales.#{locale}.name"), site_posts_path(@site, locale: locale),
|
|
||||||
class: "mr-2 mt-2 mb-2#{locale == @locale ? 'active font-weight-bold' : ''}"
|
|
||||||
%table.table{ data: { controller: 'reorder' } }
|
%table.table{ data: { controller: 'reorder' } }
|
||||||
%caption.sr-only= t('posts.caption')
|
%caption.sr-only= t('posts.caption')
|
||||||
%thead
|
%thead
|
||||||
%tr
|
%tr
|
||||||
%th.border-0.background-white.position-sticky{ style: 'top: 0; z-index: 2', colspan: '4' }
|
%th.border-0.background-white.position-sticky{ style: 'top: 0; z-index: 2', colspan: '4' }
|
||||||
= submit_tag t('posts.reorder.submit'), class: 'btn'
|
.d-flex.flex-row.justify-content-between
|
||||||
%button.btn{ data: { action: 'reorder#unselect' } }
|
%div
|
||||||
= t('posts.reorder.unselect')
|
= submit_tag t('posts.reorder.submit'), class: 'btn'
|
||||||
%span.badge{ data: { target: 'reorder.counter' } } 0
|
%button.btn{ data: { action: 'reorder#unselect' } }
|
||||||
%button.btn{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
|
= t('posts.reorder.unselect')
|
||||||
%button.btn{ data: { action: 'reorder#down' } }= t('posts.reorder.down')
|
%span.badge{ data: { target: 'reorder.counter' } } 0
|
||||||
%button.btn{ data: { action: 'reorder#top' } }= t('posts.reorder.top')
|
%button.btn{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
|
||||||
%button.btn{ data: { action: 'reorder#bottom' } }= t('posts.reorder.bottom')
|
%button.btn{ data: { action: 'reorder#down' } }= t('posts.reorder.down')
|
||||||
|
%button.btn{ data: { action: 'reorder#top' } }= t('posts.reorder.top')
|
||||||
|
%button.btn{ data: { action: 'reorder#bottom' } }= t('posts.reorder.bottom')
|
||||||
|
|
||||||
|
%div
|
||||||
%tbody
|
%tbody
|
||||||
- dir = t("locales.#{@locale}.dir")
|
- dir = t("locales.#{@locale}.dir")
|
||||||
|
- size = @posts.size
|
||||||
- @posts.each_with_index do |post, i|
|
- @posts.each_with_index do |post, i|
|
||||||
-#
|
-#
|
||||||
TODO: Solo les usuaries cachean porque tenemos que separar
|
TODO: Solo les usuaries cachean porque tenemos que separar
|
||||||
les botones por permisos.
|
les botones por permisos.
|
||||||
|
|
||||||
TODO: Verificar qué pasa cuando se gestiona el sitio en
|
|
||||||
distintos idiomas a la vez
|
|
||||||
- begin
|
- begin
|
||||||
- cache_if @usuarie, post do
|
- cache_if @usuarie, [post, I18n.locale] do
|
||||||
- checkbox_id = "checkbox-#{post.uuid.value}"
|
- checkbox_id = "checkbox-#{post.id}"
|
||||||
%tr{ id: post.uuid.value, data: { target: 'reorder.row' } }
|
%tr{ id: post.id, data: { target: 'reorder.row' } }
|
||||||
%td
|
%td
|
||||||
.custom-control.custom-checkbox
|
.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 }
|
%label.custom-control-label{ for: checkbox_id }
|
||||||
%span.sr-only= t('posts.reorder.select')
|
%span.sr-only= t('posts.reorder.select')
|
||||||
-# Orden más alto es mayor prioridad
|
-# Orden más alto es mayor prioridad
|
||||||
= hidden_field 'post[reorder]', post.uuid.value,
|
= hidden_field 'post[reorder]', post.id,
|
||||||
value: @posts.length - i,
|
value: size - i,
|
||||||
data: { reorder: true }
|
data: { reorder: true }
|
||||||
%td.w-100{ class: dir }
|
%td.w-100{ class: dir }
|
||||||
= link_to site_post_path(@site, post.id) do
|
= link_to site_post_path(@site, post.path) do
|
||||||
%span{ lang: post.lang.value, dir: dir }= post.title.value
|
%span{ lang: post.locale, dir: dir }= post.title
|
||||||
- if post.attributes.include? :draft
|
- if post.front_matter['draft'].present?
|
||||||
- if post.draft.value
|
%span.badge.badge-primary= I18n.t('posts.attributes.draft.label')
|
||||||
%span.badge.badge-primary
|
%br
|
||||||
= post_label_t(:draft, post: post)
|
%small
|
||||||
- if post.attributes.include? :categories
|
= link_to @site.layouts[post.layout].humanized_name, site_posts_path(@site, **@filter_params.merge(layout: post.layout))
|
||||||
- unless post.categories.value.empty?
|
- post.front_matter['categories']&.each do |category|
|
||||||
%br
|
= link_to site_posts_path(@site, **@filter_params.merge(category: category)) do
|
||||||
%small
|
%span{ lang: post.locale, dir: dir }= category
|
||||||
- (post.categories.respond_to?(:belongs_to) ? post.categories.belongs_to : post.categories.value).each do |c|
|
= '/' unless post.front_matter['categories'].last == category
|
||||||
= link_to site_posts_path(@site, category: (c.respond_to?(:uuid) ? c.uuid.value : c)) do
|
|
||||||
%span{ lang: post.lang.value, dir: dir }= (c.respond_to?(:title) ? c.title.value : c)
|
|
||||||
|
|
||||||
%td
|
%td.text-nowrap
|
||||||
= post.date.value.strftime('%F')
|
= post.created_at.strftime('%F')
|
||||||
%br/
|
%br/
|
||||||
- if post.attribute? :order
|
= post.order
|
||||||
= post.order.value
|
%td.text-nowrap
|
||||||
%td
|
|
||||||
- if @usuarie || policy(post).edit?
|
- if @usuarie || policy(post).edit?
|
||||||
= link_to t('posts.edit'),
|
= link_to t('posts.edit'), edit_site_post_path(@site, post.path), class: 'btn btn-block'
|
||||||
edit_site_post_path(@site, post.id),
|
|
||||||
class: 'btn btn-block'
|
|
||||||
- if @usuarie || policy(post).destroy?
|
- if @usuarie || policy(post).destroy?
|
||||||
= link_to t('posts.destroy'),
|
= link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-block', method: :delete, data: { confirm: t('posts.confirm_destroy') }
|
||||||
site_post_path(@site, post.id),
|
|
||||||
class: 'btn btn-block',
|
|
||||||
method: :delete,
|
|
||||||
data: { confirm: t('posts.confirm_destroy') }
|
|
||||||
-#
|
-#
|
||||||
Rescatar cualquier error en un post, notificarlo e
|
Rescatar cualquier error en un post, notificarlo e
|
||||||
ignorar su renderización.
|
ignorar su renderización.
|
||||||
- rescue ActionView::Template::Error => e
|
- rescue ActionView::Template::Error => e
|
||||||
- ExceptionNotifier.notify_exception(e.cause, data: { site: @site.name, post: @post.path.absolute, usuarie: current_usuarie.id })
|
- 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|
|
||||||
|
- if param == 'layout'
|
||||||
|
- value = @site.layouts[value.to_sym].humanized_name
|
||||||
|
%label{ id: "help-filter-#{param}" }= t('posts.remove_filter_help', filter: value)
|
||||||
|
|
|
@ -1,9 +1,3 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
@site.name,
|
|
||||||
link_to(t('posts.index'),
|
|
||||||
site_posts_path(@site)), t('posts.new')]
|
|
||||||
|
|
||||||
.row.justify-content-center
|
.row.justify-content-center
|
||||||
.col-md-8
|
.col-md-8
|
||||||
= render 'posts/form', site: @site, post: @post
|
= render 'posts/form', site: @site, post: @post
|
||||||
|
|
|
@ -1,9 +1,3 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
@site.name,
|
|
||||||
link_to(t('posts.index'), site_posts_path(@site)),
|
|
||||||
@post.title.value]
|
|
||||||
|
|
||||||
- dir = t("locales.#{@locale}.dir")
|
- dir = t("locales.#{@locale}.dir")
|
||||||
.row.justify-content-center
|
.row.justify-content-center
|
||||||
.col-md-8
|
.col-md-8
|
||||||
|
@ -28,7 +22,7 @@
|
||||||
- metadata = @post[attr]
|
- metadata = @post[attr]
|
||||||
- next unless metadata.front_matter?
|
- next unless metadata.front_matter?
|
||||||
|
|
||||||
- cache metadata do
|
- cache [metadata, I18n.locale] do
|
||||||
= render("posts/attribute_ro/#{metadata.type}",
|
= render("posts/attribute_ro/#{metadata.type}",
|
||||||
post: @post, attribute: attr,
|
post: @post, attribute: attr,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
@ -42,6 +36,6 @@
|
||||||
- metadata = @post[attr]
|
- metadata = @post[attr]
|
||||||
- next if metadata.front_matter?
|
- next if metadata.front_matter?
|
||||||
|
|
||||||
- cache metadata do
|
- cache [metadata, I18n.locale] do
|
||||||
%section.editor{ id: attr, dir: dir }
|
%section.editor{ id: attr, dir: dir }
|
||||||
= @post.public_send(attr).to_s.html_safe
|
= @post.public_send(attr).value.html_safe
|
||||||
|
|
|
@ -1,15 +1,9 @@
|
||||||
- if policy(site).build?
|
- if policy(site).build?
|
||||||
- if site.enqueued?
|
= form_tag site_enqueue_path(site),
|
||||||
= render 'layouts/btn_with_tooltip',
|
method: :post,
|
||||||
tooltip: t('help.sites.enqueued'),
|
class: 'form-inline inline' do
|
||||||
text: t('sites.enqueued'),
|
= submit_tag site.enqueued? ? t('sites.enqueued') : t('sites.enqueue'),
|
||||||
type: 'secondary',
|
class: 'btn no-border-radius',
|
||||||
link: nil
|
title: site.enqueued? ? t('help.sites.enqueued') : t('help.sites.enqueue'),
|
||||||
- else
|
data: { disable_with: t('sites.enqueued') },
|
||||||
= form_tag site_enqueue_path(site),
|
disabled: site.enqueued?
|
||||||
method: :post, class: 'form-inline inline' do
|
|
||||||
= button_tag type: 'submit',
|
|
||||||
class: 'btn no-border-radius',
|
|
||||||
title: t('help.sites.enqueue'),
|
|
||||||
data: { toggle: 'tooltip' } do
|
|
||||||
= t('sites.enqueue')
|
|
||||||
|
|
|
@ -39,7 +39,7 @@
|
||||||
%h2= f.label :description
|
%h2= f.label :description
|
||||||
%p.lead= t('.help.description')
|
%p.lead= t('.help.description')
|
||||||
= f.text_area :description, class: form_control(site, :description),
|
= f.text_area :description, class: form_control(site, :description),
|
||||||
maxlength: 160, minlength: 50, required: true
|
maxlength: 160, minlength: 10, required: true
|
||||||
- if invalid? site, :description
|
- if invalid? site, :description
|
||||||
.invalid-feedback= site.errors.messages[:description].join(', ')
|
.invalid-feedback= site.errors.messages[:description].join(', ')
|
||||||
%hr/
|
%hr/
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
t('.title', site: @site.name)]
|
|
||||||
.row.justify-content-center
|
.row.justify-content-center
|
||||||
.col-md-8
|
.col-md-8
|
||||||
%h1= t('.title', site: @site.name)
|
%h1= t('.title', site: @site.name)
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path), t('.title')]
|
|
||||||
|
|
||||||
.row.justify-content-center
|
.row.justify-content-center
|
||||||
.col-md-8#pull
|
.col-md-8#pull
|
||||||
%h1= t('.title')
|
%h1= t('.title')
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
= render 'layouts/breadcrumb', crumbs: [t('sites.index.title')]
|
|
||||||
|
|
||||||
%main.row
|
%main.row
|
||||||
%aside.col-md-3
|
%aside.col-md-3
|
||||||
%h1= t('.title')
|
%h1= t('.title')
|
||||||
|
@ -16,16 +14,17 @@
|
||||||
%table.table.table-condensed
|
%table.table.table-condensed
|
||||||
%tbody
|
%tbody
|
||||||
- @sites.each do |site|
|
- @sites.each do |site|
|
||||||
|
- next unless site.jekyll
|
||||||
- rol = current_usuarie.rol_for_site(site)
|
- rol = current_usuarie.rol_for_site(site)
|
||||||
-#
|
-#
|
||||||
TODO: Solo les usuaries cachean porque tenemos que separar
|
TODO: Solo les usuaries cachean porque tenemos que separar
|
||||||
les botones por permisos.
|
les botones por permisos.
|
||||||
- cache_if (rol.usuarie? && !rol.temporal), site do
|
- cache_if (rol.usuarie? && !rol.temporal), [site, I18n.locale] do
|
||||||
%tr
|
%tr
|
||||||
%td
|
%td
|
||||||
%h2
|
%h2
|
||||||
- if policy(site).show?
|
- if policy(site).show?
|
||||||
= link_to site.title, site_path(site)
|
= link_to site.title, site_posts_path(site, locale: site.default_locale)
|
||||||
- else
|
- else
|
||||||
= site.title
|
= site.title
|
||||||
%p.lead= site.description
|
%p.lead= site.description
|
||||||
|
|
|
@ -1,6 +1,3 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path), t('.title')]
|
|
||||||
|
|
||||||
.row.justify-content-center
|
.row.justify-content-center
|
||||||
.col-md-8
|
.col-md-8
|
||||||
%h1= t('.title')
|
%h1= t('.title')
|
||||||
|
|
|
@ -1,17 +1,43 @@
|
||||||
= render 'layouts/breadcrumb',
|
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
|
||||||
link_to(@site.name, site_path(@site)), t('.title')]
|
|
||||||
|
|
||||||
.row
|
.row
|
||||||
.col
|
.col
|
||||||
%h1= t('.title')
|
%h1= t('.title')
|
||||||
%p.lead= t('.help')
|
%p.lead= t('.help')
|
||||||
|
- if @last_stat
|
||||||
|
%p
|
||||||
|
%small
|
||||||
|
= t('.last_update')
|
||||||
|
%time{ datetime: @last_stat.created_at }
|
||||||
|
#{time_ago_in_words @last_stat.created_at}.
|
||||||
|
|
||||||
%table.table.table-condensed
|
.mb-5
|
||||||
%tbody
|
- Stat::INTERVALS.each do |interval|
|
||||||
%tr
|
= link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls]), class: "btn #{'btn-primary active' if @interval == interval}"
|
||||||
%td= t('.build.average')
|
|
||||||
%td= distance_of_time_in_words_if_more_than_a_minute @build_avg
|
.mb-5
|
||||||
%tr
|
%h2= t('.host.title', count: @hostnames.size)
|
||||||
%td= t('.build.maximum')
|
%p.lead= t('.host.description')
|
||||||
%td= distance_of_time_in_words_if_more_than_a_minute @build_max
|
= line_chart site_stats_host_path(@chart_params), **@chart_options
|
||||||
|
|
||||||
|
.mb-5
|
||||||
|
%h2= t('.urls.title')
|
||||||
|
%p.lead= t('.urls.description')
|
||||||
|
%form
|
||||||
|
%input{ type: 'hidden', name: 'interval', value: @interval }
|
||||||
|
.form-group
|
||||||
|
%label{ for: 'urls' }= t('.urls.label')
|
||||||
|
%textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size, aria_describedby: 'help-urls' }= @normalized_urls.join("\n")
|
||||||
|
%small#help-urls.feedback.form-text.text-muted= t('.urls.help')
|
||||||
|
.form-group
|
||||||
|
%button.btn{ type: 'submit' }= t('.urls.submit')
|
||||||
|
- if @normalized_urls.present?
|
||||||
|
= line_chart site_stats_uris_path(urls: params[:urls], **@chart_params), **@chart_options
|
||||||
|
|
||||||
|
.mb-5
|
||||||
|
%h2= t('.resources.title')
|
||||||
|
%p.lead= t('.resources.description')
|
||||||
|
|
||||||
|
- Stat::RESOURCES.each do |resource|
|
||||||
|
.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])
|
||||||
|
|
|
@ -1,32 +1,24 @@
|
||||||
= render 'layouts/breadcrumb',
|
.row.justify-content-center
|
||||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
.col.col-md-8
|
||||||
link_to(@site.name, @site),
|
|
||||||
t('.title')]
|
|
||||||
|
|
||||||
.row
|
|
||||||
.col
|
|
||||||
%h1= t('.title')
|
%h1= t('.title')
|
||||||
|
|
||||||
.row
|
|
||||||
.col
|
|
||||||
-# Una tabla de usuaries y otra de invitades, con acciones
|
-# Una tabla de usuaries y otra de invitades, con acciones
|
||||||
- %i[usuaries invitades].each do |u|
|
- %i[usuaries invitades].each do |u|
|
||||||
%h2
|
%h2.mt-5= t(".#{u}")
|
||||||
= t(".#{u}")
|
.btn-group{ role: 'group', 'aria-label': t('.actions') }
|
||||||
.btn-group{ role: 'group', 'aria-label': t('.actions') }
|
- if @policy.invite?
|
||||||
- if @policy.invite?
|
= link_to t('.invite'),
|
||||||
= link_to t('.invite'),
|
site_usuaries_invite_path(@site, invite_as: u.to_s),
|
||||||
site_usuaries_invite_path(@site, invite_as: u.to_s),
|
class: 'btn',
|
||||||
class: 'btn',
|
data: { toggle: 'tooltip' },
|
||||||
data: { toggle: 'tooltip' },
|
title: t('.help.invite', invite_as: u.to_s)
|
||||||
title: t('.help.invite', invite_as: u.to_s)
|
- if policy(Collaboration.new(@site)).collaborate?
|
||||||
- if policy(Collaboration.new(@site)).collaborate?
|
= link_to t('.public_invite'),
|
||||||
= link_to t('.public_invite'),
|
site_collaborate_path(@site),
|
||||||
site_collaborate_path(@site),
|
class: 'btn',
|
||||||
class: 'btn',
|
data: { toggle: 'tooltip' },
|
||||||
data: { toggle: 'tooltip' },
|
title: t('.help.public_invite')
|
||||||
title: t('.help.public_invite')
|
%p.lead= t(".help.#{u}")
|
||||||
%p= t(".help.#{u}")
|
|
||||||
%table.table.table-condensed
|
%table.table.table-condensed
|
||||||
%tbody
|
%tbody
|
||||||
- @site.send(u).each do |cuenta|
|
- @site.send(u).each do |cuenta|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue