mirror of
https://0xacab.org/sutty/sutty
synced 2024-11-16 10:41:41 +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=
|
||||
DEFAULT_FROM=
|
||||
EXCEPTION_TO=
|
||||
SKEL_SUTTY=https://0xacab.org/sutty/skel.sutty.nl
|
||||
# XXX: Si cambiás esta variable, tenés que editar config/webpacker.yml también :(
|
||||
SUTTY=sutty.local
|
||||
|
@ -27,3 +32,6 @@ TIENDA=tienda.sutty.local
|
|||
PANEL_URL=https://panel.sutty.nl
|
||||
AIRBRAKE_SITE_ID=1
|
||||
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
|
||||
# como el tarball van a tener que cambiar porque ya vamos a haber hecho
|
||||
# un clone/pull limpio.
|
||||
FROM alpine:3.13.4 AS build
|
||||
FROM alpine:3.13.6 AS build
|
||||
MAINTAINER "f <f@sutty.nl>"
|
||||
|
||||
ARG RAILS_MASTER_KEY
|
||||
ARG BRANCH
|
||||
|
||||
# Un entorno base
|
||||
ENV BRANCH=$BRANCH
|
||||
ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake
|
||||
ENV RAILS_ENV production
|
||||
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 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://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 adduser -s /bin/sh -G www-data -h /home/app -D app
|
||||
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
|
||||
USER app
|
||||
|
@ -37,7 +39,8 @@ WORKDIR /home/app/sutty
|
|||
# Copiamos solo el Gemfile para poder instalar las gemas necesarias
|
||||
COPY --chown=app:www-data ./Gemfile .
|
||||
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'
|
||||
# Vaciar la caché
|
||||
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
|
||||
# archivos
|
||||
RUN cd .. && git clone sutty checkout
|
||||
RUN cd ../checkout && git checkout $BRANCH
|
||||
|
||||
WORKDIR /home/app/checkout
|
||||
# Traer las gemas:
|
||||
RUN rm -rf ./vendor
|
||||
RUN mv ../sutty/vendor ./vendor
|
||||
RUN mv ../sutty/.bundle ./.bundle
|
||||
|
||||
# Instalar secretos
|
||||
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
|
||||
# Eliminar archivos innecesarios
|
||||
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
|
||||
|
||||
# Contenedor final
|
||||
FROM sutty/monit:latest
|
||||
FROM registry.nulo.in/sutty/monit:3.13.6
|
||||
ENV RAILS_ENV production
|
||||
|
||||
# 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
|
||||
# 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 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
|
||||
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://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
|
||||
RUN apk add --no-cache yarn
|
||||
# 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
|
||||
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/_sites /srv/http/_sites
|
||||
RUN ln -s data/_deploy /srv/http/_deploy
|
||||
RUN ln -s data/_public /srv/http/_public
|
||||
RUN ln -s data/_private /srv/http/_private
|
||||
|
||||
# Volver a root para cerrar la compilación
|
||||
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
|
||||
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
|
||||
|
|
47
Gemfile
47
Gemfile
|
@ -1,18 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
# TODO: Podríamos usar solo gems.sutty.nl pero por alguna razón bundler
|
||||
# prefiere x86_64-linux-musl antes que x86_64-linux y ya perdimos mucho
|
||||
# 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
|
||||
puts 'Usa haini.sh para generar un entorno de trabajo reproducible'
|
||||
source 'https://gems.sutty.nl'
|
||||
|
||||
ruby '~> 2.7'
|
||||
|
||||
|
@ -22,13 +11,18 @@ gem 'dotenv-rails', require: 'dotenv/rails-now'
|
|||
gem 'rails', '~> 6'
|
||||
# Use Puma as the app server
|
||||
gem 'puma'
|
||||
# See https://github.com/rails/execjs#readme for more supported runtimes
|
||||
# gem 'therubyracer', platforms: :ruby
|
||||
# Use SCSS for stylesheets
|
||||
gem 'sassc-rails'
|
||||
# Use Uglifier as compressor for JavaScript assets
|
||||
gem 'uglifier', '>= 1.3.0'
|
||||
gem 'bootstrap', '~> 4'
|
||||
|
||||
# Solo incluir las gemas cuando estemos en desarrollo o compilando los
|
||||
# assets. No es necesario instalarlas en producción.
|
||||
#
|
||||
# XXX: Supuestamente Rails ya soporta RAILS_GROUPS, pero Bundler no.
|
||||
if ENV['RAILS_GROUPS']&.split(',')&.include? 'assets'
|
||||
gem 'sassc-rails'
|
||||
gem 'uglifier', '>= 1.3.0'
|
||||
gem 'bootstrap', '~> 4'
|
||||
end
|
||||
|
||||
gem 'nokogiri'
|
||||
|
||||
# Turbolinks makes navigating your web application faster. Read more:
|
||||
# https://github.com/turbolinks/turbolinks
|
||||
|
@ -39,6 +33,7 @@ gem 'jbuilder', '~> 2.5'
|
|||
# Use ActiveModel has_secure_password
|
||||
gem 'bcrypt', '~> 3.1.7'
|
||||
gem 'blazer'
|
||||
gem 'chartkick'
|
||||
gem 'commonmarker'
|
||||
gem 'devise'
|
||||
gem 'devise-i18n'
|
||||
|
@ -52,22 +47,24 @@ gem 'hiredis'
|
|||
gem 'image_processing'
|
||||
gem 'icalendar'
|
||||
gem 'inline_svg'
|
||||
gem 'httparty'
|
||||
gem 'safe_yaml', source: 'https://gems.sutty.nl'
|
||||
gem 'jekyll', '~> 4.2'
|
||||
gem 'jekyll-data', source: 'https://gems.sutty.nl'
|
||||
gem 'jekyll-commonmark'
|
||||
gem 'jekyll-images'
|
||||
gem 'jekyll-include-cache'
|
||||
gem 'sutty-liquid'
|
||||
gem 'sutty-liquid', '>= 0.7.3'
|
||||
gem 'loaf'
|
||||
gem 'lockbox'
|
||||
gem 'mini_magick'
|
||||
gem 'mobility'
|
||||
gem 'pg'
|
||||
gem 'pundit'
|
||||
gem 'rails-i18n'
|
||||
gem 'rails_warden'
|
||||
gem 'redis', require: %w[redis redis/connection/hiredis]
|
||||
gem 'redis-rails'
|
||||
gem 'rollups', git: 'https://github.com/ankane/rollup.git', branch: 'master'
|
||||
gem 'rubyzip'
|
||||
gem 'rugged'
|
||||
gem 'concurrent-ruby-ext'
|
||||
|
@ -77,6 +74,12 @@ gem 'terminal-table'
|
|||
gem 'validates_hostname'
|
||||
gem 'webpacker'
|
||||
gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git'
|
||||
gem 'kaminari'
|
||||
|
||||
# database
|
||||
gem 'hairtrigger'
|
||||
gem 'pg'
|
||||
gem 'pg_search'
|
||||
|
||||
# performance
|
||||
gem 'flamegraph'
|
||||
|
|
411
Gemfile.lock
411
Gemfile.lock
|
@ -6,6 +6,15 @@ GIT
|
|||
rails (>= 3.0)
|
||||
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
|
||||
remote: https://github.com/fauno/email_address
|
||||
revision: 536b51f7071b68a55140c0c1726b4cd401d1c04d
|
||||
|
@ -18,66 +27,66 @@ GIT
|
|||
GEM
|
||||
remote: https://gems.sutty.nl/
|
||||
specs:
|
||||
actioncable (6.1.3.1)
|
||||
actionpack (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
actioncable (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.1.3.1)
|
||||
actionpack (= 6.1.3.1)
|
||||
activejob (= 6.1.3.1)
|
||||
activerecord (= 6.1.3.1)
|
||||
activestorage (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
actionmailbox (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.3.1)
|
||||
actionpack (= 6.1.3.1)
|
||||
actionview (= 6.1.3.1)
|
||||
activejob (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
actionmailer (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.3.1)
|
||||
actionview (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
actionpack (6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.3.1)
|
||||
actionpack (= 6.1.3.1)
|
||||
activerecord (= 6.1.3.1)
|
||||
activestorage (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
actiontext (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
actionview (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
activejob (6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
activejob (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
activerecord (6.1.3.1)
|
||||
activemodel (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
activestorage (6.1.3.1)
|
||||
actionpack (= 6.1.3.1)
|
||||
activejob (= 6.1.3.1)
|
||||
activerecord (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
activemodel (6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activerecord (6.1.4.1)
|
||||
activemodel (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
activestorage (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
marcel (~> 1.0.0)
|
||||
mini_mime (~> 1.0.2)
|
||||
activesupport (6.1.3.1)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.4.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
zeitwerk (~> 2.3)
|
||||
addressable (2.7.0)
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
adhesiones-jekyll-theme (0.2.1)
|
||||
jekyll (~> 4.0)
|
||||
|
@ -89,13 +98,13 @@ GEM
|
|||
jekyll-relative-urls (~> 0.0)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
ast (2.4.2)
|
||||
autoprefixer-rails (10.2.4.0)
|
||||
execjs
|
||||
bcrypt (3.1.16)
|
||||
bcrypt_pbkdf (1.1.0)
|
||||
benchmark-ips (2.8.4)
|
||||
bindex (0.8.1)
|
||||
blazer (2.4.2)
|
||||
autoprefixer-rails (10.3.3.0)
|
||||
execjs (~> 2)
|
||||
bcrypt (3.1.16-x86_64-linux-musl)
|
||||
bcrypt_pbkdf (1.1.0-x86_64-linux-musl)
|
||||
benchmark-ips (2.9.2)
|
||||
bindex (0.8.1-x86_64-linux-musl)
|
||||
blazer (2.4.7)
|
||||
activerecord (>= 5)
|
||||
chartkick (>= 3.2)
|
||||
railties (>= 5)
|
||||
|
@ -104,7 +113,7 @@ GEM
|
|||
autoprefixer-rails (>= 9.1.0)
|
||||
popper_js (>= 1.14.3, < 2)
|
||||
sassc-rails (>= 2.0.0)
|
||||
brakeman (5.0.0)
|
||||
brakeman (5.1.2)
|
||||
builder (3.2.4)
|
||||
capybara (2.18.0)
|
||||
addressable
|
||||
|
@ -113,24 +122,24 @@ GEM
|
|||
rack (>= 1.0.0)
|
||||
rack-test (>= 0.5.4)
|
||||
xpath (>= 2.0, < 4.0)
|
||||
chartkick (4.0.3)
|
||||
childprocess (3.0.0)
|
||||
chartkick (4.1.2)
|
||||
childprocess (4.1.0)
|
||||
coderay (1.1.3)
|
||||
colorator (1.1.0)
|
||||
commonmarker (0.21.2)
|
||||
commonmarker (0.21.2-x86_64-linux-musl)
|
||||
ruby-enum (~> 0.5)
|
||||
concurrent-ruby (1.1.8)
|
||||
concurrent-ruby-ext (1.1.8)
|
||||
concurrent-ruby (= 1.1.8)
|
||||
concurrent-ruby (1.1.9)
|
||||
concurrent-ruby-ext (1.1.9-x86_64-linux-musl)
|
||||
concurrent-ruby (= 1.1.9)
|
||||
crass (1.0.6)
|
||||
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.1)
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
dead_end (1.1.6)
|
||||
derailed_benchmarks (2.0.1)
|
||||
dead_end (3.1.0)
|
||||
derailed_benchmarks (2.1.1)
|
||||
benchmark-ips (~> 2)
|
||||
dead_end
|
||||
get_process_mem (~> 0)
|
||||
|
@ -142,25 +151,25 @@ GEM
|
|||
rake (> 10, < 14)
|
||||
ruby-statistics (>= 2.1)
|
||||
thor (>= 0.19, < 2)
|
||||
devise (4.7.3)
|
||||
devise (4.8.0)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-i18n (1.9.3)
|
||||
devise (>= 4.7.1)
|
||||
devise_invitable (2.0.4)
|
||||
devise-i18n (1.10.1)
|
||||
devise (>= 4.8.0)
|
||||
devise_invitable (2.0.5)
|
||||
actionmailer (>= 5.0)
|
||||
devise (>= 4.6)
|
||||
dotenv (2.7.6)
|
||||
dotenv-rails (2.7.6)
|
||||
dotenv (= 2.7.6)
|
||||
railties (>= 3.2)
|
||||
down (5.2.0)
|
||||
addressable (~> 2.5)
|
||||
ed25519 (1.2.4)
|
||||
editorial-autogestiva-jekyll-theme (0.3.0)
|
||||
down (5.2.4)
|
||||
addressable (~> 2.8)
|
||||
ed25519 (1.2.4-x86_64-linux-musl)
|
||||
editorial-autogestiva-jekyll-theme (0.3.4)
|
||||
jekyll (~> 4)
|
||||
jekyll-commonmark (~> 1.3)
|
||||
jekyll-data (~> 1.1)
|
||||
|
@ -179,44 +188,50 @@ GEM
|
|||
jekyll-unique-urls (~> 0)
|
||||
jekyll-write-and-commit-changes (~> 0)
|
||||
sutty-liquid (~> 0)
|
||||
em-websocket (0.5.2)
|
||||
em-websocket (0.5.3)
|
||||
eventmachine (>= 0.12.9)
|
||||
http_parser.rb (~> 0.6.0)
|
||||
http_parser.rb (~> 0)
|
||||
errbase (0.2.1)
|
||||
erubi (1.10.0)
|
||||
eventmachine (1.2.7)
|
||||
eventmachine (1.2.7-x86_64-linux-musl)
|
||||
exception_notification (4.4.3)
|
||||
actionmailer (>= 4.0, < 7)
|
||||
activesupport (>= 4.0, < 7)
|
||||
execjs (2.7.0)
|
||||
factory_bot (6.1.0)
|
||||
execjs (2.8.1)
|
||||
factory_bot (6.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
factory_bot_rails (6.1.0)
|
||||
factory_bot (~> 6.1.0)
|
||||
factory_bot_rails (6.2.0)
|
||||
factory_bot (~> 6.2.0)
|
||||
railties (>= 5.0.0)
|
||||
fast_blank (1.0.0)
|
||||
fast_jsonparser (0.5.0)
|
||||
ffi (1.15.0)
|
||||
fast_blank (1.0.1-x86_64-linux-musl)
|
||||
fast_jsonparser (0.5.0-x86_64-linux-musl)
|
||||
ffi (1.15.4-x86_64-linux-musl)
|
||||
flamegraph (0.9.5)
|
||||
forwardable-extended (2.6.0)
|
||||
friendly_id (5.4.2)
|
||||
activerecord (>= 4.0.0)
|
||||
get_process_mem (0.2.7)
|
||||
ffi (~> 1.0)
|
||||
globalid (0.4.2)
|
||||
activesupport (>= 4.2.0)
|
||||
haml (5.2.1)
|
||||
globalid (0.6.0)
|
||||
activesupport (>= 5.0)
|
||||
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)
|
||||
tilt
|
||||
haml-lint (0.999.999)
|
||||
haml_lint
|
||||
haml_lint (0.37.0)
|
||||
haml_lint (0.37.1)
|
||||
haml (>= 4.0, < 5.3)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
rubocop (>= 0.50.0)
|
||||
sysexits (~> 1.1)
|
||||
hamlit (2.15.0)
|
||||
hamlit (2.15.1-x86_64-linux-musl)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
|
@ -227,25 +242,25 @@ GEM
|
|||
railties (>= 4.0.1)
|
||||
heapy (0.2.0)
|
||||
thor
|
||||
hiredis (0.6.3)
|
||||
http_parser.rb (0.6.0)
|
||||
hiredis (0.6.3-x86_64-linux-musl)
|
||||
http_parser.rb (0.8.0-x86_64-linux-musl)
|
||||
httparty (0.18.1)
|
||||
mime-types (~> 3.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.8.10)
|
||||
i18n (1.8.11)
|
||||
concurrent-ruby (~> 1.0)
|
||||
icalendar (2.7.1)
|
||||
ice_cube (~> 0.16)
|
||||
ice_cube (0.16.3)
|
||||
ice_cube (0.16.4)
|
||||
image_processing (1.12.1)
|
||||
mini_magick (>= 4.9.5, < 5)
|
||||
ruby-vips (>= 2.0.17, < 3)
|
||||
inline_svg (1.7.2)
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
jbuilder (2.11.2)
|
||||
jbuilder (2.11.3)
|
||||
activesupport (>= 5.0.0)
|
||||
jekyll (4.2.0)
|
||||
jekyll (4.2.1)
|
||||
addressable (~> 2.4)
|
||||
colorator (~> 1.0)
|
||||
em-websocket (~> 0.5)
|
||||
|
@ -260,8 +275,8 @@ GEM
|
|||
rouge (~> 3.0)
|
||||
safe_yaml (~> 1.0)
|
||||
terminal-table (~> 2.0)
|
||||
jekyll-commonmark (1.3.1)
|
||||
commonmarker (~> 0.14)
|
||||
jekyll-commonmark (1.3.2)
|
||||
commonmarker (~> 0.14, < 0.22)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-data (1.1.2)
|
||||
jekyll (>= 3.3, < 5.0.0)
|
||||
|
@ -272,21 +287,19 @@ GEM
|
|||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-hardlinks (0.1.2)
|
||||
jekyll (~> 4)
|
||||
jekyll-ignore-layouts (0.1.0)
|
||||
jekyll-ignore-layouts (0.1.2)
|
||||
jekyll (~> 4)
|
||||
jekyll-images (0.2.7)
|
||||
jekyll-images (0.3.0)
|
||||
jekyll (~> 4)
|
||||
ruby-filemagic (~> 0.7)
|
||||
ruby-vips (~> 2)
|
||||
jekyll-include-cache (0.2.1)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-linked-posts (0.2.0)
|
||||
jekyll-linked-posts (0.4.2)
|
||||
jekyll (~> 4)
|
||||
jekyll-locales (0.1.12)
|
||||
jekyll-lunr (0.2.0)
|
||||
jekyll-locales (0.1.13)
|
||||
jekyll-lunr (0.3.0)
|
||||
loofah (~> 2.4)
|
||||
jekyll-node-modules (0.1.0)
|
||||
jekyll (~> 4)
|
||||
jekyll-order (0.1.4)
|
||||
jekyll-relative-urls (0.0.6)
|
||||
jekyll (~> 4)
|
||||
|
@ -294,9 +307,9 @@ GEM
|
|||
sassc (> 2.0.1, < 3.0)
|
||||
jekyll-seo-tag (2.7.1)
|
||||
jekyll (>= 3.8, < 5.0)
|
||||
jekyll-spree-client (0.1.12)
|
||||
jekyll-spree-client (0.1.19)
|
||||
fast_blank (~> 1)
|
||||
spree-api-client (~> 0.2)
|
||||
spree-api-client (>= 0.2.4)
|
||||
jekyll-turbolinks (0.0.5)
|
||||
jekyll (~> 4)
|
||||
turbolinks-source (~> 5)
|
||||
|
@ -304,9 +317,21 @@ GEM
|
|||
jekyll (~> 4)
|
||||
jekyll-watch (2.2.1)
|
||||
listen (~> 3.0)
|
||||
jekyll-write-and-commit-changes (0.1.2)
|
||||
jekyll-write-and-commit-changes (0.2.1)
|
||||
jekyll (~> 4)
|
||||
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)
|
||||
rexml
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
|
@ -320,72 +345,77 @@ GEM
|
|||
rb-fsevent (~> 0.9, >= 0.9.4)
|
||||
rb-inotify (~> 0.9, >= 0.9.7)
|
||||
ruby_dep (~> 1.2)
|
||||
lockbox (0.6.4)
|
||||
loaf (0.10.0)
|
||||
railties (>= 3.2)
|
||||
lockbox (0.6.6)
|
||||
lograge (0.11.2)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
railties (>= 4)
|
||||
request_store (~> 1.0)
|
||||
loofah (2.9.1)
|
||||
loofah (2.12.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
marcel (1.0.1)
|
||||
marcel (1.0.2)
|
||||
memory_profiler (1.0.0)
|
||||
mercenary (0.4.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.2021.0225)
|
||||
mime-types-data (3.2021.1115)
|
||||
mini_histogram (0.3.1)
|
||||
mini_magick (4.11.0)
|
||||
mini_mime (1.0.3)
|
||||
mini_portile2 (2.5.0)
|
||||
mini_mime (1.1.2)
|
||||
mini_portile2 (2.6.1)
|
||||
minima (2.5.1)
|
||||
jekyll (>= 3.5, < 5.0)
|
||||
jekyll-feed (~> 0.9)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
minitest (5.14.4)
|
||||
mobility (1.1.1)
|
||||
mobility (1.2.4)
|
||||
i18n (>= 0.6.10, < 2)
|
||||
request_store (~> 1.0)
|
||||
multi_xml (0.6.0)
|
||||
net-ssh (6.1.0)
|
||||
netaddr (2.0.4)
|
||||
nio4r (2.5.7)
|
||||
nokogiri (1.11.3)
|
||||
mini_portile2 (~> 2.5.0)
|
||||
netaddr (2.0.5)
|
||||
nio4r (2.5.8-x86_64-linux-musl)
|
||||
nokogiri (1.12.5-x86_64-linux-musl)
|
||||
mini_portile2 (~> 2.6.1)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.20.1)
|
||||
parser (3.0.1.0)
|
||||
parallel (1.21.0)
|
||||
parser (3.0.2.0)
|
||||
ast (~> 2.4.1)
|
||||
pathutil (0.16.2)
|
||||
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)
|
||||
prometheus_exporter (0.7.0)
|
||||
prometheus_exporter (1.0.0)
|
||||
webrick
|
||||
pry (0.14.1)
|
||||
coderay (~> 1.1)
|
||||
method_source (~> 1.0)
|
||||
public_suffix (4.0.6)
|
||||
puma (5.2.2)
|
||||
puma (5.5.2-x86_64-linux-musl)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.1.0)
|
||||
pundit (2.1.1)
|
||||
activesupport (>= 3.0.0)
|
||||
racc (1.5.2)
|
||||
racc (1.6.0-x86_64-linux-musl)
|
||||
rack (2.2.3)
|
||||
rack-cors (1.1.1)
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (2.3.1)
|
||||
rack-mini-profiler (2.3.3)
|
||||
rack (>= 1.2.0)
|
||||
rack-proxy (0.6.5)
|
||||
rack-proxy (0.7.0)
|
||||
rack
|
||||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
radios-comunitarias-jekyll-theme (0.1.4)
|
||||
radios-comunitarias-jekyll-theme (0.1.5)
|
||||
jekyll (~> 4.0)
|
||||
jekyll-data (~> 1.1)
|
||||
jekyll-feed (~> 0.9)
|
||||
|
@ -396,65 +426,66 @@ GEM
|
|||
jekyll-relative-urls (~> 0.0)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
jekyll-turbolinks (~> 0)
|
||||
rails (6.1.3.1)
|
||||
actioncable (= 6.1.3.1)
|
||||
actionmailbox (= 6.1.3.1)
|
||||
actionmailer (= 6.1.3.1)
|
||||
actionpack (= 6.1.3.1)
|
||||
actiontext (= 6.1.3.1)
|
||||
actionview (= 6.1.3.1)
|
||||
activejob (= 6.1.3.1)
|
||||
activemodel (= 6.1.3.1)
|
||||
activerecord (= 6.1.3.1)
|
||||
activestorage (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
rails (6.1.4.1)
|
||||
actioncable (= 6.1.4.1)
|
||||
actionmailbox (= 6.1.4.1)
|
||||
actionmailer (= 6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
actiontext (= 6.1.4.1)
|
||||
actionview (= 6.1.4.1)
|
||||
activejob (= 6.1.4.1)
|
||||
activemodel (= 6.1.4.1)
|
||||
activerecord (= 6.1.4.1)
|
||||
activestorage (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.3.1)
|
||||
railties (= 6.1.4.1)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.3.0)
|
||||
rails-html-sanitizer (1.4.2)
|
||||
loofah (~> 2.3)
|
||||
rails-i18n (6.0.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 6.0.0, < 7)
|
||||
rails_warden (0.6.0)
|
||||
warden (>= 1.2.0)
|
||||
railties (6.1.3.1)
|
||||
actionpack (= 6.1.3.1)
|
||||
activesupport (= 6.1.3.1)
|
||||
railties (6.1.4.1)
|
||||
actionpack (= 6.1.4.1)
|
||||
activesupport (= 6.1.4.1)
|
||||
method_source
|
||||
rake (>= 0.8.7)
|
||||
rake (>= 0.13)
|
||||
thor (~> 1.0)
|
||||
rainbow (3.0.0)
|
||||
rake (13.0.3)
|
||||
rb-fsevent (0.10.4)
|
||||
rake (13.0.6)
|
||||
rb-fsevent (0.11.0)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
recursero-jekyll-theme (0.1.2)
|
||||
jekyll (~> 4.0)
|
||||
recursero-jekyll-theme (0.2.0)
|
||||
jekyll (~> 4)
|
||||
jekyll-commonmark (~> 1.3)
|
||||
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-include-cache (~> 0)
|
||||
jekyll-linked-posts (~> 0.2)
|
||||
jekyll-linked-posts (~> 0)
|
||||
jekyll-locales (~> 0.1)
|
||||
jekyll-lunr (~> 0.1)
|
||||
jekyll-node-modules (~> 0.1)
|
||||
jekyll-order (~> 0.1)
|
||||
jekyll-relative-urls (~> 0.0)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
jekyll-turbolinks (~> 0)
|
||||
jekyll-order (~> 0)
|
||||
jekyll-relative-urls (~> 0)
|
||||
jekyll-seo-tag (~> 2)
|
||||
jekyll-unique-urls (~> 0.1)
|
||||
sutty-archives (~> 2.2)
|
||||
sutty-liquid (~> 0.1)
|
||||
redis (4.2.5)
|
||||
sutty-liquid (~> 0)
|
||||
redis (4.5.1)
|
||||
redis-actionpack (5.2.0)
|
||||
actionpack (>= 5, < 7)
|
||||
redis-rack (>= 2.1.0, < 3)
|
||||
redis-store (>= 1.1.0, < 2)
|
||||
redis-activesupport (5.2.0)
|
||||
redis-activesupport (5.2.1)
|
||||
activesupport (>= 3, < 7)
|
||||
redis-store (>= 1.3, < 2)
|
||||
redis-rack (2.1.3)
|
||||
|
@ -473,36 +504,41 @@ GEM
|
|||
actionpack (>= 5.0)
|
||||
railties (>= 5.0)
|
||||
rexml (3.2.5)
|
||||
rouge (3.26.0)
|
||||
rubocop (1.12.1)
|
||||
rouge (3.26.1)
|
||||
rubocop (1.23.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.0.0.0)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 1.8, < 3.0)
|
||||
rexml
|
||||
rubocop-ast (>= 1.2.0, < 2.0)
|
||||
rubocop-ast (>= 1.12.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 3.0)
|
||||
rubocop-ast (1.4.1)
|
||||
parser (>= 2.7.1.5)
|
||||
rubocop-rails (2.9.1)
|
||||
rubocop-ast (1.13.0)
|
||||
parser (>= 3.0.1.1)
|
||||
rubocop-rails (2.12.4)
|
||||
activesupport (>= 4.2.0)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 0.90.0, < 2.0)
|
||||
rubocop (>= 1.7.0, < 2.0)
|
||||
ruby-enum (0.9.0)
|
||||
i18n
|
||||
ruby-filemagic (0.7.2)
|
||||
ruby-filemagic (0.7.2-x86_64-linux-musl)
|
||||
ruby-progressbar (1.11.0)
|
||||
ruby-statistics (2.1.3)
|
||||
ruby-vips (2.1.0)
|
||||
ruby-statistics (3.0.0)
|
||||
ruby-vips (2.1.4)
|
||||
ffi (~> 1.12)
|
||||
ruby2ruby (2.4.4)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_dep (1.5.0)
|
||||
rubyzip (2.3.0)
|
||||
rugged (1.1.0)
|
||||
ruby_parser (3.18.1)
|
||||
sexp_processor (~> 4.16)
|
||||
rubyzip (2.3.2)
|
||||
rugged (1.2.0-x86_64-linux-musl)
|
||||
safe_yaml (1.0.6)
|
||||
safely_block (0.3.0)
|
||||
errbase (>= 0.1.1)
|
||||
sassc (2.4.0)
|
||||
sassc (2.4.0-x86_64-linux-musl)
|
||||
ffi (~> 1.9)
|
||||
sassc-rails (2.1.2)
|
||||
railties (>= 4.0.0)
|
||||
|
@ -510,10 +546,12 @@ GEM
|
|||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
selenium-webdriver (3.142.7)
|
||||
childprocess (>= 0.5, < 4.0)
|
||||
selenium-webdriver (4.1.0)
|
||||
childprocess (>= 0.5, < 5.0)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2)
|
||||
semantic_range (3.0.0)
|
||||
sexp_processor (4.16.0)
|
||||
share-to-fediverse-jekyll-theme (0.1.4)
|
||||
jekyll (~> 4.0)
|
||||
jekyll-data (~> 1.1)
|
||||
|
@ -525,7 +563,7 @@ GEM
|
|||
simpleidn (0.2.1)
|
||||
unf (~> 0.1.4)
|
||||
sourcemap (0.1.1)
|
||||
spree-api-client (0.2.1)
|
||||
spree-api-client (0.2.4)
|
||||
fast_blank (~> 1)
|
||||
httparty (~> 0.18.0)
|
||||
spring (2.1.1)
|
||||
|
@ -535,12 +573,12 @@ GEM
|
|||
sprockets (4.0.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (> 1, < 3)
|
||||
sprockets-rails (3.2.2)
|
||||
actionpack (>= 4.0)
|
||||
activesupport (>= 4.0)
|
||||
sprockets-rails (3.4.1)
|
||||
actionpack (>= 5.2)
|
||||
activesupport (>= 5.2)
|
||||
sprockets (>= 3.0.0)
|
||||
sqlite3 (1.4.2)
|
||||
stackprof (0.2.16)
|
||||
sqlite3 (1.4.2-x86_64-linux-musl)
|
||||
stackprof (0.2.17-x86_64-linux-musl)
|
||||
sucker_punch (3.0.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
sutty-archives (2.5.4)
|
||||
|
@ -562,14 +600,14 @@ GEM
|
|||
jekyll-include-cache (~> 0)
|
||||
jekyll-relative-urls (~> 0.0)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
sutty-liquid (0.7.2)
|
||||
sutty-liquid (0.7.4)
|
||||
fast_blank (~> 1.0)
|
||||
jekyll (~> 4)
|
||||
sutty-minima (2.5.0)
|
||||
jekyll (>= 3.5, < 5.0)
|
||||
jekyll-feed (~> 0.9)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
symbol-fstring (1.0.0)
|
||||
symbol-fstring (1.0.2-x86_64-linux-musl)
|
||||
sysexits (1.2.0)
|
||||
temple (0.8.2)
|
||||
terminal-table (2.0.0)
|
||||
|
@ -586,33 +624,34 @@ GEM
|
|||
execjs (>= 0.3.0, < 3)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.7.7)
|
||||
unicode-display_width (1.7.0)
|
||||
unf_ext (0.0.8-x86_64-linux-musl)
|
||||
unicode-display_width (1.8.0)
|
||||
validates_hostname (1.0.11)
|
||||
activerecord (>= 3.0)
|
||||
activesupport (>= 3.0)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.1.0)
|
||||
web-console (4.2.0)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
webpacker (5.2.1)
|
||||
webpacker (5.4.3)
|
||||
activesupport (>= 5.2)
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.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.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.4.2)
|
||||
zeitwerk (2.5.1)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
x86_64-linux-musl
|
||||
|
||||
DEPENDENCIES
|
||||
adhesiones-jekyll-theme
|
||||
|
@ -622,6 +661,7 @@ DEPENDENCIES
|
|||
bootstrap (~> 4)
|
||||
brakeman
|
||||
capybara (~> 2.13)
|
||||
chartkick
|
||||
commonmarker
|
||||
concurrent-ruby-ext
|
||||
database_cleaner
|
||||
|
@ -640,9 +680,11 @@ DEPENDENCIES
|
|||
fast_jsonparser
|
||||
flamegraph
|
||||
friendly_id
|
||||
hairtrigger
|
||||
haml-lint
|
||||
hamlit-rails
|
||||
hiredis
|
||||
httparty
|
||||
icalendar
|
||||
image_processing
|
||||
inline_svg
|
||||
|
@ -652,8 +694,10 @@ DEPENDENCIES
|
|||
jekyll-data!
|
||||
jekyll-images
|
||||
jekyll-include-cache
|
||||
kaminari
|
||||
letter_opener
|
||||
listen (>= 3.0.5, < 3.2)
|
||||
loaf
|
||||
lockbox
|
||||
lograge
|
||||
memory_profiler
|
||||
|
@ -661,7 +705,9 @@ DEPENDENCIES
|
|||
minima
|
||||
mobility
|
||||
net-ssh
|
||||
nokogiri
|
||||
pg
|
||||
pg_search
|
||||
prometheus_exporter
|
||||
pry
|
||||
puma
|
||||
|
@ -675,6 +721,7 @@ DEPENDENCIES
|
|||
recursero-jekyll-theme
|
||||
redis
|
||||
redis-rails
|
||||
rollups!
|
||||
rubocop-rails
|
||||
rubyzip
|
||||
rugged
|
||||
|
@ -690,7 +737,7 @@ DEPENDENCIES
|
|||
sucker_punch
|
||||
sutty-donaciones-jekyll-theme
|
||||
sutty-jekyll-theme
|
||||
sutty-liquid
|
||||
sutty-liquid (>= 0.7.3)
|
||||
sutty-minima
|
||||
symbol-fstring
|
||||
terminal-table
|
||||
|
@ -706,4 +753,4 @@ RUBY VERSION
|
|||
ruby 2.7.1p83
|
||||
|
||||
BUNDLED WITH
|
||||
2.1.4
|
||||
2.2.2
|
||||
|
|
211
Makefile
211
Makefile
|
@ -1,99 +1,156 @@
|
|||
# Incluir las variables de entorno
|
||||
mkfile_path := $(abspath $(lastword $(MAKEFILE_LIST)))
|
||||
root_dir := $(patsubst %/,%,$(dir $(mkfile_path)))
|
||||
include $(root_dir)/.env
|
||||
SHELL := /bin/bash
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
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)
|
||||
PANEL_URL=https://panel.sutty.nl RAILS_ENV=production NODE_ENV=production bundle exec rake assets:precompile assets:clean
|
||||
# XXX: El espacio antes del comentario cuenta como espacio
|
||||
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
|
||||
|
||||
serve: /etc/hosts
|
||||
bundle exec rails s -b "ssl://0.0.0.0:3000?key=../sutty.local/domain/$(SUTTY).key&cert=../sutty.local/domain/$(SUTTY).crt"
|
||||
|
||||
# Servir JS con el dev server.
|
||||
# Esto acelera la compilación del javascript, tiene que correrse por separado
|
||||
# de serve.
|
||||
serve-js: /etc/hosts
|
||||
bundle exec ./bin/webpack-dev-server
|
||||
|
||||
# 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))
|
||||
# El nodo delegado tiene dos entornos, production y staging.
|
||||
# Dependiendo del entorno que elijamos, se van a generar los assets y el
|
||||
# contenedor y subirse a un servidor u otro. No utilizamos CI/CD (aún).
|
||||
#
|
||||
# Production es el entorno de panel.sutty.nl
|
||||
ifeq ($(env),production)
|
||||
container ?= sutty
|
||||
## TODO: Cambiar a otra cosa
|
||||
branch ?= rails
|
||||
public ?= public
|
||||
endif
|
||||
|
||||
$(gem_binary_dir)/%-x86_64-linux-musl.gem:
|
||||
@docker run \
|
||||
-v $(gem_dir):/srv/gems \
|
||||
-v `readlink -f ~/.ccache`:/home/builder/.ccache \
|
||||
-e HTTP_BASIC_USER=$(HTTP_BASIC_USER) \
|
||||
-e HTTP_BASIC_PASSWORD=$(HTTP_BASIC_PASSWORD) \
|
||||
-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 $*"
|
||||
# Staging es el entorno de panel.staging.sutty.nl
|
||||
ifeq ($(env),staging)
|
||||
container := staging
|
||||
branch := staging
|
||||
public := staging
|
||||
endif
|
||||
|
||||
# Compilar todas las gemas binarias y subirlas a gems.sutty.nl para que
|
||||
# al crear el contenedor no tengamos que compilarlas cada vez
|
||||
build-gems: $(gems_musl)
|
||||
help: always ## Ayuda
|
||||
@echo -e "Sutty\n" | sed -re "s/^.*/\x1B[38;5;197m&\x1B[0m/"
|
||||
@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)
|
||||
rebuild_gems = $(patsubst $(gem_dir)/cache/%.gem,$(gem_dir)/$(alpine_version)/%-x86_64-linux-musl.gem,$(cached_gems))
|
||||
rebuild-gems: $(rebuild_gems)
|
||||
assets: public/packs/manifest.json.br ## Compilar los assets
|
||||
|
||||
dirs := $(patsubst %,root/%,data sites deploy public)
|
||||
test: always ## Ejecutar los tests
|
||||
$(MAKE) rake args="test RAILS_ENV=test $(args)"
|
||||
|
||||
$(dirs):
|
||||
mkdir -p $@
|
||||
postgresql: /etc/hosts ## Iniciar la base de datos
|
||||
pgrep postgres >/dev/null || $(hain) postgresql
|
||||
|
||||
app/assets/fonts/forkawesome-webfont.woff2: fa.txt
|
||||
which glyphhanger || npm i -g glyphhanger
|
||||
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 $@
|
||||
serve-js: /etc/hosts node_modules ## Iniciar el servidor de desarrollo de Javascript
|
||||
$(hain) 'bundle exec ./bin/webpack-dev-server'
|
||||
|
||||
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
|
||||
sudo chgrp -R 82 public/
|
||||
rsync -av public/ athshe:/srv/sutty/srv/http/data/_public/
|
||||
rails: ## Corre rails dentro del entorno de desarrollo (pasar argumentos con args=).
|
||||
$(MAKE) bundle args="exec rails $(args)"
|
||||
|
||||
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
|
||||
@echo "Chequeando si es necesario agregar el dominio local $(SUTTY)"
|
||||
@grep -q " $(SUTTY)$$" $@ || echo -e "127.0.0.1 $(SUTTY)\n::1 $(SUTTY)" | sudo tee -a $@
|
||||
@grep -q " api.$(SUTTY)$$" $@ || echo -e "127.0.0.1 api.$(SUTTY)\n::1 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 " 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
|
||||
|
|
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/).
|
||||
|
||||
### 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
|
||||
|
||||
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/).
|
||||
|
||||
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;
|
||||
$component-active-bg: $magenta;
|
||||
|
||||
$spacers: (
|
||||
2-plus: 0.75rem
|
||||
);
|
||||
|
||||
@import "bootstrap";
|
||||
@import "editor";
|
||||
|
||||
|
@ -210,6 +214,10 @@ svg {
|
|||
}
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@extend .badge
|
||||
}
|
||||
|
||||
.black-bg {
|
||||
color: $white;
|
||||
background-color: $black;
|
||||
|
@ -355,6 +363,13 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
|
|||
.text-column-#{$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;
|
||||
*, *::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 {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
@ -64,6 +71,10 @@
|
|||
strong, em, del, u, sub, sup, small { background: #0002; }
|
||||
a { background: #13fefe50; }
|
||||
[data-editor-selected] { outline: #f206f9 solid thick; }
|
||||
p[data-multimedia-inner] {
|
||||
// Ignorar clicks en el párrafo placeholder
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
*[data-editor-loading] {
|
||||
|
|
|
@ -23,8 +23,6 @@ class ApplicationController < ActionController::Base
|
|||
redirect_to sites_path
|
||||
end
|
||||
|
||||
def markdown; end
|
||||
|
||||
private
|
||||
|
||||
def uuid?(string)
|
||||
|
@ -42,11 +40,21 @@ class ApplicationController < ActionController::Base
|
|||
site
|
||||
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
|
||||
# corresponde con el idioma de los artículos, porque puede querer
|
||||
# traducirlos.
|
||||
def set_locale(&action)
|
||||
I18n.with_locale(current_usuarie&.lang || I18n.default_locale, &action)
|
||||
I18n.with_locale(current_locale(include_params: false), &action)
|
||||
end
|
||||
|
||||
# Muestra una página 404
|
||||
|
|
|
@ -7,80 +7,67 @@ class PostsController < ApplicationController
|
|||
|
||||
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
|
||||
def default_url_options
|
||||
{ locale: params[:locale] || current_usuarie&.lang || I18n.locale }
|
||||
{ locale: current_locale }
|
||||
end
|
||||
|
||||
def index
|
||||
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
|
||||
# más simple saber si hubo cambios.
|
||||
if @category || @layout || stale?(@site)
|
||||
@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
|
||||
return unless stale?([current_usuarie, site, filter_params])
|
||||
|
||||
@category_name = if uuid?(@category)
|
||||
@site.posts(lang: locale).find(@category, uuid: true)&.title&.value
|
||||
else
|
||||
@category
|
||||
end
|
||||
# Todos los artículos de este sitio para el idioma actual
|
||||
@posts = site.indexed_posts.where(locale: locale)
|
||||
# De este tipo
|
||||
@posts = @posts.where(layout: filter_params[:layout]) if filter_params[:layout]
|
||||
# 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
|
||||
@usuarie = @site.usuarie? current_usuarie
|
||||
|
||||
# Orden descendiente por número y luego por fecha
|
||||
@posts.sort_by!(:order, :date).reverse!
|
||||
end
|
||||
# Filtrar los posts que les invitades no pueden ver
|
||||
@usuarie = site.usuarie? current_usuarie
|
||||
end
|
||||
|
||||
def show
|
||||
@site = find_site
|
||||
@post = @site.posts(lang: locale).find params[:id]
|
||||
|
||||
authorize @post
|
||||
@locale = locale
|
||||
|
||||
fresh_when @post
|
||||
authorize post
|
||||
breadcrumb post.title.value, ''
|
||||
fresh_when post
|
||||
end
|
||||
|
||||
# Genera una previsualización del artículo.
|
||||
#
|
||||
# TODO: No todos los artículos tienen previsualización!
|
||||
def preview
|
||||
@site = find_site
|
||||
@post = @site.posts(lang: locale).find params[:post_id]
|
||||
authorize post
|
||||
|
||||
authorize @post
|
||||
|
||||
render html: @post.render
|
||||
render html: post.render
|
||||
end
|
||||
|
||||
def new
|
||||
authorize Post
|
||||
@site = find_site
|
||||
@post = @site.posts.build(lang: locale, layout: params[:layout])
|
||||
@locale = locale
|
||||
@post = site.posts(lang: locale).build(layout: params[:layout])
|
||||
|
||||
breadcrumb I18n.t('loaf.breadcrumbs.posts.new', layout: @post.layout.humanized_name.downcase), ''
|
||||
end
|
||||
|
||||
def create
|
||||
authorize Post
|
||||
@site = find_site
|
||||
service = PostService.new(site: @site,
|
||||
service = PostService.new(site: site,
|
||||
usuarie: current_usuarie,
|
||||
params: params)
|
||||
@post = service.create
|
||||
|
||||
if @post.persisted?
|
||||
@site.touch
|
||||
site.touch
|
||||
forget_content
|
||||
|
||||
redirect_to site_post_path(@site, @post)
|
||||
|
@ -90,30 +77,24 @@ class PostsController < ApplicationController
|
|||
end
|
||||
|
||||
def edit
|
||||
@site = find_site
|
||||
@post = @site.posts(lang: locale).find params[:id]
|
||||
|
||||
authorize @post
|
||||
|
||||
@locale = locale
|
||||
authorize post
|
||||
breadcrumb post.title.value, site_post_path(site, post, locale: locale), match: :exact
|
||||
breadcrumb 'posts.edit', ''
|
||||
end
|
||||
|
||||
def update
|
||||
@site = find_site
|
||||
@post = @site.posts(lang: locale).find params[:id]
|
||||
authorize post
|
||||
|
||||
authorize @post
|
||||
|
||||
service = PostService.new(site: @site,
|
||||
post: @post,
|
||||
service = PostService.new(site: site,
|
||||
post: post,
|
||||
usuarie: current_usuarie,
|
||||
params: params)
|
||||
|
||||
if service.update.persisted?
|
||||
@site.touch
|
||||
site.touch
|
||||
forget_content
|
||||
|
||||
redirect_to site_post_path(@site, @post)
|
||||
redirect_to site_post_path(site, post)
|
||||
else
|
||||
render 'posts/edit'
|
||||
end
|
||||
|
@ -121,34 +102,30 @@ class PostsController < ApplicationController
|
|||
|
||||
# Eliminar artículos
|
||||
def destroy
|
||||
@site = find_site
|
||||
@post = @site.posts(lang: locale).find params[:id]
|
||||
authorize post
|
||||
|
||||
authorize @post
|
||||
|
||||
service = PostService.new(site: @site,
|
||||
post: @post,
|
||||
service = PostService.new(site: site,
|
||||
post: post,
|
||||
usuarie: current_usuarie,
|
||||
params: params)
|
||||
|
||||
# TODO: Notificar si se pudo o no
|
||||
service.destroy
|
||||
@site.touch
|
||||
redirect_to site_posts_path(@site)
|
||||
site.touch
|
||||
redirect_to site_posts_path(site, locale: post.lang.value)
|
||||
end
|
||||
|
||||
# Reordenar los artículos
|
||||
def reorder
|
||||
@site = find_site
|
||||
authorize @site
|
||||
authorize site
|
||||
|
||||
service = PostService.new(site: @site,
|
||||
service = PostService.new(site: site,
|
||||
usuarie: current_usuarie,
|
||||
params: params)
|
||||
|
||||
service.reorder
|
||||
@site.touch
|
||||
redirect_to site_posts_path(@site)
|
||||
site.touch
|
||||
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||
end
|
||||
|
||||
# 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
|
||||
# _config.yml del sitio en lugar de mezclar idiomas.
|
||||
def locale
|
||||
@site&.locales&.find(-> { I18n.locale }) do |l|
|
||||
@locale ||= site&.locales&.find(-> { site&.default_locale }) do |l|
|
||||
l.to_s == params[:locale]
|
||||
end
|
||||
end
|
||||
|
@ -169,4 +146,24 @@ class PostsController < ApplicationController
|
|||
def forget_content
|
||||
flash[:js] = { target: 'editor', action: 'forget-content', keys: (params[:storage_keys] || []).to_json }
|
||||
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
|
||||
|
|
|
@ -7,6 +7,9 @@ class SitesController < ApplicationController
|
|||
|
||||
before_action :authenticate_usuarie!
|
||||
|
||||
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
|
||||
breadcrumb 'sites.index', :sites_path, match: :exact
|
||||
|
||||
# Ver un listado de sitios
|
||||
def index
|
||||
authorize Site
|
||||
|
@ -20,10 +23,12 @@ class SitesController < ApplicationController
|
|||
def show
|
||||
authorize site
|
||||
|
||||
redirect_to site_posts_path(site)
|
||||
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||
end
|
||||
|
||||
def new
|
||||
breadcrumb 'sites.new', :new_site_path
|
||||
|
||||
@site = Site.new
|
||||
authorize @site
|
||||
|
||||
|
@ -35,7 +40,7 @@ class SitesController < ApplicationController
|
|||
params: site_params)
|
||||
|
||||
if (@site = service.create).persisted?
|
||||
redirect_to site_posts_path(@site)
|
||||
redirect_to site_posts_path(@site, locale: @site.default_locale)
|
||||
else
|
||||
render 'new'
|
||||
end
|
||||
|
@ -43,6 +48,10 @@ class SitesController < ApplicationController
|
|||
|
||||
def edit
|
||||
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
|
||||
end
|
||||
|
||||
|
@ -53,7 +62,7 @@ class SitesController < ApplicationController
|
|||
usuarie: current_usuarie)
|
||||
|
||||
if service.update.valid?
|
||||
redirect_to site_posts_path(site)
|
||||
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||
else
|
||||
render 'edit'
|
||||
end
|
||||
|
@ -63,9 +72,10 @@ class SitesController < ApplicationController
|
|||
authorize site
|
||||
|
||||
# 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
|
||||
|
||||
def reorder_posts
|
||||
|
@ -85,7 +95,7 @@ class SitesController < ApplicationController
|
|||
flash[:danger] = I18n.t('errors.posts.reorder')
|
||||
end
|
||||
|
||||
redirect_to site_posts_path(site)
|
||||
redirect_to site_posts_path(site, locale: site.default_locale)
|
||||
end
|
||||
|
||||
def fetch
|
||||
|
@ -97,7 +107,7 @@ class SitesController < ApplicationController
|
|||
def merge
|
||||
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')
|
||||
else
|
||||
flash[:error] = I18n.t('sites.fetch.merge.error')
|
||||
|
|
|
@ -3,16 +3,166 @@
|
|||
# Estadísticas del sitio
|
||||
class StatsController < ApplicationController
|
||||
include Pundit
|
||||
include ActionView::Helpers::DateHelper
|
||||
|
||||
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
|
||||
@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
|
||||
authorize SiteStat.new(@site)
|
||||
end
|
||||
|
||||
# Solo queremos el promedio de tiempo de compilación, no de
|
||||
# instalación de dependencias.
|
||||
stats = @site.build_stats.jekyll
|
||||
@build_avg = stats.average(:seconds).to_f.round(2)
|
||||
@build_max = stats.maximum(:seconds).to_f.round(2)
|
||||
# TODO: Eliminar cuando mergeemos referer-origin
|
||||
def hostnames
|
||||
@hostnames ||= [@site.hostname, @site.alternative_hostnames].flatten
|
||||
end
|
||||
|
||||
# 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
|
||||
|
|
|
@ -7,12 +7,18 @@ class UsuariesController < ApplicationController
|
|||
include Pundit
|
||||
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
|
||||
def index
|
||||
@site = find_site
|
||||
site_usuarie = SiteUsuarie.new(@site, current_usuarie)
|
||||
site_usuarie = SiteUsuarie.new(site, current_usuarie)
|
||||
authorize site_usuarie
|
||||
|
||||
breadcrumb 'usuaries.index', ''
|
||||
|
||||
@policy = policy(site_usuarie)
|
||||
end
|
||||
|
||||
|
@ -156,4 +162,8 @@ class UsuariesController < ApplicationController
|
|||
'invitade'
|
||||
end
|
||||
end
|
||||
|
||||
def site
|
||||
@site ||= find_site
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,18 +1,23 @@
|
|||
import { storeContent, restoreContent, forgetContent } from 'editor/storage'
|
||||
import { storeContent, restoreContent, forgetContent } from "editor/storage";
|
||||
import {
|
||||
isDirectChild, moveChildren, safeGetSelection, safeGetRangeAt,
|
||||
setAuxiliaryToolbar, parentBlockNames, clearSelected,
|
||||
} 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'
|
||||
isDirectChild,
|
||||
moveChildren,
|
||||
safeGetSelection,
|
||||
safeGetRangeAt,
|
||||
setAuxiliaryToolbar,
|
||||
parentBlockNames,
|
||||
clearSelected,
|
||||
} 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 {
|
||||
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
|
||||
setupButtons as setupMultimediaButtons,
|
||||
} from 'editor/types/multimedia'
|
||||
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from 'editor/types/mark'
|
||||
setupAuxiliaryToolbar as setupMultimediaAuxiliaryToolbar,
|
||||
setupButtons as setupMultimediaButtons,
|
||||
} from "editor/types/multimedia";
|
||||
import { setupAuxiliaryToolbar as setupMarkAuxiliaryToolbar } from "editor/types/mark";
|
||||
|
||||
// Esta funcion corrije errores que pueden haber como:
|
||||
// * 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>
|
||||
// Lo hace para que siga la estructura del documento y que no se borren por
|
||||
// cleanContent luego.
|
||||
function fixContent (editor: Editor, node: Element = editor.contentEl): void {
|
||||
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
|
||||
node.parentElement?.removeChild(node)
|
||||
return
|
||||
}
|
||||
function fixContent(editor: Editor, node: Element = editor.contentEl): void {
|
||||
if (node.tagName === "SCRIPT" || node.tagName === "STYLE") {
|
||||
node.parentElement?.removeChild(node);
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.tagName === 'I') {
|
||||
const el = document.createElement('em')
|
||||
moveChildren(node, el, null)
|
||||
node.parentElement?.replaceChild(el, node)
|
||||
node = el
|
||||
}
|
||||
if (node.tagName === 'B') {
|
||||
const el = document.createElement('strong')
|
||||
moveChildren(node, el, null)
|
||||
node.parentElement?.replaceChild(el, node)
|
||||
node = el
|
||||
}
|
||||
if (node.tagName === "I") {
|
||||
const el = document.createElement("em");
|
||||
moveChildren(node, el, null);
|
||||
node.parentElement?.replaceChild(el, node);
|
||||
node = el;
|
||||
}
|
||||
if (node.tagName === "B") {
|
||||
const el = document.createElement("strong");
|
||||
moveChildren(node, el, null);
|
||||
node.parentElement?.replaceChild(el, node);
|
||||
node = el;
|
||||
}
|
||||
|
||||
if (node instanceof HTMLImageElement) {
|
||||
node.dataset.multimediaInner = ''
|
||||
const figureEl = types.multimedia.create(editor)
|
||||
if (node instanceof HTMLImageElement) {
|
||||
node.dataset.multimediaInner = "";
|
||||
const figureEl = types.multimedia.create(editor);
|
||||
|
||||
let targetEl = node.parentElement
|
||||
if (!targetEl) throw new Error('No encontré lx objetivo')
|
||||
while (true) {
|
||||
const type = getType(targetEl)
|
||||
if (!type) throw new Error('lx objetivo tiene tipo')
|
||||
if (type.type.allowedChildren.includes('multimedia')) break
|
||||
if (!targetEl.parentElement) throw new Error('No encontré lx objetivo')
|
||||
targetEl = targetEl.parentElement
|
||||
}
|
||||
let targetEl = node.parentElement;
|
||||
if (!targetEl) throw new Error("No encontré lx objetivo");
|
||||
while (true) {
|
||||
const type = getType(targetEl);
|
||||
if (!type) throw new Error("lx objetivo tiene tipo");
|
||||
if (type.type.allowedChildren.includes("multimedia")) break;
|
||||
if (!targetEl.parentElement) throw new Error("No encontré lx objetivo");
|
||||
targetEl = targetEl.parentElement;
|
||||
}
|
||||
|
||||
let parentEl = [...targetEl.childNodes].find(
|
||||
el => el.contains(node)
|
||||
)
|
||||
if (!parentEl) throw new Error('no encontré lx pariente')
|
||||
targetEl.insertBefore(figureEl, parentEl)
|
||||
let parentEl = [...targetEl.childNodes].find((el) => el.contains(node));
|
||||
if (!parentEl) throw new Error("no encontré lx pariente");
|
||||
targetEl.insertBefore(figureEl, parentEl);
|
||||
|
||||
const innerEl = figureEl.querySelector('[data-multimedia-inner]')
|
||||
if (!innerEl) throw new Error('Raro.')
|
||||
figureEl.replaceChild(node, innerEl)
|
||||
const innerEl = figureEl.querySelector("[data-multimedia-inner]");
|
||||
if (!innerEl) throw new Error("Raro.");
|
||||
figureEl.replaceChild(node, innerEl);
|
||||
|
||||
node = figureEl
|
||||
}
|
||||
node = figureEl;
|
||||
}
|
||||
|
||||
const _type = getType(node)
|
||||
if (!_type) return
|
||||
const _type = getType(node);
|
||||
if (!_type) return;
|
||||
|
||||
const { typeName, type } = _type
|
||||
const { typeName, type } = _type;
|
||||
|
||||
if (type.allowedChildren !== 'ignore-children') {
|
||||
const sel = safeGetSelection(editor)
|
||||
const range = sel && safeGetRangeAt(sel)
|
||||
if (type.allowedChildren !== "ignore-children") {
|
||||
const sel = safeGetSelection(editor);
|
||||
const range = sel && safeGetRangeAt(sel);
|
||||
|
||||
if (getValidChildren(node, type).length == 0) {
|
||||
if (typeof type.handleEmpty !== 'string') {
|
||||
const el = type.handleEmpty.create(editor)
|
||||
// mover cosas que pueden haber
|
||||
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
|
||||
// creamos acá
|
||||
moveChildren(node, el, null)
|
||||
node.appendChild(el)
|
||||
if (range?.intersectsNode(node))
|
||||
sel?.collapse(el)
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (!(child instanceof Element)) continue
|
||||
fixContent(editor, child)
|
||||
}
|
||||
}
|
||||
if (getValidChildren(node, type).length == 0) {
|
||||
if (typeof type.handleEmpty !== "string") {
|
||||
const el = type.handleEmpty.create(editor);
|
||||
// mover cosas que pueden haber
|
||||
// por ejemplo: cuando convertís a un <ul>, queda texto fuera del li que
|
||||
// creamos acá
|
||||
moveChildren(node, el, null);
|
||||
node.appendChild(el);
|
||||
if (range?.intersectsNode(node)) sel?.collapse(el);
|
||||
}
|
||||
}
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (!(child instanceof Element)) continue;
|
||||
fixContent(editor, child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
// * no borramos los <br> por que se requieren para que los navegadores
|
||||
// funcionen bien al escribir. no se deberían mostrar de todas maneras
|
||||
function cleanContent (editor: Editor, node: Element = editor.contentEl): void {
|
||||
const _type = getType(node)
|
||||
if (!_type) {
|
||||
node.parentElement?.removeChild(node)
|
||||
return
|
||||
}
|
||||
function cleanContent(editor: Editor, node: Element = editor.contentEl): void {
|
||||
const _type = getType(node);
|
||||
if (!_type) {
|
||||
node.parentElement?.removeChild(node);
|
||||
return;
|
||||
}
|
||||
|
||||
const { type } = _type
|
||||
const { type } = _type;
|
||||
|
||||
if (type.allowedChildren !== 'ignore-children') {
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE
|
||||
&& !type.allowedChildren.includes('text')
|
||||
) {
|
||||
node.removeChild(child)
|
||||
continue
|
||||
}
|
||||
if (type.allowedChildren !== "ignore-children") {
|
||||
for (const child of node.childNodes) {
|
||||
if (
|
||||
child.nodeType === Node.TEXT_NODE &&
|
||||
!type.allowedChildren.includes("text")
|
||||
) {
|
||||
node.removeChild(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!(child instanceof Element)) continue
|
||||
if (!(child instanceof Element)) continue;
|
||||
|
||||
const childType = getType(child)
|
||||
if (childType?.typeName === 'br') continue
|
||||
if (!childType || !type.allowedChildren.includes(childType.typeName)) {
|
||||
// XXX: esto extrae las cosas de adentro para que no sea destructivo
|
||||
moveChildren(child, node, child)
|
||||
node.removeChild(child)
|
||||
return
|
||||
}
|
||||
const childType = getType(child);
|
||||
if (childType?.typeName === "br") continue;
|
||||
if (!childType || !type.allowedChildren.includes(childType.typeName)) {
|
||||
// XXX: esto extrae las cosas de adentro para que no sea destructivo
|
||||
moveChildren(child, node, child);
|
||||
node.removeChild(child);
|
||||
return;
|
||||
}
|
||||
|
||||
cleanContent(editor, child)
|
||||
}
|
||||
cleanContent(editor, child);
|
||||
}
|
||||
|
||||
// solo contar children válido para ese nodo
|
||||
const validChildrenLength = getValidChildren(node, type).length
|
||||
// solo contar children válido para ese nodo
|
||||
const validChildrenLength = getValidChildren(node, type).length;
|
||||
|
||||
const sel = safeGetSelection(editor)
|
||||
const range = sel && safeGetRangeAt(sel)
|
||||
if (type.handleEmpty === 'remove'
|
||||
&& validChildrenLength == 0
|
||||
//&& (!range || !range.intersectsNode(node))
|
||||
) {
|
||||
node.parentNode?.removeChild(node)
|
||||
return
|
||||
}
|
||||
}
|
||||
const sel = safeGetSelection(editor);
|
||||
const range = sel && safeGetRangeAt(sel);
|
||||
if (
|
||||
type.handleEmpty === "remove" &&
|
||||
validChildrenLength == 0
|
||||
//&& (!range || !range.intersectsNode(node))
|
||||
) {
|
||||
node.parentNode?.removeChild(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routine (editor: Editor): void {
|
||||
try {
|
||||
fixContent(editor)
|
||||
cleanContent(editor)
|
||||
storeContent(editor)
|
||||
function routine(editor: Editor): void {
|
||||
try {
|
||||
fixContent(editor);
|
||||
cleanContent(editor);
|
||||
storeContent(editor);
|
||||
|
||||
editor.htmlEl.value = editor.contentEl.innerHTML
|
||||
} catch (error) {
|
||||
console.error('Hubo un problema corriendo la rutina', editor, error)
|
||||
}
|
||||
editor.htmlEl.value = editor.contentEl.innerHTML;
|
||||
} catch (error) {
|
||||
console.error("Hubo un problema corriendo la rutina", editor, error);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Editor {
|
||||
editorEl: HTMLElement,
|
||||
toolbarEl: HTMLElement,
|
||||
toolbar: {
|
||||
auxiliary: {
|
||||
mark: {
|
||||
parentEl: HTMLElement,
|
||||
colorEl: HTMLInputElement,
|
||||
},
|
||||
multimedia: {
|
||||
parentEl: HTMLElement,
|
||||
fileEl: HTMLInputElement,
|
||||
uploadEl: HTMLButtonElement,
|
||||
altEl: HTMLInputElement,
|
||||
removeEl: HTMLButtonElement,
|
||||
},
|
||||
link: {
|
||||
parentEl: HTMLElement,
|
||||
urlEl: HTMLInputElement,
|
||||
},
|
||||
},
|
||||
},
|
||||
contentEl: HTMLElement,
|
||||
wordAlertEl: HTMLElement,
|
||||
htmlEl: HTMLTextAreaElement,
|
||||
editorEl: HTMLElement;
|
||||
toolbarEl: HTMLElement;
|
||||
toolbar: {
|
||||
auxiliary: {
|
||||
mark: {
|
||||
parentEl: HTMLElement;
|
||||
colorEl: HTMLInputElement;
|
||||
textColorEl: HTMLInputElement;
|
||||
};
|
||||
multimedia: {
|
||||
parentEl: HTMLElement;
|
||||
fileEl: HTMLInputElement;
|
||||
uploadEl: HTMLButtonElement;
|
||||
altEl: HTMLInputElement;
|
||||
removeEl: HTMLButtonElement;
|
||||
};
|
||||
link: {
|
||||
parentEl: HTMLElement;
|
||||
urlEl: HTMLInputElement;
|
||||
};
|
||||
};
|
||||
};
|
||||
contentEl: HTMLElement;
|
||||
wordAlertEl: HTMLElement;
|
||||
htmlEl: HTMLTextAreaElement;
|
||||
}
|
||||
|
||||
function getSel<T extends Element>(parentEl: HTMLElement, selector: string): T {
|
||||
const el = parentEl.querySelector<T>(selector)
|
||||
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``)
|
||||
return el
|
||||
const el = parentEl.querySelector<T>(selector);
|
||||
if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``);
|
||||
return el;
|
||||
}
|
||||
|
||||
function setupEditor (editorEl: HTMLElement): void {
|
||||
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
|
||||
document.execCommand('defaultParagraphSeparator', false, 'p')
|
||||
function setupEditor(editorEl: HTMLElement): void {
|
||||
// XXX: ¡Esto afecta a todo el documento! ¿Quizás usar un iframe para el editor?
|
||||
document.execCommand("defaultParagraphSeparator", false, "p");
|
||||
|
||||
const editor: Editor = {
|
||||
editorEl,
|
||||
toolbarEl: getSel(editorEl, '.editor-toolbar'),
|
||||
toolbar: {
|
||||
auxiliary: {
|
||||
mark: {
|
||||
parentEl: getSel(editorEl, '[data-editor-auxiliary=mark]'),
|
||||
colorEl: getSel(editorEl, '[data-editor-auxiliary=mark] [name=mark-color]'),
|
||||
},
|
||||
multimedia: {
|
||||
parentEl: getSel(editorEl, '[data-editor-auxiliary=multimedia]'),
|
||||
fileEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file]'),
|
||||
uploadEl: getSel(editorEl, '[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]'),
|
||||
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)
|
||||
const editor: Editor = {
|
||||
editorEl,
|
||||
toolbarEl: getSel(editorEl, ".editor-toolbar"),
|
||||
toolbar: {
|
||||
auxiliary: {
|
||||
mark: {
|
||||
parentEl: getSel(editorEl, "[data-editor-auxiliary=mark]"),
|
||||
colorEl: getSel(
|
||||
editorEl,
|
||||
"[data-editor-auxiliary=mark] [name=mark-color]"
|
||||
),
|
||||
textColorEl: getSel(
|
||||
editorEl,
|
||||
"[data-editor-auxiliary=mark] [name=mark-text-color]"
|
||||
),
|
||||
},
|
||||
multimedia: {
|
||||
parentEl: getSel(editorEl, "[data-editor-auxiliary=multimedia]"),
|
||||
fileEl: getSel(
|
||||
editorEl,
|
||||
"[data-editor-auxiliary=multimedia] [name=multimedia-file]"
|
||||
),
|
||||
uploadEl: getSel(
|
||||
editorEl,
|
||||
"[data-editor-auxiliary=multimedia] [name=multimedia-file-upload]"
|
||||
),
|
||||
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
|
||||
// de última edición podríamos saber si el artículo fue editado
|
||||
// después o la versión local es la última.
|
||||
//
|
||||
// TODO: Preguntar si se lo quiere recuperar.
|
||||
restoreContent(editor)
|
||||
// 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
|
||||
// después o la versión local es la última.
|
||||
//
|
||||
// TODO: Preguntar si se lo quiere recuperar.
|
||||
restoreContent(editor);
|
||||
|
||||
// Word alert
|
||||
editor.contentEl.addEventListener('paste', () => {
|
||||
editor.wordAlertEl.style.display = 'block'
|
||||
})
|
||||
// Word alert
|
||||
editor.contentEl.addEventListener("paste", () => {
|
||||
editor.wordAlertEl.style.display = "block";
|
||||
});
|
||||
|
||||
// Setup routine listeners
|
||||
const observer = new MutationObserver(() => routine(editor))
|
||||
observer.observe(editor.contentEl, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
})
|
||||
// Setup routine listeners
|
||||
const observer = new MutationObserver(() => routine(editor));
|
||||
observer.observe(editor.contentEl, {
|
||||
childList: true,
|
||||
attributes: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
document.addEventListener("selectionchange", () => routine(editor))
|
||||
document.addEventListener("selectionchange", () => routine(editor));
|
||||
|
||||
// Capture onClick
|
||||
editor.contentEl.addEventListener('click', event => {
|
||||
const target = event.target! as Element
|
||||
const type = getType(target)
|
||||
if (!type || !type.type.onClick) {
|
||||
setAuxiliaryToolbar(editor, null)
|
||||
clearSelected(editor)
|
||||
return true
|
||||
}
|
||||
type.type.onClick(editor, target)
|
||||
return false
|
||||
}, true)
|
||||
// Capture onClick
|
||||
editor.contentEl.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target! as Element;
|
||||
const type = getType(target);
|
||||
if (!type || !type.type.onClick) {
|
||||
setAuxiliaryToolbar(editor, null);
|
||||
clearSelected(editor);
|
||||
return true;
|
||||
}
|
||||
type.type.onClick(editor, target);
|
||||
return false;
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Clean seleted
|
||||
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
|
||||
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
|
||||
// Clean seleted
|
||||
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
|
||||
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
|
||||
|
||||
// Setup botones
|
||||
setupMarksButtons(editor)
|
||||
setupBlocksButtons(editor)
|
||||
setupParentBlocksButtons(editor)
|
||||
setupMultimediaButtons(editor)
|
||||
// Setup botones
|
||||
setupMarksButtons(editor);
|
||||
setupBlocksButtons(editor);
|
||||
setupParentBlocksButtons(editor);
|
||||
setupMultimediaButtons(editor);
|
||||
|
||||
setupLinkAuxiliaryToolbar(editor)
|
||||
setupMultimediaAuxiliaryToolbar(editor)
|
||||
setupMarkAuxiliaryToolbar(editor)
|
||||
setupLinkAuxiliaryToolbar(editor);
|
||||
setupMultimediaAuxiliaryToolbar(editor);
|
||||
setupMarkAuxiliaryToolbar(editor);
|
||||
|
||||
// Finally...
|
||||
routine(editor)
|
||||
// Finally...
|
||||
routine(editor);
|
||||
}
|
||||
|
||||
document.addEventListener("turbolinks:load", () => {
|
||||
const flash = document.querySelector<HTMLElement>('.js-flash')
|
||||
const flash = document.querySelector<HTMLElement>(".js-flash");
|
||||
|
||||
if (flash) {
|
||||
const keys = JSON.parse(flash.dataset.keys || '[]')
|
||||
if (flash) {
|
||||
const keys = JSON.parse(flash.dataset.keys || "[]");
|
||||
|
||||
switch (flash.dataset.target) {
|
||||
case 'editor':
|
||||
switch (flash.dataset.action) {
|
||||
case 'forget-content':
|
||||
keys.forEach(forgetContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (flash.dataset.target) {
|
||||
case "editor":
|
||||
switch (flash.dataset.action) {
|
||||
case "forget-content":
|
||||
keys.forEach(forgetContent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const editorEl of document.querySelectorAll<HTMLElement>('.editor[data-editor]')) {
|
||||
try {
|
||||
setupEditor(editorEl)
|
||||
} catch (error) {
|
||||
// TODO: mostrar error
|
||||
console.error('no se pudo iniciar el editor, error completo', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
for (const editorEl of document.querySelectorAll<HTMLElement>(
|
||||
".editor[data-editor]"
|
||||
)) {
|
||||
try {
|
||||
setupEditor(editorEl);
|
||||
} 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
|
||||
|
@ -6,27 +6,33 @@ import { Editor } from 'editor/editor'
|
|||
*
|
||||
* Usamos la URL completa sin anchors.
|
||||
*/
|
||||
function getStorageKey (editor: Editor): string {
|
||||
const keyEl = editor.editorEl.querySelector<HTMLInputElement>('[data-target="storage-key"]')
|
||||
if (!keyEl) throw new Error('No encuentro la llave para guardar los artículos')
|
||||
return keyEl.value
|
||||
function getStorageKey(editor: Editor): string {
|
||||
const keyEl = editor.editorEl.querySelector<HTMLInputElement>(
|
||||
'[data-target="storage-key"]'
|
||||
);
|
||||
if (!keyEl)
|
||||
throw new Error("No encuentro la llave para guardar los artículos");
|
||||
return keyEl.value;
|
||||
}
|
||||
|
||||
export function forgetContent (storedKey: string): void {
|
||||
window.localStorage.removeItem(storedKey)
|
||||
export function forgetContent(storedKey: string): void {
|
||||
window.localStorage.removeItem(storedKey);
|
||||
}
|
||||
|
||||
export function storeContent (editor: Editor): void {
|
||||
if (editor.contentEl.innerText.trim().length === 0) return
|
||||
export function storeContent(editor: Editor): void {
|
||||
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 {
|
||||
const content = window.localStorage.getItem(getStorageKey(editor))
|
||||
export function restoreContent(editor: Editor): void {
|
||||
const content = window.localStorage.getItem(getStorageKey(editor));
|
||||
|
||||
if (!content) return
|
||||
if (content.trim().length === 0) return
|
||||
if (!content) 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 { marks } from 'editor/types/marks'
|
||||
import { blocks, li, EditorBlock } from 'editor/types/blocks'
|
||||
import { parentBlocks } from 'editor/types/parentBlocks'
|
||||
import { multimedia } from 'editor/types/multimedia'
|
||||
import { blockNames, parentBlockNames, safeGetRangeAt, safeGetSelection } from 'editor/utils'
|
||||
import { Editor } from "editor/editor";
|
||||
import { marks } from "editor/types/marks";
|
||||
import { blocks, li, EditorBlock } from "editor/types/blocks";
|
||||
import { parentBlocks } from "editor/types/parentBlocks";
|
||||
import { multimedia } from "editor/types/multimedia";
|
||||
import {
|
||||
blockNames,
|
||||
parentBlockNames,
|
||||
safeGetRangeAt,
|
||||
safeGetSelection,
|
||||
} from "editor/utils";
|
||||
|
||||
export interface EditorNode {
|
||||
selector: string,
|
||||
// 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,
|
||||
// quizás podemos hacer que esto sea una función que retorna bool
|
||||
allowedChildren: string[] | 'ignore-children',
|
||||
selector: string;
|
||||
// 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,
|
||||
// quizás podemos hacer que esto sea una función que retorna bool
|
||||
allowedChildren: string[] | "ignore-children";
|
||||
|
||||
// * 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)
|
||||
// * si es 'remove', sacamos el coso si está vacío.
|
||||
// ej: strong: { handleNothing: 'remove' }
|
||||
// * si es un block, insertamos el bloque y movemos la selección ahí
|
||||
// ej: ul: { handleNothing: li }
|
||||
handleEmpty: 'do-nothing' | 'remove' | EditorBlock,
|
||||
// * 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)
|
||||
// * si es 'remove', sacamos el coso si está vacío.
|
||||
// ej: strong: { handleNothing: 'remove' }
|
||||
// * si es un block, insertamos el bloque y movemos la selección ahí
|
||||
// ej: ul: { handleNothing: li }
|
||||
handleEmpty: "do-nothing" | "remove" | EditorBlock;
|
||||
|
||||
// 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
|
||||
// el formato. esto es importante por que, por ejemplo, no deberíamos
|
||||
// cambiar la selección acá.
|
||||
create: (editor: Editor) => HTMLElement,
|
||||
// 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
|
||||
// el formato. esto es importante por que, por ejemplo, no deberíamos
|
||||
// cambiar la selección acá.
|
||||
create: (editor: Editor) => HTMLElement;
|
||||
|
||||
onClick?: (editor: Editor, target: Element) => void,
|
||||
onClick?: (editor: Editor, target: Element) => void;
|
||||
}
|
||||
|
||||
export const types: { [propName: string]: EditorNode } = {
|
||||
...marks,
|
||||
...blocks,
|
||||
li,
|
||||
...parentBlocks,
|
||||
contentEl: {
|
||||
selector: '.editor-content',
|
||||
allowedChildren: [...blockNames, ...parentBlockNames, 'multimedia'],
|
||||
handleEmpty: blocks.paragraph,
|
||||
create: () => { throw new Error('se intentó crear contentEl') }
|
||||
},
|
||||
br: {
|
||||
selector: 'br',
|
||||
allowedChildren: [],
|
||||
handleEmpty: 'do-nothing',
|
||||
create: () => { throw new Error('se intentó crear br') }
|
||||
},
|
||||
multimedia,
|
||||
}
|
||||
...marks,
|
||||
...blocks,
|
||||
li,
|
||||
...parentBlocks,
|
||||
contentEl: {
|
||||
selector: ".editor-content",
|
||||
allowedChildren: [...blockNames, ...parentBlockNames, "multimedia"],
|
||||
handleEmpty: blocks.paragraph,
|
||||
create: () => {
|
||||
throw new Error("se intentó crear contentEl");
|
||||
},
|
||||
},
|
||||
br: {
|
||||
selector: "br",
|
||||
allowedChildren: [],
|
||||
handleEmpty: "do-nothing",
|
||||
create: () => {
|
||||
throw new Error("se intentó crear br");
|
||||
},
|
||||
},
|
||||
multimedia,
|
||||
};
|
||||
|
||||
export function getType (node: Element): { typeName: string, type: EditorNode } | null {
|
||||
for (let [typeName, type] of Object.entries(types)) {
|
||||
if (node.matches(type.selector)) {
|
||||
return { typeName, type }
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
export function getType(
|
||||
node: Element
|
||||
): { typeName: string; type: EditorNode } | null {
|
||||
for (let [typeName, type] of Object.entries(types)) {
|
||||
if (node.matches(type.selector)) {
|
||||
return { typeName, type };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// encuentra el primer pariente que pueda tener al type, y retorna un array
|
||||
// donde
|
||||
// array[0] = elemento que matchea el type
|
||||
// array[array.len - 1] = primer elemento seleccionado
|
||||
export function getValidParentInSelection (args: {
|
||||
editor: Editor,
|
||||
type: string,
|
||||
export function getValidParentInSelection(args: {
|
||||
editor: Editor;
|
||||
type: string;
|
||||
}): Element[] {
|
||||
const sel = safeGetSelection(args.editor)
|
||||
if (!sel) throw new Error('No se donde insertar esto')
|
||||
const range = safeGetRangeAt(sel)
|
||||
if (!range) throw new Error('No se donde insertar esto')
|
||||
const sel = safeGetSelection(args.editor);
|
||||
if (!sel) throw new Error("No se donde insertar esto");
|
||||
const range = safeGetRangeAt(sel);
|
||||
if (!range) throw new Error("No se donde insertar esto");
|
||||
|
||||
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')
|
||||
}
|
||||
let list: Element[] = [];
|
||||
|
||||
while (true) {
|
||||
const el = list[0]
|
||||
if (!args.editor.contentEl.contains(el)
|
||||
&& el != args.editor.contentEl)
|
||||
throw new Error('No se donde insertar esto')
|
||||
const type = getType(el)
|
||||
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");
|
||||
}
|
||||
|
||||
if (type) {
|
||||
//if (type.typeName === 'contentEl') break
|
||||
//if (parentBlockNames.includes(type.typeName)) break
|
||||
if ((type.type.allowedChildren instanceof Array)
|
||||
&& type.type.allowedChildren.includes(args.type)) break
|
||||
}
|
||||
if (el.parentElement) {
|
||||
list = [el.parentElement, ...list]
|
||||
} else {
|
||||
throw new Error('No se donde insertar esto')
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
while (true) {
|
||||
const el = list[0];
|
||||
if (!args.editor.contentEl.contains(el) && el != args.editor.contentEl)
|
||||
throw new Error("No se donde insertar esto");
|
||||
const type = getType(el);
|
||||
|
||||
if (type) {
|
||||
//if (type.typeName === 'contentEl') break
|
||||
//if (parentBlockNames.includes(type.typeName)) break
|
||||
if (
|
||||
type.type.allowedChildren instanceof Array &&
|
||||
type.type.allowedChildren.includes(args.type)
|
||||
)
|
||||
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[] {
|
||||
if (type.allowedChildren === 'ignore-children')
|
||||
throw new Error('se llamó a getValidChildren con un type que no lo permite!')
|
||||
return [...node.childNodes].filter(n => {
|
||||
// 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
|
||||
export function getValidChildren(node: Element, type: EditorNode): Node[] {
|
||||
if (type.allowedChildren === "ignore-children")
|
||||
throw new Error(
|
||||
"se llamó a getValidChildren con un type que no lo permite!"
|
||||
);
|
||||
return [...node.childNodes].filter((n) => {
|
||||
// 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
|
||||
if (!(n instanceof Element))
|
||||
return false
|
||||
// si no es un elemento, no es válido
|
||||
if (!(n instanceof Element)) return false;
|
||||
|
||||
const t = getType(n)
|
||||
if (!t) return false
|
||||
return type.allowedChildren.includes(t.typeName)
|
||||
})
|
||||
const t = getType(n);
|
||||
if (!t) return false;
|
||||
return type.allowedChildren.includes(t.typeName);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,72 +1,76 @@
|
|||
import { Editor } from 'editor/editor'
|
||||
import { Editor } from "editor/editor";
|
||||
import {
|
||||
safeGetSelection, safeGetRangeAt,
|
||||
moveChildren,
|
||||
markNames, blockNames, parentBlockNames,
|
||||
} from 'editor/utils'
|
||||
import { EditorNode, getType, getValidParentInSelection } from 'editor/types'
|
||||
safeGetSelection,
|
||||
safeGetRangeAt,
|
||||
moveChildren,
|
||||
markNames,
|
||||
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 {
|
||||
return {
|
||||
selector: tag,
|
||||
allowedChildren: [...markNames, 'text'],
|
||||
handleEmpty: 'do-nothing',
|
||||
create: () => document.createElement(tag),
|
||||
}
|
||||
}
|
||||
|
||||
export const li: EditorBlock = makeBlock('li')
|
||||
export const li: EditorBlock = makeBlock("li");
|
||||
|
||||
// XXX: si agregás algo acá, agregalo a blockNames
|
||||
// (y probablemente le quieras hacer un botón en app/views/posts/attributes/_content.haml)
|
||||
export const blocks: { [propName: string]: EditorBlock } = {
|
||||
paragraph: makeBlock('p'),
|
||||
h1: makeBlock('h1'),
|
||||
h2: makeBlock('h2'),
|
||||
h3: makeBlock('h3'),
|
||||
h4: makeBlock('h4'),
|
||||
h5: makeBlock('h5'),
|
||||
h6: makeBlock('h6'),
|
||||
unordered_list: {
|
||||
...makeBlock('ul'),
|
||||
allowedChildren: ['li'],
|
||||
handleEmpty: li,
|
||||
},
|
||||
ordered_list: {
|
||||
...makeBlock('ol'),
|
||||
allowedChildren: ['li'],
|
||||
handleEmpty: li,
|
||||
},
|
||||
}
|
||||
|
||||
export function setupButtons (editor: Editor): void {
|
||||
for (const [ name, type ] of Object.entries(blocks)) {
|
||||
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="block-${name}"]`)
|
||||
if (!buttonEl) continue
|
||||
buttonEl.addEventListener("click", event => {
|
||||
event.preventDefault()
|
||||
|
||||
const list = getValidParentInSelection({ editor, type: name })
|
||||
|
||||
// No borrar cosas como multimedia
|
||||
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
let replacementType = list[1].matches(type.selector)
|
||||
? blocks.paragraph
|
||||
: type
|
||||
|
||||
const el = replacementType.create(editor)
|
||||
replacementType.onClick && replacementType.onClick(editor, el)
|
||||
moveChildren(list[1], el, null)
|
||||
list[0].replaceChild(el, list[1])
|
||||
window.getSelection()?.collapse(el)
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
paragraph: makeBlock("p"),
|
||||
h1: makeBlock("h1"),
|
||||
h2: makeBlock("h2"),
|
||||
h3: makeBlock("h3"),
|
||||
h4: makeBlock("h4"),
|
||||
h5: makeBlock("h5"),
|
||||
h6: makeBlock("h6"),
|
||||
unordered_list: {
|
||||
...makeBlock("ul"),
|
||||
allowedChildren: ["li"],
|
||||
handleEmpty: li,
|
||||
},
|
||||
ordered_list: {
|
||||
...makeBlock("ol"),
|
||||
allowedChildren: ["li"],
|
||||
handleEmpty: li,
|
||||
},
|
||||
};
|
||||
|
||||
export function setupButtons(editor: Editor): void {
|
||||
for (const [name, type] of Object.entries(blocks)) {
|
||||
const buttonEl = editor.toolbarEl.querySelector(
|
||||
`[data-editor-button="block-${name}"]`
|
||||
);
|
||||
if (!buttonEl) continue;
|
||||
buttonEl.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const list = getValidParentInSelection({ editor, type: name });
|
||||
|
||||
// No borrar cosas como multimedia
|
||||
if (blockNames.indexOf(getType(list[1])!.typeName) === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let replacementType = list[1].matches(type.selector)
|
||||
? blocks.paragraph
|
||||
: type;
|
||||
|
||||
const el = replacementType.create(editor);
|
||||
replacementType.onClick && replacementType.onClick(editor, el);
|
||||
moveChildren(list[1], el, null);
|
||||
list[0].replaceChild(el, list[1]);
|
||||
window.getSelection()?.collapse(el);
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,37 +1,37 @@
|
|||
import { Editor } from 'editor/editor'
|
||||
import { EditorNode } from 'editor/types'
|
||||
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils'
|
||||
import { Editor } from "editor/editor";
|
||||
import { EditorNode } from "editor/types";
|
||||
import { markNames, setAuxiliaryToolbar, clearSelected } from "editor/utils";
|
||||
|
||||
function select (editor: Editor, el: HTMLAnchorElement): void {
|
||||
clearSelected(editor)
|
||||
el.dataset.editorSelected = ''
|
||||
editor.toolbar.auxiliary.link.urlEl.value = el.href
|
||||
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl)
|
||||
function select(editor: Editor, el: HTMLAnchorElement): void {
|
||||
clearSelected(editor);
|
||||
el.dataset.editorSelected = "";
|
||||
editor.toolbar.auxiliary.link.urlEl.value = el.href;
|
||||
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.link.parentEl);
|
||||
}
|
||||
|
||||
export const link: EditorNode = {
|
||||
selector: 'a',
|
||||
allowedChildren: [...markNames.filter(n => n !== 'link'), 'text'],
|
||||
handleEmpty: 'remove',
|
||||
create: () => document.createElement('a'),
|
||||
onClick (editor, el) {
|
||||
if (!(el instanceof HTMLAnchorElement))
|
||||
throw new Error('oh no')
|
||||
select(editor, el)
|
||||
},
|
||||
}
|
||||
selector: "a",
|
||||
allowedChildren: [...markNames.filter((n) => n !== "link"), "text"],
|
||||
handleEmpty: "remove",
|
||||
create: () => document.createElement("a"),
|
||||
onClick(editor, el) {
|
||||
if (!(el instanceof HTMLAnchorElement)) throw new Error("oh no");
|
||||
select(editor, el);
|
||||
},
|
||||
};
|
||||
|
||||
export function setupAuxiliaryToolbar (editor: Editor): void {
|
||||
editor.toolbar.auxiliary.link.urlEl.addEventListener('input', event => {
|
||||
const url = editor.toolbar.auxiliary.link.urlEl.value
|
||||
const selectedEl = editor.contentEl
|
||||
.querySelector<HTMLAnchorElement>('a[data-editor-selected]')
|
||||
if (!selectedEl)
|
||||
throw new Error('No pude encontrar el link para setear el enlace')
|
||||
|
||||
selectedEl.href = url
|
||||
})
|
||||
editor.toolbar.auxiliary.link.urlEl.addEventListener('keydown', event => {
|
||||
if (event.keyCode == 13) event.preventDefault()
|
||||
})
|
||||
export function setupAuxiliaryToolbar(editor: Editor): void {
|
||||
editor.toolbar.auxiliary.link.urlEl.addEventListener("input", (event) => {
|
||||
const url = editor.toolbar.auxiliary.link.urlEl.value;
|
||||
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
|
||||
"a[data-editor-selected]"
|
||||
);
|
||||
if (!selectedEl)
|
||||
throw new Error("No pude encontrar el link para setear el enlace");
|
||||
|
||||
selectedEl.href = url;
|
||||
});
|
||||
editor.toolbar.auxiliary.link.urlEl.addEventListener("keydown", (event) => {
|
||||
if (event.keyCode == 13) event.preventDefault();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,49 +1,66 @@
|
|||
import { Editor } from 'editor/editor'
|
||||
import { EditorNode } from 'editor/types'
|
||||
import { markNames, setAuxiliaryToolbar, clearSelected } from 'editor/utils'
|
||||
import { Editor } from "editor/editor";
|
||||
import { EditorNode } from "editor/types";
|
||||
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
|
||||
// TODO: cambiar por una solución más copada
|
||||
function rgbToHex (rgb: string): string {
|
||||
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/)
|
||||
if (!matches) throw new Error('no pude parsear el rgb()')
|
||||
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3])
|
||||
function rgbToHex(rgb: string): string {
|
||||
const matches = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
|
||||
if (!matches) throw new Error("no pude parsear el rgb()");
|
||||
return "#" + hex(matches[1]) + hex(matches[2]) + hex(matches[3]);
|
||||
}
|
||||
|
||||
function select (editor: Editor, el: HTMLElement): void {
|
||||
clearSelected(editor)
|
||||
el.dataset.editorSelected = ''
|
||||
editor.toolbar.auxiliary.mark.colorEl.value
|
||||
= el.style.backgroundColor
|
||||
? rgbToHex(el.style.backgroundColor)
|
||||
: '#f206f9'
|
||||
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl)
|
||||
function select(editor: Editor, el: HTMLElement): void {
|
||||
clearSelected(editor);
|
||||
el.dataset.editorSelected = "";
|
||||
editor.toolbar.auxiliary.mark.colorEl.value = el.style.backgroundColor
|
||||
? rgbToHex(el.style.backgroundColor)
|
||||
: "#f206f9";
|
||||
editor.toolbar.auxiliary.mark.textColorEl.value = el.style.color
|
||||
? rgbToHex(el.style.color)
|
||||
: "#000000";
|
||||
setAuxiliaryToolbar(editor, editor.toolbar.auxiliary.mark.parentEl);
|
||||
}
|
||||
|
||||
export const mark: EditorNode = {
|
||||
selector: 'mark',
|
||||
allowedChildren: [...markNames.filter(n => n !== 'mark'), 'text'],
|
||||
handleEmpty: 'remove',
|
||||
create: () => document.createElement('mark'),
|
||||
onClick (editor, el) {
|
||||
if (!(el instanceof HTMLElement))
|
||||
throw new Error('oh no')
|
||||
select(editor, el)
|
||||
},
|
||||
}
|
||||
selector: "mark",
|
||||
allowedChildren: [...markNames.filter((n) => n !== "mark"), "text"],
|
||||
handleEmpty: "remove",
|
||||
create: () => document.createElement("mark"),
|
||||
onClick(editor, el) {
|
||||
if (!(el instanceof HTMLElement)) throw new Error("oh no");
|
||||
select(editor, el);
|
||||
},
|
||||
};
|
||||
|
||||
export function setupAuxiliaryToolbar (editor: Editor): void {
|
||||
editor.toolbar.auxiliary.mark.colorEl.addEventListener('input', event => {
|
||||
const color = editor.toolbar.auxiliary.mark.colorEl.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')
|
||||
|
||||
selectedEl.style.backgroundColor = color
|
||||
})
|
||||
editor.toolbar.auxiliary.mark.colorEl.addEventListener('keydown', event => {
|
||||
if (event.keyCode == 13) event.preventDefault()
|
||||
})
|
||||
export function setupAuxiliaryToolbar(editor: Editor): void {
|
||||
editor.toolbar.auxiliary.mark.colorEl.addEventListener("input", (event) => {
|
||||
const color = editor.toolbar.auxiliary.mark.colorEl.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");
|
||||
|
||||
selectedEl.style.backgroundColor = color;
|
||||
});
|
||||
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 { EditorNode } from 'editor/types'
|
||||
import { Editor } from "editor/editor";
|
||||
import { EditorNode } from "editor/types";
|
||||
import {
|
||||
safeGetSelection, safeGetRangeAt,
|
||||
moveChildren,
|
||||
markNames,
|
||||
} from 'editor/utils'
|
||||
import { link } from 'editor/types/link'
|
||||
import { mark } from 'editor/types/mark'
|
||||
safeGetSelection,
|
||||
safeGetRangeAt,
|
||||
moveChildren,
|
||||
markNames,
|
||||
} from "editor/utils";
|
||||
import { link } from "editor/types/link";
|
||||
import { mark } from "editor/types/mark";
|
||||
|
||||
function makeMark (name: string, tag: string): EditorNode {
|
||||
return {
|
||||
selector: tag,
|
||||
allowedChildren: [...markNames.filter(n => n !== name), 'text'],
|
||||
handleEmpty: 'remove',
|
||||
create: () => document.createElement(tag),
|
||||
}
|
||||
function makeMark(name: string, tag: string): EditorNode {
|
||||
return {
|
||||
selector: tag,
|
||||
allowedChildren: [...markNames.filter((n) => n !== name), "text"],
|
||||
handleEmpty: "remove",
|
||||
create: () => document.createElement(tag),
|
||||
};
|
||||
}
|
||||
|
||||
// XXX: si agregás algo acá, agregalo a markNames
|
||||
export const marks: { [propName: string]: EditorNode } = {
|
||||
bold: makeMark('bold', 'strong'),
|
||||
italic: makeMark('italic', 'em'),
|
||||
deleted: makeMark('deleted', 'del'),
|
||||
underline: makeMark('underline', 'u'),
|
||||
sub: makeMark('sub', 'sub'),
|
||||
super: makeMark('super', 'sup'),
|
||||
mark,
|
||||
link,
|
||||
small: makeMark('small', 'small'),
|
||||
}
|
||||
bold: makeMark("bold", "strong"),
|
||||
italic: makeMark("italic", "em"),
|
||||
deleted: makeMark("deleted", "del"),
|
||||
underline: makeMark("underline", "u"),
|
||||
sub: makeMark("sub", "sub"),
|
||||
super: makeMark("super", "sup"),
|
||||
mark,
|
||||
link,
|
||||
small: makeMark("small", "small"),
|
||||
};
|
||||
|
||||
function recursiveFilterSelection (
|
||||
node: Element,
|
||||
selection: Selection,
|
||||
selector: string,
|
||||
function recursiveFilterSelection(
|
||||
node: Element,
|
||||
selection: Selection,
|
||||
selector: string
|
||||
): Element[] {
|
||||
let output: Element[] = []
|
||||
for (const child of [...node.children]) {
|
||||
if (child.matches(selector)
|
||||
&& selection.containsNode(child)
|
||||
) output.push(child)
|
||||
output = [...output, ...recursiveFilterSelection(child, selection, selector)]
|
||||
}
|
||||
return output
|
||||
let output: Element[] = [];
|
||||
for (const child of [...node.children]) {
|
||||
if (child.matches(selector) && selection.containsNode(child))
|
||||
output.push(child);
|
||||
output = [
|
||||
...output,
|
||||
...recursiveFilterSelection(child, selection, selector),
|
||||
];
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function setupButtons (editor: Editor): void {
|
||||
for (const [ name, type ] of Object.entries(marks)) {
|
||||
const buttonEl = editor.toolbarEl.querySelector(`[data-editor-button="mark-${name}"]`)
|
||||
if (!buttonEl) continue
|
||||
buttonEl.addEventListener("click", event => {
|
||||
event.preventDefault()
|
||||
export function setupButtons(editor: Editor): void {
|
||||
for (const [name, type] of Object.entries(marks)) {
|
||||
const buttonEl = editor.toolbarEl.querySelector(
|
||||
`[data-editor-button="mark-${name}"]`
|
||||
);
|
||||
if (!buttonEl) continue;
|
||||
buttonEl.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const sel = safeGetSelection(editor)
|
||||
if (!sel) return
|
||||
const range = safeGetRangeAt(sel)
|
||||
if (!range) return
|
||||
const sel = safeGetSelection(editor);
|
||||
if (!sel) return;
|
||||
const range = safeGetRangeAt(sel);
|
||||
if (!range) return;
|
||||
|
||||
let parentEl = range.commonAncestorContainer
|
||||
while (!(parentEl instanceof Element)) {
|
||||
if (!parentEl.parentElement) return
|
||||
parentEl = parentEl.parentElement
|
||||
}
|
||||
let parentEl = range.commonAncestorContainer;
|
||||
while (!(parentEl instanceof Element)) {
|
||||
if (!parentEl.parentElement) return;
|
||||
parentEl = parentEl.parentElement;
|
||||
}
|
||||
|
||||
const existingMarks = recursiveFilterSelection(
|
||||
parentEl,
|
||||
sel,
|
||||
type.selector,
|
||||
)
|
||||
console.debug('marks encontradas:', existingMarks)
|
||||
const existingMarks = recursiveFilterSelection(
|
||||
parentEl,
|
||||
sel,
|
||||
type.selector
|
||||
);
|
||||
console.debug("marks encontradas:", existingMarks);
|
||||
|
||||
if (existingMarks.length > 0) {
|
||||
const mark = existingMarks[0]
|
||||
if (!mark.parentElement)
|
||||
throw new Error(':/')
|
||||
moveChildren(mark, mark.parentElement, mark)
|
||||
mark.parentElement.removeChild(mark)
|
||||
} else {
|
||||
if (range.commonAncestorContainer === editor.contentEl)
|
||||
// TODO: mostrar error
|
||||
return console.error("No puedo marcar cosas a través de distintos bloques!")
|
||||
if (existingMarks.length > 0) {
|
||||
const mark = existingMarks[0];
|
||||
if (!mark.parentElement) throw new Error(":/");
|
||||
moveChildren(mark, mark.parentElement, mark);
|
||||
mark.parentElement.removeChild(mark);
|
||||
} else {
|
||||
if (range.commonAncestorContainer === editor.contentEl)
|
||||
// TODO: mostrar error
|
||||
return console.error(
|
||||
"No puedo marcar cosas a través de distintos bloques!"
|
||||
);
|
||||
|
||||
const tagEl = type.create(editor)
|
||||
type.onClick && type.onClick(editor, tagEl)
|
||||
const tagEl = type.create(editor);
|
||||
type.onClick && type.onClick(editor, tagEl);
|
||||
|
||||
tagEl.appendChild(range.extractContents())
|
||||
tagEl.appendChild(range.extractContents());
|
||||
|
||||
range.insertNode(tagEl)
|
||||
range.selectNode(tagEl)
|
||||
}
|
||||
range.insertNode(tagEl);
|
||||
range.selectNode(tagEl);
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,206 +1,230 @@
|
|||
import * as ActiveStorage from '@rails/activestorage'
|
||||
import { Editor } from 'editor/editor'
|
||||
import { EditorNode, getValidParentInSelection } from 'editor/types'
|
||||
import * as ActiveStorage from "@rails/activestorage";
|
||||
import { Editor } from "editor/editor";
|
||||
import { EditorNode, getValidParentInSelection } from "editor/types";
|
||||
import {
|
||||
safeGetSelection, safeGetRangeAt,
|
||||
markNames, parentBlockNames,
|
||||
setAuxiliaryToolbar, clearSelected,
|
||||
} from 'editor/utils'
|
||||
safeGetSelection,
|
||||
safeGetRangeAt,
|
||||
markNames,
|
||||
parentBlockNames,
|
||||
setAuxiliaryToolbar,
|
||||
clearSelected,
|
||||
} from "editor/utils";
|
||||
|
||||
function uploadFile (file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const upload = new ActiveStorage.DirectUpload(
|
||||
file,
|
||||
origin + '/rails/active_storage/direct_uploads',
|
||||
)
|
||||
function uploadFile(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const upload = new ActiveStorage.DirectUpload(
|
||||
file,
|
||||
origin + "/rails/active_storage/direct_uploads"
|
||||
);
|
||||
|
||||
upload.create((error: any, blob: any) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`
|
||||
resolve(url)
|
||||
}
|
||||
})
|
||||
})
|
||||
upload.create((error: any, blob: any) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
const url = `${origin}/rails/active_storage/blobs/${blob.signed_id}/${blob.filename}`;
|
||||
resolve(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getAlt (multimediaInnerEl: HTMLElement): string | null {
|
||||
switch (multimediaInnerEl.tagName) {
|
||||
case 'VIDEO':
|
||||
case 'AUDIO':
|
||||
return multimediaInnerEl.getAttribute('aria-label')
|
||||
case 'IMG':
|
||||
return (multimediaInnerEl as HTMLImageElement).alt
|
||||
case 'IFRAME':
|
||||
return multimediaInnerEl.title
|
||||
default:
|
||||
throw new Error('no pude conseguir el alt')
|
||||
}
|
||||
function getAlt(multimediaInnerEl: HTMLElement): string | null {
|
||||
switch (multimediaInnerEl.tagName) {
|
||||
case "VIDEO":
|
||||
case "AUDIO":
|
||||
return multimediaInnerEl.getAttribute("aria-label");
|
||||
case "IMG":
|
||||
return (multimediaInnerEl as HTMLImageElement).alt;
|
||||
case "IFRAME":
|
||||
return multimediaInnerEl.title;
|
||||
default:
|
||||
throw new Error("no pude conseguir el alt");
|
||||
}
|
||||
}
|
||||
function setAlt (multimediaInnerEl: HTMLElement, value: string): void {
|
||||
switch (multimediaInnerEl.tagName) {
|
||||
case 'VIDEO':
|
||||
case 'AUDIO':
|
||||
multimediaInnerEl.setAttribute('aria-label', value)
|
||||
break
|
||||
case 'IMG':
|
||||
(multimediaInnerEl as HTMLImageElement).alt = value
|
||||
break
|
||||
case 'IFRAME':
|
||||
multimediaInnerEl.title = value
|
||||
break
|
||||
default:
|
||||
throw new Error('no pude setear el alt')
|
||||
}
|
||||
function setAlt(multimediaInnerEl: HTMLElement, value: string): void {
|
||||
switch (multimediaInnerEl.tagName) {
|
||||
case "VIDEO":
|
||||
case "AUDIO":
|
||||
multimediaInnerEl.setAttribute("aria-label", value);
|
||||
break;
|
||||
case "IMG":
|
||||
(multimediaInnerEl as HTMLImageElement).alt = value;
|
||||
break;
|
||||
case "IFRAME":
|
||||
multimediaInnerEl.title = value;
|
||||
break;
|
||||
default:
|
||||
throw new Error("no pude setear el alt");
|
||||
}
|
||||
}
|
||||
|
||||
function select (editor: Editor, el: HTMLElement): void {
|
||||
clearSelected(editor)
|
||||
el.dataset.editorSelected = ''
|
||||
function select(editor: Editor, el: HTMLElement): void {
|
||||
clearSelected(editor);
|
||||
el.dataset.editorSelected = "";
|
||||
|
||||
const innerEl = el.querySelector<HTMLElement>('[data-multimedia-inner]')
|
||||
if (!innerEl) throw new Error('No hay multimedia válida')
|
||||
if (innerEl.tagName === "P") {
|
||||
editor.toolbar.auxiliary.multimedia.altEl.value = "";
|
||||
editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
|
||||
} else {
|
||||
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
|
||||
editor.toolbar.auxiliary.multimedia.altEl.disabled = false;
|
||||
}
|
||||
const innerEl = el.querySelector<HTMLElement>("[data-multimedia-inner]");
|
||||
if (!innerEl) throw new Error("No hay multimedia válida");
|
||||
if (innerEl.tagName === "P") {
|
||||
editor.toolbar.auxiliary.multimedia.altEl.value = "";
|
||||
editor.toolbar.auxiliary.multimedia.altEl.disabled = true;
|
||||
} else {
|
||||
editor.toolbar.auxiliary.multimedia.altEl.value = getAlt(innerEl) || "";
|
||||
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 = {
|
||||
selector: 'figure[data-multimedia]',
|
||||
allowedChildren: 'ignore-children',
|
||||
handleEmpty: 'remove',
|
||||
create: () => {
|
||||
const figureEl = document.createElement('figure')
|
||||
figureEl.dataset.multimedia = ''
|
||||
figureEl.contentEditable = 'false'
|
||||
selector: "figure[data-multimedia]",
|
||||
allowedChildren: "ignore-children",
|
||||
handleEmpty: "remove",
|
||||
create: () => {
|
||||
const figureEl = document.createElement("figure");
|
||||
figureEl.dataset.multimedia = "";
|
||||
figureEl.contentEditable = "false";
|
||||
|
||||
const placeholderEl = document.createElement('p')
|
||||
placeholderEl.dataset.multimediaInner = ''
|
||||
// TODO i18n
|
||||
placeholderEl.append('¡Clickeame para subir un archivo!')
|
||||
figureEl.appendChild(placeholderEl)
|
||||
const placeholderEl = document.createElement("p");
|
||||
placeholderEl.dataset.multimediaInner = "";
|
||||
// TODO i18n
|
||||
placeholderEl.append("¡Clickeame para subir un archivo!");
|
||||
figureEl.appendChild(placeholderEl);
|
||||
|
||||
const descriptionEl = document.createElement('figcaption')
|
||||
descriptionEl.contentEditable = 'true'
|
||||
// TODO i18n
|
||||
descriptionEl.append('Escribí acá la descripción del archivo.')
|
||||
figureEl.appendChild(descriptionEl)
|
||||
const descriptionEl = document.createElement("figcaption");
|
||||
descriptionEl.contentEditable = "true";
|
||||
// TODO i18n
|
||||
descriptionEl.append("Escribí acá la descripción del archivo.");
|
||||
figureEl.appendChild(descriptionEl);
|
||||
|
||||
return figureEl
|
||||
},
|
||||
onClick (editor, el) {
|
||||
if (!(el instanceof HTMLElement))
|
||||
throw new Error('oh no')
|
||||
select(editor, el)
|
||||
},
|
||||
}
|
||||
function createElementWithFile (url: string, type: string): HTMLElement {
|
||||
if (type.match(/^image\/.+$/)) {
|
||||
const el = document.createElement('img')
|
||||
el.dataset.multimediaInner = ''
|
||||
el.src = url
|
||||
return el
|
||||
} else if (type.match(/^video\/.+$/)) {
|
||||
const el = document.createElement('video')
|
||||
el.controls = true
|
||||
el.dataset.multimediaInner = ''
|
||||
el.src = url
|
||||
return el
|
||||
} else if (type.match(/^audio\/.+$/)) {
|
||||
const el = document.createElement('audio')
|
||||
el.controls = true
|
||||
el.dataset.multimediaInner = ''
|
||||
el.src = url
|
||||
return el
|
||||
} else if (type.match(/^application\/pdf$/)) {
|
||||
const el = document.createElement('iframe')
|
||||
el.dataset.multimediaInner = ''
|
||||
el.src = url
|
||||
return el
|
||||
} else {
|
||||
// TODO: chequear si el archivo es válido antes de subir
|
||||
throw new Error('Tipo de archivo no reconocido')
|
||||
}
|
||||
return figureEl;
|
||||
},
|
||||
onClick(editor, el) {
|
||||
if (!(el instanceof HTMLElement)) throw new Error("oh no");
|
||||
select(editor, el);
|
||||
},
|
||||
};
|
||||
function createElementWithFile(url: string, type: string): HTMLElement {
|
||||
if (type.match(/^image\/.+$/)) {
|
||||
const el = document.createElement("img");
|
||||
el.dataset.multimediaInner = "";
|
||||
el.src = url;
|
||||
return el;
|
||||
} else if (type.match(/^video\/.+$/)) {
|
||||
const el = document.createElement("video");
|
||||
el.controls = true;
|
||||
el.dataset.multimediaInner = "";
|
||||
el.src = url;
|
||||
return el;
|
||||
} else if (type.match(/^audio\/.+$/)) {
|
||||
const el = document.createElement("audio");
|
||||
el.controls = true;
|
||||
el.dataset.multimediaInner = "";
|
||||
el.src = url;
|
||||
return el;
|
||||
} else if (type.match(/^application\/pdf$/)) {
|
||||
const el = document.createElement("iframe");
|
||||
el.dataset.multimediaInner = "";
|
||||
el.src = url;
|
||||
return el;
|
||||
} else {
|
||||
// TODO: chequear si el archivo es válido antes de subir
|
||||
throw new Error("Tipo de archivo no reconocido");
|
||||
}
|
||||
}
|
||||
|
||||
export function setupAuxiliaryToolbar (editor: Editor): void {
|
||||
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener('click', event => {
|
||||
const files = editor.toolbar.auxiliary.multimedia.fileEl.files
|
||||
if (!files || !files.length) throw new Error('no hay archivos para subir')
|
||||
const file = files[0]
|
||||
export function setupAuxiliaryToolbar(editor: Editor): void {
|
||||
editor.toolbar.auxiliary.multimedia.uploadEl.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
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
|
||||
.querySelector<HTMLElement>('figure[data-editor-selected]')
|
||||
if (!selectedEl)
|
||||
throw new Error('No pude encontrar el elemento para setear el archivo')
|
||||
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
|
||||
"figure[data-editor-selected]"
|
||||
);
|
||||
if (!selectedEl)
|
||||
throw new Error("No pude encontrar el elemento para setear el archivo");
|
||||
|
||||
selectedEl.dataset.editorLoading = ''
|
||||
uploadFile(file)
|
||||
.then(url => {
|
||||
const innerEl = selectedEl.querySelector('[data-multimedia-inner]')
|
||||
if (!innerEl) throw new Error('No hay multimedia a reemplazar')
|
||||
selectedEl.dataset.editorLoading = "";
|
||||
uploadFile(file)
|
||||
.then((url) => {
|
||||
const innerEl = selectedEl.querySelector("[data-multimedia-inner]");
|
||||
if (!innerEl) throw new Error("No hay multimedia a reemplazar");
|
||||
|
||||
const el = createElementWithFile(url, file.type)
|
||||
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value)
|
||||
selectedEl.replaceChild(el, innerEl)
|
||||
const el = createElementWithFile(url, file.type);
|
||||
setAlt(el, editor.toolbar.auxiliary.multimedia.altEl.value);
|
||||
selectedEl.replaceChild(el, innerEl);
|
||||
|
||||
select(editor, selectedEl)
|
||||
select(editor, selectedEl);
|
||||
|
||||
delete selectedEl.dataset.editorError
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err)
|
||||
// TODO: mostrar error
|
||||
selectedEl.dataset.editorError = ''
|
||||
})
|
||||
.finally(() => { delete selectedEl.dataset.editorLoading })
|
||||
})
|
||||
delete selectedEl.dataset.editorError;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
// TODO: mostrar error
|
||||
selectedEl.dataset.editorError = "";
|
||||
})
|
||||
.finally(() => {
|
||||
delete selectedEl.dataset.editorLoading;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener('click', event => {
|
||||
const selectedEl = editor.contentEl
|
||||
.querySelector<HTMLElement>('figure[data-editor-selected]')
|
||||
if (!selectedEl)
|
||||
throw new Error('No pude encontrar el elemento para borrar')
|
||||
editor.toolbar.auxiliary.multimedia.removeEl.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const selectedEl = editor.contentEl.querySelector<HTMLElement>(
|
||||
"figure[data-editor-selected]"
|
||||
);
|
||||
if (!selectedEl)
|
||||
throw new Error("No pude encontrar el elemento para borrar");
|
||||
|
||||
selectedEl.parentElement?.removeChild(selectedEl)
|
||||
setAuxiliaryToolbar(editor, null)
|
||||
})
|
||||
selectedEl.parentElement?.removeChild(selectedEl);
|
||||
setAuxiliaryToolbar(editor, null);
|
||||
}
|
||||
);
|
||||
|
||||
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('input', event => {
|
||||
const selectedEl = editor.contentEl
|
||||
.querySelector<HTMLAnchorElement>('figure[data-editor-selected]')
|
||||
if (!selectedEl)
|
||||
throw new Error('No pude encontrar el multimedia para setear el alt')
|
||||
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
|
||||
"input",
|
||||
(event) => {
|
||||
const selectedEl = editor.contentEl.querySelector<HTMLAnchorElement>(
|
||||
"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]')
|
||||
if (!innerEl) throw new Error('No hay multimedia a para setear el alt')
|
||||
const innerEl = selectedEl.querySelector<HTMLElement>(
|
||||
"[data-multimedia-inner]"
|
||||
);
|
||||
if (!innerEl) throw new Error("No hay multimedia a para setear el alt");
|
||||
|
||||
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value)
|
||||
})
|
||||
editor.toolbar.auxiliary.multimedia.altEl.addEventListener('keydown', event => {
|
||||
if (event.keyCode == 13) event.preventDefault()
|
||||
})
|
||||
setAlt(innerEl, editor.toolbar.auxiliary.multimedia.altEl.value);
|
||||
}
|
||||
);
|
||||
editor.toolbar.auxiliary.multimedia.altEl.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.keyCode == 13) event.preventDefault();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function setupButtons (editor: Editor): void {
|
||||
const buttonEl = editor.toolbarEl.querySelector('[data-editor-button="multimedia"]')
|
||||
if (!buttonEl) throw new Error('No encontre el botón de multimedia')
|
||||
buttonEl.addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
export function setupButtons(editor: Editor): void {
|
||||
const buttonEl = editor.toolbarEl.querySelector(
|
||||
'[data-editor-button="multimedia"]'
|
||||
);
|
||||
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)
|
||||
list[0].insertBefore(el, list[1].nextElementSibling)
|
||||
select(editor, el)
|
||||
const el = multimedia.create(editor);
|
||||
list[0].insertBefore(el, list[1].nextElementSibling);
|
||||
select(editor, el);
|
||||
|
||||
return false
|
||||
})
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,70 +1,78 @@
|
|||
import { Editor } from 'editor/editor'
|
||||
import { Editor } from "editor/editor";
|
||||
import {
|
||||
safeGetSelection, safeGetRangeAt,
|
||||
moveChildren,
|
||||
blockNames, parentBlockNames,
|
||||
} from 'editor/utils'
|
||||
import { EditorNode, getType, getValidParentInSelection } from 'editor/types'
|
||||
safeGetSelection,
|
||||
safeGetRangeAt,
|
||||
moveChildren,
|
||||
blockNames,
|
||||
parentBlockNames,
|
||||
} from "editor/utils";
|
||||
import { EditorNode, getType, getValidParentInSelection } from "editor/types";
|
||||
|
||||
function makeParentBlock (tag: string, create: EditorNode["create"]): EditorNode {
|
||||
return {
|
||||
selector: tag,
|
||||
allowedChildren: [...blockNames, 'multimedia'],
|
||||
handleEmpty: 'remove',
|
||||
create,
|
||||
}
|
||||
function makeParentBlock(
|
||||
tag: string,
|
||||
create: EditorNode["create"]
|
||||
): EditorNode {
|
||||
return {
|
||||
selector: tag,
|
||||
allowedChildren: [...blockNames, "multimedia"],
|
||||
handleEmpty: "remove",
|
||||
create,
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: añadir blockquote
|
||||
// XXX: si agregás algo acá, probablemente le quieras hacer un botón
|
||||
// en app/views/posts/attributes/_content.haml
|
||||
export const parentBlocks: { [propName: string]: EditorNode } = {
|
||||
left: makeParentBlock('div[data-align=left]', () => {
|
||||
const el = document.createElement('div')
|
||||
el.dataset.align = 'left'
|
||||
return el
|
||||
}),
|
||||
center: makeParentBlock('div[data-align=center]', () => {
|
||||
const el = document.createElement('div')
|
||||
el.dataset.align = 'center'
|
||||
return el
|
||||
}),
|
||||
right: makeParentBlock('div[data-align=right]', () => {
|
||||
const el = document.createElement('div')
|
||||
el.dataset.align = '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}"]`
|
||||
)
|
||||
if (!buttonEl) continue
|
||||
buttonEl.addEventListener("click", event => {
|
||||
event.preventDefault()
|
||||
|
||||
// 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
|
||||
// para encontrar los bloques que están seleccionados y moverlos/cambiarles
|
||||
// el parentBlock)
|
||||
|
||||
const list = getValidParentInSelection({ editor, type: name })
|
||||
|
||||
const replacementEl = type.create(editor)
|
||||
if (list[0] == editor.contentEl) {
|
||||
// no está en un parentBlock
|
||||
editor.contentEl.insertBefore(replacementEl, list[1])
|
||||
replacementEl.appendChild(list[1])
|
||||
} else {
|
||||
// está en un parentBlock
|
||||
moveChildren(list[0], replacementEl, null)
|
||||
editor.contentEl.replaceChild(replacementEl, list[0])
|
||||
}
|
||||
window.getSelection()?.collapse(replacementEl)
|
||||
|
||||
return false
|
||||
})
|
||||
}
|
||||
left: makeParentBlock("div[data-align=left]", () => {
|
||||
const el = document.createElement("div");
|
||||
el.dataset.align = "left";
|
||||
el.style.textAlign = "left";
|
||||
return el;
|
||||
}),
|
||||
center: makeParentBlock("div[data-align=center]", () => {
|
||||
const el = document.createElement("div");
|
||||
el.dataset.align = "center";
|
||||
el.style.textAlign = "center";
|
||||
return el;
|
||||
}),
|
||||
right: makeParentBlock("div[data-align=right]", () => {
|
||||
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}"]`
|
||||
);
|
||||
if (!buttonEl) continue;
|
||||
buttonEl.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// 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
|
||||
// para encontrar los bloques que están seleccionados y moverlos/cambiarles
|
||||
// el parentBlock)
|
||||
|
||||
const list = getValidParentInSelection({ editor, type: name });
|
||||
|
||||
const replacementEl = type.create(editor);
|
||||
if (list[0] == editor.contentEl) {
|
||||
// no está en un parentBlock
|
||||
editor.contentEl.insertBefore(replacementEl, list[1]);
|
||||
replacementEl.appendChild(list[1]);
|
||||
} else {
|
||||
// está en un parentBlock
|
||||
moveChildren(list[0], replacementEl, null);
|
||||
editor.contentEl.replaceChild(replacementEl, list[0]);
|
||||
}
|
||||
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 markNames = ['bold', 'italic', 'deleted', 'underline', 'sub', 'super', 'mark', 'link', 'small']
|
||||
export const parentBlockNames = ['left', 'center', 'right']
|
||||
export const blockNames = [
|
||||
"paragraph",
|
||||
"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) {
|
||||
while (from.firstChild) to.insertBefore(from.firstChild, toRef)
|
||||
export function moveChildren(from: Element, to: Element, toRef: Node | null) {
|
||||
while (from.firstChild) to.insertBefore(from.firstChild, toRef);
|
||||
}
|
||||
|
||||
export function isDirectChild (node: Node, supposedChild: Node): boolean {
|
||||
for (const child of node.childNodes) {
|
||||
if (child == supposedChild) return true
|
||||
}
|
||||
return false
|
||||
export function isDirectChild(node: Node, supposedChild: Node): boolean {
|
||||
for (const child of node.childNodes) {
|
||||
if (child == supposedChild) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function safeGetSelection (editor: Editor): Selection | null {
|
||||
const sel = window.getSelection()
|
||||
if (!sel) return null
|
||||
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
|
||||
// deberíamos mostrar un error?
|
||||
if (
|
||||
!editor.contentEl.contains(sel.anchorNode)
|
||||
|| !editor.contentEl.contains(sel.focusNode)
|
||||
|| sel.anchorNode == editor.contentEl
|
||||
|| sel.focusNode == editor.contentEl
|
||||
) return null
|
||||
return sel
|
||||
export function safeGetSelection(editor: Editor): Selection | null {
|
||||
const sel = window.getSelection();
|
||||
if (!sel) return null;
|
||||
// XXX: no damos la selección si esta fuera o _es_ el contentEl, ¿quizás
|
||||
// deberíamos mostrar un error?
|
||||
if (
|
||||
!editor.contentEl.contains(sel.anchorNode) ||
|
||||
!editor.contentEl.contains(sel.focusNode) ||
|
||||
sel.anchorNode == editor.contentEl ||
|
||||
sel.focusNode == editor.contentEl
|
||||
)
|
||||
return null;
|
||||
return sel;
|
||||
}
|
||||
|
||||
export function safeGetRangeAt (selection: Selection, num = 0): Range | null {
|
||||
try {
|
||||
return selection.getRangeAt(num)
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
export function safeGetRangeAt(selection: Selection, num = 0): Range | null {
|
||||
try {
|
||||
return selection.getRangeAt(num);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SplitNode {
|
||||
range: Range,
|
||||
node: Node,
|
||||
range: Range;
|
||||
node: Node;
|
||||
}
|
||||
|
||||
export function splitNode (node: Element, range: Range): [SplitNode, SplitNode] {
|
||||
const [left, right] = [
|
||||
{ range: document.createRange(), node: node.cloneNode(false) },
|
||||
{ range: document.createRange(), node: node.cloneNode(false) },
|
||||
]
|
||||
export function splitNode(node: Element, range: Range): [SplitNode, SplitNode] {
|
||||
const [left, right] = [
|
||||
{ range: document.createRange(), node: node.cloneNode(false) },
|
||||
{ range: document.createRange(), node: node.cloneNode(false) },
|
||||
];
|
||||
|
||||
if (node.firstChild) left.range.setStartBefore(node.firstChild)
|
||||
left.range.setEnd(range.startContainer, range.startOffset)
|
||||
left.range.surroundContents(left.node)
|
||||
if (node.firstChild) left.range.setStartBefore(node.firstChild);
|
||||
left.range.setEnd(range.startContainer, range.startOffset);
|
||||
left.range.surroundContents(left.node);
|
||||
|
||||
right.range.setStart(range.endContainer, range.endOffset)
|
||||
if (node.lastChild) right.range.setEndAfter(node.lastChild)
|
||||
right.range.surroundContents(right.node)
|
||||
right.range.setStart(range.endContainer, range.endOffset);
|
||||
if (node.lastChild) right.range.setEndAfter(node.lastChild);
|
||||
right.range.surroundContents(right.node);
|
||||
|
||||
if (!node.parentElement)
|
||||
throw new Error('No pude separar los nodos por que no tiene parentNode')
|
||||
if (!node.parentElement)
|
||||
throw new Error("No pude separar los nodos por que no tiene parentNode");
|
||||
|
||||
moveChildren(node, node.parentElement, node)
|
||||
node.parentElement.removeChild(node)
|
||||
moveChildren(node, node.parentElement, node);
|
||||
node.parentElement.removeChild(node);
|
||||
|
||||
return [left, right]
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
export function setAuxiliaryToolbar (editor: Editor, bar: HTMLElement | null): void {
|
||||
for (const { parentEl } of Object.values(editor.toolbar.auxiliary)) {
|
||||
delete parentEl.dataset.editorAuxiliaryActive
|
||||
}
|
||||
if (bar) bar.dataset.editorAuxiliaryActive = 'active'
|
||||
export function setAuxiliaryToolbar(
|
||||
editor: Editor,
|
||||
bar: HTMLElement | null
|
||||
): void {
|
||||
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 {
|
||||
const selectedEl = editor.contentEl.querySelector('[data-editor-selected]')
|
||||
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected
|
||||
export function clearSelected(editor: Editor): void {
|
||||
const selectedEl = editor.contentEl.querySelector("[data-editor-selected]");
|
||||
if (selectedEl) delete (selectedEl as HTMLElement).dataset.editorSelected;
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import 'etc'
|
|||
import Rails from '@rails/ujs'
|
||||
import Turbolinks from 'turbolinks'
|
||||
import * as ActiveStorage from '@rails/activestorage'
|
||||
import 'chartkick/chart.js'
|
||||
|
||||
Rails.start()
|
||||
Turbolinks.start()
|
||||
|
|
|
@ -30,15 +30,17 @@ class BacktraceJob < ApplicationJob
|
|||
|
||||
# Encuentra el código fuente del error
|
||||
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
|
||||
|
||||
begin
|
||||
raise BacktraceException, "#{origin}: #{params['errors']&.first&.dig('message')}"
|
||||
raise BacktraceException, "#{origin}: #{message}"
|
||||
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
|
||||
|
||||
|
@ -102,4 +104,9 @@ class BacktraceJob < ApplicationJob
|
|||
rescue URI::Error
|
||||
params.dig('context', 'url')
|
||||
end
|
||||
|
||||
# @return [String,Nil]
|
||||
def message
|
||||
@message ||= params['errors']&.first&.dig('message')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -5,16 +5,33 @@ class DeployJob < ApplicationJob
|
|||
class DeployException < StandardError; end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def perform(site, notify = true)
|
||||
def perform(site, notify = true, time = Time.now)
|
||||
ActiveRecord::Base.connection_pool.with_connection do
|
||||
@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!
|
||||
@deployed = { deploy_local: deploy_locally }
|
||||
|
||||
# No es opcional
|
||||
unless @deployed[:deploy_local]
|
||||
@site.update_attribute :status, 'waiting'
|
||||
@site.update status: 'waiting'
|
||||
notify_usuaries if notify
|
||||
|
||||
# Hacer fallar la tarea
|
||||
|
@ -22,8 +39,11 @@ class DeployJob < ApplicationJob
|
|||
end
|
||||
|
||||
deploy_others
|
||||
|
||||
# Volver a la espera
|
||||
@site.update status: 'waiting'
|
||||
|
||||
notify_usuaries if notify
|
||||
@site.update_attribute :status, 'waiting'
|
||||
end
|
||||
end
|
||||
# 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
|
||||
|
||||
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
|
||||
|
|
|
@ -62,10 +62,15 @@ class DeployLocal < Deploy
|
|||
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
|
||||
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
|
||||
'JEKYLL_ENV' => Rails.env,
|
||||
'LANG' => ENV['LANG']
|
||||
'LANG' => ENV['LANG'],
|
||||
'YARN_CACHE_FOLDER' => yarn_cache_dir
|
||||
}
|
||||
end
|
||||
|
||||
def yarn_cache_dir
|
||||
Rails.root.join('_yarn_cache').to_s
|
||||
end
|
||||
|
||||
def yarn_lock
|
||||
File.join(site.path, 'yarn.lock')
|
||||
end
|
||||
|
@ -80,9 +85,9 @@ class DeployLocal < Deploy
|
|||
|
||||
# Corre yarn dentro del repositorio
|
||||
def yarn
|
||||
return unless yarn_lock?
|
||||
return true unless yarn_lock?
|
||||
|
||||
run 'yarn'
|
||||
run 'yarn install --production'
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
# TODO: Sanitizar otros valores
|
||||
|
|
|
@ -3,13 +3,6 @@
|
|||
# Almacena el UUID de otro Post y actualiza el valor en el Post
|
||||
# relacionado.
|
||||
class MetadataBelongsTo < MetadataRelatedPosts
|
||||
def value_was=(new_value)
|
||||
@belongs_to = nil
|
||||
@belonged_to = nil
|
||||
|
||||
super(new_value)
|
||||
end
|
||||
|
||||
# TODO: Convertir algunos tipos de valores en módulos para poder
|
||||
# implementar varios tipos de campo sin repetir código
|
||||
#
|
||||
|
@ -20,6 +13,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
''
|
||||
end
|
||||
|
||||
# Obtiene el valor desde el documento.
|
||||
#
|
||||
# @return [String]
|
||||
def document_value
|
||||
document.data[name.to_s]
|
||||
end
|
||||
|
||||
def validate
|
||||
super
|
||||
|
||||
|
@ -39,10 +39,14 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
|
||||
# Si estamos cambiando la relación, tenemos que eliminar la relación
|
||||
# anterior
|
||||
belonged_to[inverse].value.delete post.uuid.value if changed? && belonged_to.present?
|
||||
if belonged_to.present?
|
||||
belonged_to[inverse].value = belonged_to[inverse].value.reject do |rej|
|
||||
rej == post.uuid.value
|
||||
end
|
||||
end
|
||||
|
||||
# No duplicar las relaciones
|
||||
belongs_to[inverse].value << 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
|
||||
end
|
||||
|
@ -63,20 +67,13 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
end
|
||||
|
||||
# El Post relacionado con este artículo
|
||||
#
|
||||
# XXX: Memoizamos usando el valor para tener el valor siempre
|
||||
# actualizado.
|
||||
def belongs_to
|
||||
return if value.blank?
|
||||
|
||||
@belongs_to ||= posts.find(value, uuid: true)
|
||||
posts.find(value, uuid: true) if value.present?
|
||||
end
|
||||
|
||||
# El artículo relacionado anterior
|
||||
def belonged_to
|
||||
return if value_was.blank?
|
||||
|
||||
@belonged_to ||= posts.find(value_was, uuid: true)
|
||||
posts.find(value_was, uuid: true) if value_was.present?
|
||||
end
|
||||
|
||||
def related_posts?
|
||||
|
@ -87,6 +84,10 @@ class MetadataBelongsTo < MetadataRelatedPosts
|
|||
@related_methods ||= %i[belongs_to belonged_to].freeze
|
||||
end
|
||||
|
||||
def indexable_values
|
||||
belongs_to&.title&.value
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def post_exists?
|
||||
|
|
|
@ -25,10 +25,19 @@ class MetadataBoolean < MetadataTemplate
|
|||
# * false
|
||||
# * true
|
||||
def value
|
||||
return document.data.fetch(name.to_s, default_value) if self[:value].nil?
|
||||
return self[:value] unless self[:value].is_a? String
|
||||
case self[:value]
|
||||
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
|
||||
|
||||
private
|
||||
|
|
|
@ -15,14 +15,26 @@ class MetadataContent < MetadataTemplate
|
|||
false
|
||||
end
|
||||
|
||||
def document_value
|
||||
document.content
|
||||
end
|
||||
|
||||
def indexable?
|
||||
true && !private?
|
||||
end
|
||||
|
||||
def to_s
|
||||
sanitizer.sanitize value, tags: [], attributes: []
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Detectar si el contenido estaba en Markdown y pasarlo a HTML
|
||||
def legacy_content
|
||||
return unless document.content
|
||||
return document.content if /^\s*</ =~ document.content
|
||||
return unless document_value
|
||||
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
|
||||
|
||||
# Limpiar el HTML que recibimos
|
||||
|
@ -44,7 +56,7 @@ class MetadataContent < MetadataTemplate
|
|||
uri = URI element['src']
|
||||
|
||||
# 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
|
||||
element.remove
|
||||
end
|
||||
|
|
|
@ -7,17 +7,58 @@ class MetadataDocumentDate < MetadataTemplate
|
|||
Date.today.to_time
|
||||
end
|
||||
|
||||
def value_from_document
|
||||
# @return [Time]
|
||||
def document_value
|
||||
return nil if post.new?
|
||||
|
||||
document.date
|
||||
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
|
||||
# "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
|
||||
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
|
||||
|
|
|
@ -16,6 +16,7 @@ class MetadataFile < MetadataTemplate
|
|||
def validate
|
||||
super
|
||||
|
||||
errors << I18n.t("metadata.#{type}.site_invalid") if site.invalid?
|
||||
errors << I18n.t("metadata.#{type}.path_required") if path_missing?
|
||||
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
|
||||
|
||||
|
|
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
|
||||
# quitarla de la relación remota, sino hay que agregarla.
|
||||
#
|
||||
def save
|
||||
# XXX: No usamos super
|
||||
self[:value] = sanitize value
|
||||
|
@ -25,27 +26,21 @@ class MetadataHasAndBelongsToMany < MetadataHasMany
|
|||
return true unless changed?
|
||||
return true unless inverse?
|
||||
|
||||
# XXX: Usamos asignación para aprovechar value= que setea el valor
|
||||
# anterior en @value_was
|
||||
(had_many - has_many).each do |remove|
|
||||
remove[inverse]&.value&.delete post.uuid.value
|
||||
remove[inverse].value = remove[inverse].value.reject do |rej|
|
||||
rej == post.uuid.value
|
||||
end
|
||||
end
|
||||
|
||||
(has_many - had_many).each do |add|
|
||||
next unless add[inverse]
|
||||
next if add[inverse].value.include? post.uuid.value
|
||||
|
||||
add[inverse].value << post.uuid.value
|
||||
add[inverse].value = (add[inverse].value.dup << post.uuid.value)
|
||||
end
|
||||
|
||||
true
|
||||
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
|
||||
|
|
|
@ -6,32 +6,18 @@
|
|||
# Localmente tenemos un Array de UUIDs. Remotamente tenemos una String
|
||||
# apuntando a un Post, que se mantiene actualizado como el actual.
|
||||
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
|
||||
def has_many
|
||||
@has_many ||= posts.where(uuid: value)
|
||||
return default_value if value.blank?
|
||||
|
||||
posts.where(uuid: value)
|
||||
end
|
||||
|
||||
# La relación anterior
|
||||
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
|
||||
|
||||
def inverse?
|
||||
|
@ -71,8 +57,4 @@ class MetadataHasMany < MetadataRelatedPosts
|
|||
def related_methods
|
||||
@related_methods ||= %i[has_many had_many].freeze
|
||||
end
|
||||
|
||||
def posts_exist?
|
||||
has_many.size == sanitize(value).size
|
||||
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
|
||||
end
|
||||
|
||||
def value_from_document
|
||||
# @return [Symbol]
|
||||
def document_value
|
||||
document.collection.label.to_sym
|
||||
end
|
||||
|
||||
def value
|
||||
self[:value] ||= value_from_document || default_value
|
||||
self[:value] ||= document_value || default_value
|
||||
end
|
||||
|
||||
def values
|
||||
|
|
|
@ -9,14 +9,15 @@ class MetadataMarkdownContent < MetadataText
|
|||
end
|
||||
|
||||
def value
|
||||
self[:value] || value_from_document || default_value
|
||||
self[:value] || document_value || default_value
|
||||
end
|
||||
|
||||
def front_matter?
|
||||
false
|
||||
end
|
||||
|
||||
def value_from_document
|
||||
# @return [String]
|
||||
def document_value
|
||||
document.content
|
||||
end
|
||||
|
||||
|
|
|
@ -3,12 +3,16 @@
|
|||
# Este campo representa el archivo donde se almacenan los datos
|
||||
class MetadataPath < MetadataTemplate
|
||||
# :label en este caso es el idioma/colección
|
||||
#
|
||||
# @return [String]
|
||||
def default_value
|
||||
File.join(site.path, "_#{lang}", "#{date}-#{slug}#{ext}")
|
||||
end
|
||||
|
||||
# El valor no vuelve desde el documento
|
||||
def value_from_document
|
||||
# La ruta del archivo según Jekyll
|
||||
#
|
||||
# @return [String]
|
||||
def document_value
|
||||
document.path
|
||||
end
|
||||
|
||||
|
|
|
@ -2,6 +2,12 @@
|
|||
|
||||
# Este metadato permite generar rutas manuales.
|
||||
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
|
||||
def private?
|
||||
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
|
||||
end
|
||||
|
||||
def indexable?
|
||||
false
|
||||
end
|
||||
|
||||
def indexable_values
|
||||
posts.where(uuid: value).map(&:title).map(&:value)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Obtiene todos los posts y opcionalmente los filtra
|
||||
def posts
|
||||
@posts ||= site.posts(lang: lang).where(**filter)
|
||||
site.posts(lang: lang).where(**filter)
|
||||
end
|
||||
|
||||
def title(post)
|
||||
|
|
|
@ -7,6 +7,10 @@ class MetadataString < MetadataTemplate
|
|||
super || ''
|
||||
end
|
||||
|
||||
def indexable?
|
||||
true && !private?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# No se permite HTML en las strings
|
||||
|
|
|
@ -7,6 +7,11 @@
|
|||
MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
||||
:value, :help, :required, :errors, :post,
|
||||
:layout, keyword_init: true) do
|
||||
# Determina si el campo es indexable
|
||||
def indexable?
|
||||
false
|
||||
end
|
||||
|
||||
def inspect
|
||||
"#<#{self.class} site=#{site.name.inspect} post=#{post.id.inspect} value=#{value.inspect}>"
|
||||
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
|
||||
# siempre vamos a obtener un item nuevo.
|
||||
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
|
||||
|
||||
# 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
|
||||
value.hash.to_s + values.hash.to_s
|
||||
post.cache_version + value.hash.to_s + values.hash.to_s
|
||||
end
|
||||
|
||||
# @return [String]
|
||||
def cache_key_with_version
|
||||
cache_key + '-' + cache_version
|
||||
"#{cache_key}-#{cache_version}"
|
||||
end
|
||||
|
||||
# XXX: Deberíamos sanitizar durante la asignación?
|
||||
|
@ -38,11 +49,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
def value_was
|
||||
return @value_was if instance_variable_defined? '@value_was'
|
||||
|
||||
@value_was = value_from_document
|
||||
end
|
||||
|
||||
def value_from_document
|
||||
@value_from_document ||= document.data[name.to_s]
|
||||
@value_was = document_value
|
||||
end
|
||||
|
||||
def changed?
|
||||
|
@ -74,7 +81,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
# Valor actual o por defecto. Al memoizarlo podemos modificarlo
|
||||
# usando otros métodos que el de asignación.
|
||||
def value
|
||||
self[:value] ||= if (data = value_from_document).present?
|
||||
self[:value] ||= if (data = document_value).present?
|
||||
private? ? decrypt(data) : data
|
||||
else
|
||||
default_value
|
||||
|
@ -205,9 +212,13 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
|
|||
|
||||
box.decrypt_str value.to_s
|
||||
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
|
||||
|
||||
# Cifra el valor.
|
||||
|
|
|
@ -17,6 +17,12 @@ class Post
|
|||
|
||||
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
|
||||
# Obtiene el layout sin leer el Document
|
||||
#
|
||||
|
@ -53,9 +59,7 @@ class Post
|
|||
public_send(attr)&.value = args[attr] if args.key?(attr)
|
||||
end
|
||||
|
||||
# XXX: No usamos Post#read porque a esta altura todavía no sabemos
|
||||
# nada del Document
|
||||
document.read! if File.exist? document.path
|
||||
document.read! unless new?
|
||||
end
|
||||
|
||||
def inspect
|
||||
|
@ -75,6 +79,9 @@ class Post
|
|||
# TODO: Cambiar el locale en otro lado
|
||||
l = lang.value.to_s
|
||||
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.
|
||||
document.renderer.payload = {
|
||||
|
@ -114,7 +121,7 @@ class Post
|
|||
end
|
||||
|
||||
def cache_version
|
||||
updated_at.utc.to_s(:usec)
|
||||
(updated_at || modified_at).utc.to_s(:usec)
|
||||
end
|
||||
|
||||
# Agregar el timestamp para saber si cambió, siguiendo el módulo
|
||||
|
@ -131,6 +138,8 @@ class Post
|
|||
|
||||
# Fecha de última modificación del archivo
|
||||
def updated_at
|
||||
return if new?
|
||||
|
||||
File.mtime(path.absolute)
|
||||
end
|
||||
|
||||
|
@ -195,6 +204,8 @@ class Post
|
|||
post: self, required: true)
|
||||
end
|
||||
|
||||
alias locale lang
|
||||
|
||||
# TODO: Mover a method_missing
|
||||
def 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
|
||||
|
||||
unless template.front_matter?
|
||||
body += "\n\n"
|
||||
body += "\n\n" if body.present?
|
||||
body += template.value
|
||||
next
|
||||
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]
|
||||
end.compact.to_h
|
||||
|
@ -255,11 +268,17 @@ class Post
|
|||
end
|
||||
|
||||
# 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
|
||||
FileUtils.rm_f path.absolute
|
||||
run_callbacks :destroy do
|
||||
FileUtils.rm_f path.absolute
|
||||
|
||||
site.delete_post self
|
||||
site.delete_post self
|
||||
end
|
||||
end
|
||||
alias destroy! destroy
|
||||
|
||||
|
@ -283,10 +302,13 @@ class Post
|
|||
end
|
||||
end
|
||||
|
||||
return false unless save_attributes!
|
||||
return false unless write
|
||||
run_callbacks :save do
|
||||
return false unless save_attributes!
|
||||
return false unless write
|
||||
end
|
||||
|
||||
# Vuelve a leer el post para tomar los cambios
|
||||
document.reset
|
||||
read
|
||||
|
||||
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)
|
||||
return self if args.empty?
|
||||
|
||||
@where ||= {}
|
||||
@where[args.hash.to_s] ||= begin
|
||||
begin
|
||||
PostRelation.new(site: site, lang: lang).concat(select do |post|
|
||||
result = args.map do |attr, value|
|
||||
next unless post.attribute?(attr)
|
||||
|
|
|
@ -26,7 +26,7 @@ class Site < ApplicationRecord
|
|||
validates :design_id, presence: true
|
||||
validates_inclusion_of :status, in: %w[waiting enqueued building]
|
||||
validates_presence_of :title
|
||||
validates :description, length: { in: 50..160 }
|
||||
validates :description, length: { in: 10..160 }
|
||||
validate :deploy_local_presence
|
||||
validate :compatible_layouts, on: :update
|
||||
|
||||
|
@ -37,6 +37,7 @@ class Site < ApplicationRecord
|
|||
belongs_to :design
|
||||
belongs_to :licencia
|
||||
|
||||
has_many :stats
|
||||
has_many :log_entries, dependent: :destroy
|
||||
has_many :deploys, dependent: :destroy
|
||||
has_many :build_stats, through: :deploys
|
||||
|
@ -65,8 +66,8 @@ class Site < ApplicationRecord
|
|||
|
||||
accepts_nested_attributes_for :deploys, allow_destroy: true
|
||||
|
||||
# El sitio en Jekyll
|
||||
attr_reader :jekyll
|
||||
# XXX: Es importante incluir luego de los callbacks de :load_jekyll
|
||||
include Site::Index
|
||||
|
||||
# No permitir HTML en estos atributos
|
||||
def title=(title)
|
||||
|
@ -97,7 +98,7 @@ class Site < ApplicationRecord
|
|||
# @param slash Boolean Agregar / al final o no
|
||||
# @return String La URL con o sin / al final
|
||||
def url(slash: true)
|
||||
'https://' + hostname + (slash ? '/' : '')
|
||||
"https://#{hostname}#{slash ? '/' : ''}"
|
||||
end
|
||||
|
||||
# Obtiene los dominios alternativos
|
||||
|
@ -105,7 +106,7 @@ class Site < ApplicationRecord
|
|||
# @return Array
|
||||
def alternative_hostnames
|
||||
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
|
||||
|
||||
|
@ -114,7 +115,7 @@ class Site < ApplicationRecord
|
|||
# @return Array
|
||||
def alternative_urls(slash: true)
|
||||
alternative_hostnames.map do |h|
|
||||
'https://' + h + (slash ? '/' : '')
|
||||
"https://#{h}#{slash ? '/' : ''}"
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -175,41 +176,30 @@ class Site < ApplicationRecord
|
|||
end
|
||||
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
|
||||
#
|
||||
# XXX: Leer directamente sin pasar por Jekyll
|
||||
def data
|
||||
read
|
||||
|
||||
# Define los valores por defecto según la llave buscada
|
||||
@jekyll.data.default_proc = proc do |data, key|
|
||||
data[key] = case key
|
||||
when 'layout' then {}
|
||||
end
|
||||
unless jekyll.data.present?
|
||||
run_in_path do
|
||||
jekyll.reader.read_data
|
||||
jekyll.data['layouts'] ||= {}
|
||||
end
|
||||
end
|
||||
|
||||
@jekyll.data
|
||||
jekyll.data
|
||||
end
|
||||
|
||||
# Traer las colecciones. Todos los artículos van a estar dentro de
|
||||
# colecciones.
|
||||
def collections
|
||||
read
|
||||
unless @read
|
||||
run_in_path do
|
||||
jekyll.reader.read_collections
|
||||
end
|
||||
|
||||
@jekyll.collections
|
||||
@read = true
|
||||
end
|
||||
|
||||
jekyll.collections
|
||||
end
|
||||
|
||||
# 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
|
||||
def posts(lang: nil)
|
||||
read
|
||||
|
||||
# Traemos los posts del idioma actual por defecto
|
||||
lang ||= I18n.locale
|
||||
# Traemos los posts del idioma actual por defecto o el que haya
|
||||
lang ||= locales.include?(I18n.locale) ? I18n.locale : default_locale
|
||||
lang = lang.to_sym
|
||||
|
||||
# Crea un Struct dinámico con los valores de los locales, si
|
||||
|
@ -275,7 +263,9 @@ class Site < ApplicationRecord
|
|||
# NoMethodError
|
||||
@layouts_struct ||= Struct.new(*layout_keys, keyword_init: true)
|
||||
@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
|
||||
|
||||
|
@ -293,6 +283,15 @@ class Site < ApplicationRecord
|
|||
layout_keys.include? layout.to_sym
|
||||
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
|
||||
#
|
||||
# TODO: Traer recursivamente, si el campo contiene Hash
|
||||
|
@ -313,14 +312,31 @@ class Site < ApplicationRecord
|
|||
|
||||
# Poner en la cola de compilación
|
||||
def enqueue!
|
||||
!enqueued? && update_attribute(:status, 'enqueued')
|
||||
update(status: 'enqueued') if waiting?
|
||||
end
|
||||
|
||||
# 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?
|
||||
status == 'enqueued'
|
||||
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
|
||||
#
|
||||
# TODO: En lugar de leer todo junto de una vez, extraer la carga de
|
||||
|
@ -334,10 +350,7 @@ class Site < ApplicationRecord
|
|||
|
||||
def reload_jekyll!
|
||||
reset
|
||||
|
||||
Dir.chdir(path) do
|
||||
@jekyll = Jekyll::Site.new(configuration)
|
||||
end
|
||||
jekyll
|
||||
end
|
||||
|
||||
def reload
|
||||
|
@ -390,7 +403,7 @@ class Site < ApplicationRecord
|
|||
|
||||
# Detecta si el tema actual es una gema
|
||||
def theme_available?
|
||||
available_themes.include? design.gem
|
||||
available_themes.include? design&.gem
|
||||
end
|
||||
|
||||
# Devuelve el dominio actual
|
||||
|
@ -404,7 +417,7 @@ class Site < ApplicationRecord
|
|||
end
|
||||
|
||||
def self.default
|
||||
find_by(name: Site.domain + '.')
|
||||
find_by(name: "#{Site.domain}.")
|
||||
end
|
||||
|
||||
def reset
|
||||
|
@ -515,4 +528,8 @@ class Site < ApplicationRecord
|
|||
errors.add(:design_id,
|
||||
I18n.t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.error'))
|
||||
end
|
||||
|
||||
def run_in_path(&block)
|
||||
Dir.chdir path, &block
|
||||
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]
|
||||
def fetch
|
||||
if origin.check_connection :fetch
|
||||
rugged.fetch(origin)[:received_objects]
|
||||
if origin.check_connection(:fetch, credentials: credentials)
|
||||
rugged.fetch(origin, credentials: credentials)[:received_objects]
|
||||
else
|
||||
0
|
||||
end
|
||||
|
@ -149,6 +149,26 @@ class Site
|
|||
|
||||
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)
|
||||
Pathname.new(file).relative_path_from(Pathname.new(path)).to_s
|
||||
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
|
||||
return scope if scope&.first&.site&.usuarie? usuarie
|
||||
|
||||
scope.select do |post|
|
||||
post.usuaries.include? usuarie
|
||||
end
|
||||
scope.by_usuarie(usuarie.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,4 +12,16 @@ class SiteStatPolicy
|
|||
def index?
|
||||
site_stat.site.usuarie? usuarie
|
||||
end
|
||||
|
||||
def host?
|
||||
index?
|
||||
end
|
||||
|
||||
def resources?
|
||||
index?
|
||||
end
|
||||
|
||||
def uris?
|
||||
index?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -61,6 +61,18 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
|
|||
commit_config(action: :tor)
|
||||
end
|
||||
|
||||
# Trae cambios desde la rama remota y reindexa los artículos.
|
||||
#
|
||||
# @return [Boolean]
|
||||
def merge
|
||||
result = site.repository.merge(usuarie)
|
||||
|
||||
# TODO: Implementar callbacks
|
||||
site.try(:index_posts!) if result
|
||||
|
||||
result.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# 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.
|
||||
def change_licencias
|
||||
site.locales.each do |locale|
|
||||
next unless I18n.available_locales.include? locale
|
||||
|
||||
Mobility.with_locale(locale) do
|
||||
permalink = "#{I18n.t('activerecord.models.licencia').downcase}/"
|
||||
post = site.posts(lang: locale).find_by(permalink: permalink)
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
= render 'layouts/breadcrumb',
|
||||
crumbs: [link_to(t('.index'), sites_path), t('.title')]
|
||||
- breadcrumb 'sites.index', sites_path
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- 'black-bg'
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: nil
|
||||
|
||||
= content_for :body do
|
||||
- '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
|
||||
:plain
|
||||
window.env = {
|
||||
AIRBRAKE_SITE_ID: #{@site&.id || 1},
|
||||
AIRBRAKE_API_KEY: "#{@site&.airbrake_api_key}",
|
||||
AIRBRAKE_SITE_ID: #{@site.id},
|
||||
AIRBRAKE_API_KEY: "#{@site.airbrake_api_key}",
|
||||
PANEL_URL: "#{ENV['PANEL_URL']}"
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<% unless @data[:_backtrace] %>
|
||||
<% unless @data[:javascript_backtrace] %>
|
||||
```
|
||||
<%= raw @backtrace.join("\n") %>
|
||||
```
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<% if @data[:_backtrace] %>
|
||||
<% if @data[:javascript_backtrace] %>
|
||||
<% @data.dig(:params, 'errors')&.each do |error| %>
|
||||
# <%= 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 %>
|
||||
|
|
|
@ -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,
|
||||
title: t('svg.sutty.title'), desc: t('svg.sutty.desc')
|
||||
|
||||
- if crumbs
|
||||
%nav{ aria: { label: t('.title') }, role: 'navigation' }
|
||||
%ol.breadcrumb
|
||||
%li.breadcrumb-item
|
||||
= link_to edit_usuarie_registration_path,
|
||||
data: { toggle: 'tooltip' },
|
||||
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
|
||||
%nav{ aria: { label: t('.title') } }
|
||||
%ol.breadcrumb.m-0.flex-wrap
|
||||
- breadcrumb_trail do |crumb|
|
||||
%li.breadcrumb-item{ class: crumb.current? ? 'active' : '' }
|
||||
- if crumb.current?
|
||||
%span.line-clamp-1{ aria: { current: 'page' } }= crumb.name
|
||||
- else
|
||||
%li.breadcrumb-item= crumb
|
||||
%span.line-clamp-1= link_to crumb.name, crumb.url
|
||||
|
||||
- if current_usuarie
|
||||
%ul.navbar-nav
|
||||
|
|
|
@ -20,6 +20,7 @@
|
|||
|
||||
%body{ class: yield(:body) }
|
||||
.container-fluid#sutty
|
||||
= render 'layouts/breadcrumb'
|
||||
= yield
|
||||
- if flash[:js]
|
||||
.js-flash.d-none{ data: flash[:js] }
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
- metadata = post[attribute]
|
||||
- type = metadata.type
|
||||
|
||||
- cache metadata do
|
||||
- cache [metadata, I18n.locale] do
|
||||
= render("posts/attributes/#{type}",
|
||||
base: 'post', post: post, attribute: attribute,
|
||||
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' } }
|
||||
%label{ for: 'mark-color' }= t('editor.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' } }
|
||||
.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
|
||||
= 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,
|
||||
dir: dir, lang: locale,
|
||||
**field_options(attribute, metadata, class: 'content')
|
||||
.editor.mt-1
|
||||
= render 'posts/attribute_feedback',
|
||||
post: post, attribute: attribute, metadata: metadata
|
||||
.markdown-editor.mt-1
|
||||
|
|
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
|
||||
.col-md-8
|
||||
= 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
|
||||
%aside.menu.col-md-3
|
||||
%h1= link_to @site.title, @site.url
|
||||
|
@ -14,15 +7,13 @@
|
|||
%table.mb-3
|
||||
- @site.layouts.each do |layout|
|
||||
- next if layout.hidden?
|
||||
- filter = params[:layout] == layout.value
|
||||
%tr
|
||||
%th= layout.humanized_name
|
||||
%td.pl-3= link_to t('posts.add'),
|
||||
new_site_post_path(@site, layout: layout.name),
|
||||
class: 'badge badge-secondary'
|
||||
%td= link_to t(filter ? 'posts.remove_filter' : 'posts.filter'),
|
||||
site_posts_path(@site, layout: (filter ? nil : layout.value)),
|
||||
class: 'badge badge-' + (filter ? 'primary' : 'secondary')
|
||||
%td.pl-3= link_to t('posts.add'), new_site_post_path(@site, layout: layout.value), class: 'btn btn-secondary btn-sm'
|
||||
- if @filter_params[:layout] == layout.name.to_s
|
||||
%td= link_to t('posts.remove_filter'), site_posts_path(@site, **@filter_params.merge(layout: nil)), class: 'btn btn-primary btn-sm'
|
||||
- else
|
||||
%td= link_to t('posts.filter'), site_posts_path(@site, **@filter_params.merge(layout: layout.value)), class: 'btn btn-secondary btn-sm'
|
||||
|
||||
- if policy(@site).edit?
|
||||
= link_to t('sites.edit.btn', site: @site.title), edit_site_path(@site), class: 'btn'
|
||||
|
@ -48,87 +39,102 @@
|
|||
|
||||
%section.col
|
||||
= 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?
|
||||
%h2= t('posts.none')
|
||||
%h2= t('posts.empty')
|
||||
- else
|
||||
= form_tag site_posts_reorder_path, method: :post do
|
||||
.d-flex.justify-content-between.align-items-center
|
||||
-#
|
||||
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' : ''}"
|
||||
%input{ type: 'hidden', name: 'post[lang]', value: @locale }
|
||||
%table.table{ data: { controller: 'reorder' } }
|
||||
%caption.sr-only= t('posts.caption')
|
||||
%thead
|
||||
%tr
|
||||
%th.border-0.background-white.position-sticky{ style: 'top: 0; z-index: 2', colspan: '4' }
|
||||
= submit_tag t('posts.reorder.submit'), class: 'btn'
|
||||
%button.btn{ data: { action: 'reorder#unselect' } }
|
||||
= t('posts.reorder.unselect')
|
||||
%span.badge{ data: { target: 'reorder.counter' } } 0
|
||||
%button.btn{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
|
||||
%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')
|
||||
.d-flex.flex-row.justify-content-between
|
||||
%div
|
||||
= submit_tag t('posts.reorder.submit'), class: 'btn'
|
||||
%button.btn{ data: { action: 'reorder#unselect' } }
|
||||
= t('posts.reorder.unselect')
|
||||
%span.badge{ data: { target: 'reorder.counter' } } 0
|
||||
%button.btn{ data: { action: 'reorder#up' } }= t('posts.reorder.up')
|
||||
%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
|
||||
- dir = t("locales.#{@locale}.dir")
|
||||
- size = @posts.size
|
||||
- @posts.each_with_index do |post, i|
|
||||
-#
|
||||
TODO: Solo les usuaries cachean porque tenemos que separar
|
||||
les botones por permisos.
|
||||
|
||||
TODO: Verificar qué pasa cuando se gestiona el sitio en
|
||||
distintos idiomas a la vez
|
||||
- begin
|
||||
- cache_if @usuarie, post do
|
||||
- checkbox_id = "checkbox-#{post.uuid.value}"
|
||||
%tr{ id: post.uuid.value, data: { target: 'reorder.row' } }
|
||||
- cache_if @usuarie, [post, I18n.locale] do
|
||||
- checkbox_id = "checkbox-#{post.id}"
|
||||
%tr{ id: post.id, data: { target: 'reorder.row' } }
|
||||
%td
|
||||
.custom-control.custom-checkbox
|
||||
%input.custom-control-input{ id: checkbox_id, type: 'checkbox', autocomplete: 'off', data: { action: 'reorder#select' } }
|
||||
%label.custom-control-label{ for: checkbox_id }
|
||||
%span.sr-only= t('posts.reorder.select')
|
||||
-# Orden más alto es mayor prioridad
|
||||
= hidden_field 'post[reorder]', post.uuid.value,
|
||||
value: @posts.length - i,
|
||||
= hidden_field 'post[reorder]', post.id,
|
||||
value: size - i,
|
||||
data: { reorder: true }
|
||||
%td.w-100{ class: dir }
|
||||
= link_to site_post_path(@site, post.id) do
|
||||
%span{ lang: post.lang.value, dir: dir }= post.title.value
|
||||
- if post.attributes.include? :draft
|
||||
- if post.draft.value
|
||||
%span.badge.badge-primary
|
||||
= post_label_t(:draft, post: post)
|
||||
- if post.attributes.include? :categories
|
||||
- unless post.categories.value.empty?
|
||||
%br
|
||||
%small
|
||||
- (post.categories.respond_to?(:belongs_to) ? post.categories.belongs_to : post.categories.value).each do |c|
|
||||
= 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)
|
||||
= link_to site_post_path(@site, post.path) do
|
||||
%span{ lang: post.locale, dir: dir }= post.title
|
||||
- if post.front_matter['draft'].present?
|
||||
%span.badge.badge-primary= I18n.t('posts.attributes.draft.label')
|
||||
%br
|
||||
%small
|
||||
= link_to @site.layouts[post.layout].humanized_name, site_posts_path(@site, **@filter_params.merge(layout: post.layout))
|
||||
- post.front_matter['categories']&.each do |category|
|
||||
= link_to site_posts_path(@site, **@filter_params.merge(category: category)) do
|
||||
%span{ lang: post.locale, dir: dir }= category
|
||||
= '/' unless post.front_matter['categories'].last == category
|
||||
|
||||
%td
|
||||
= post.date.value.strftime('%F')
|
||||
%td.text-nowrap
|
||||
= post.created_at.strftime('%F')
|
||||
%br/
|
||||
- if post.attribute? :order
|
||||
= post.order.value
|
||||
%td
|
||||
= post.order
|
||||
%td.text-nowrap
|
||||
- if @usuarie || policy(post).edit?
|
||||
= link_to t('posts.edit'),
|
||||
edit_site_post_path(@site, post.id),
|
||||
class: 'btn btn-block'
|
||||
= link_to t('posts.edit'), edit_site_post_path(@site, post.path), class: 'btn btn-block'
|
||||
- if @usuarie || policy(post).destroy?
|
||||
= link_to t('posts.destroy'),
|
||||
site_post_path(@site, post.id),
|
||||
class: 'btn btn-block',
|
||||
method: :delete,
|
||||
data: { confirm: t('posts.confirm_destroy') }
|
||||
= link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-block', method: :delete, data: { confirm: t('posts.confirm_destroy') }
|
||||
-#
|
||||
Rescatar cualquier error en un post, notificarlo e
|
||||
ignorar su renderización.
|
||||
- rescue ActionView::Template::Error => e
|
||||
- ExceptionNotifier.notify_exception(e.cause, data: { site: @site.name, post: @post.path.absolute, usuarie: current_usuarie.id })
|
||||
|
||||
#footnotes{ hidden: true }
|
||||
- @filter_params.each do |param, value|
|
||||
- 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
|
||||
.col-md-8
|
||||
= 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")
|
||||
.row.justify-content-center
|
||||
.col-md-8
|
||||
|
@ -28,7 +22,7 @@
|
|||
- metadata = @post[attr]
|
||||
- next unless metadata.front_matter?
|
||||
|
||||
- cache metadata do
|
||||
- cache [metadata, I18n.locale] do
|
||||
= render("posts/attribute_ro/#{metadata.type}",
|
||||
post: @post, attribute: attr,
|
||||
metadata: metadata,
|
||||
|
@ -42,6 +36,6 @@
|
|||
- metadata = @post[attr]
|
||||
- next if metadata.front_matter?
|
||||
|
||||
- cache metadata do
|
||||
- cache [metadata, I18n.locale] do
|
||||
%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 site.enqueued?
|
||||
= render 'layouts/btn_with_tooltip',
|
||||
tooltip: t('help.sites.enqueued'),
|
||||
text: t('sites.enqueued'),
|
||||
type: 'secondary',
|
||||
link: nil
|
||||
- else
|
||||
= form_tag site_enqueue_path(site),
|
||||
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')
|
||||
= form_tag site_enqueue_path(site),
|
||||
method: :post,
|
||||
class: 'form-inline inline' do
|
||||
= submit_tag site.enqueued? ? t('sites.enqueued') : t('sites.enqueue'),
|
||||
class: 'btn no-border-radius',
|
||||
title: site.enqueued? ? t('help.sites.enqueued') : t('help.sites.enqueue'),
|
||||
data: { disable_with: t('sites.enqueued') },
|
||||
disabled: site.enqueued?
|
||||
|
|
|
@ -39,7 +39,7 @@
|
|||
%h2= f.label :description
|
||||
%p.lead= t('.help.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
|
||||
.invalid-feedback= site.errors.messages[:description].join(', ')
|
||||
%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
|
||||
.col-md-8
|
||||
%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
|
||||
.col-md-8#pull
|
||||
%h1= t('.title')
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
= render 'layouts/breadcrumb', crumbs: [t('sites.index.title')]
|
||||
|
||||
%main.row
|
||||
%aside.col-md-3
|
||||
%h1= t('.title')
|
||||
|
@ -16,16 +14,17 @@
|
|||
%table.table.table-condensed
|
||||
%tbody
|
||||
- @sites.each do |site|
|
||||
- next unless site.jekyll
|
||||
- rol = current_usuarie.rol_for_site(site)
|
||||
-#
|
||||
TODO: Solo les usuaries cachean porque tenemos que separar
|
||||
les botones por permisos.
|
||||
- cache_if (rol.usuarie? && !rol.temporal), site do
|
||||
- cache_if (rol.usuarie? && !rol.temporal), [site, I18n.locale] do
|
||||
%tr
|
||||
%td
|
||||
%h2
|
||||
- if policy(site).show?
|
||||
= link_to site.title, site_path(site)
|
||||
= link_to site.title, site_posts_path(site, locale: site.default_locale)
|
||||
- else
|
||||
= site.title
|
||||
%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
|
||||
.col-md-8
|
||||
%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
|
||||
.col
|
||||
%h1= t('.title')
|
||||
%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
|
||||
%tbody
|
||||
%tr
|
||||
%td= t('.build.average')
|
||||
%td= distance_of_time_in_words_if_more_than_a_minute @build_avg
|
||||
%tr
|
||||
%td= t('.build.maximum')
|
||||
%td= distance_of_time_in_words_if_more_than_a_minute @build_max
|
||||
.mb-5
|
||||
- Stat::INTERVALS.each do |interval|
|
||||
= link_to t(".#{interval}"), site_stats_path(interval: interval, urls: params[:urls]), class: "btn #{'btn-primary active' if @interval == interval}"
|
||||
|
||||
.mb-5
|
||||
%h2= t('.host.title', count: @hostnames.size)
|
||||
%p.lead= t('.host.description')
|
||||
= 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',
|
||||
crumbs: [link_to(t('sites.index.title'), sites_path),
|
||||
link_to(@site.name, @site),
|
||||
t('.title')]
|
||||
|
||||
.row
|
||||
.col
|
||||
.row.justify-content-center
|
||||
.col.col-md-8
|
||||
%h1= t('.title')
|
||||
|
||||
.row
|
||||
.col
|
||||
-# Una tabla de usuaries y otra de invitades, con acciones
|
||||
- %i[usuaries invitades].each do |u|
|
||||
%h2
|
||||
= t(".#{u}")
|
||||
.btn-group{ role: 'group', 'aria-label': t('.actions') }
|
||||
- if @policy.invite?
|
||||
= link_to t('.invite'),
|
||||
site_usuaries_invite_path(@site, invite_as: u.to_s),
|
||||
class: 'btn',
|
||||
data: { toggle: 'tooltip' },
|
||||
title: t('.help.invite', invite_as: u.to_s)
|
||||
- if policy(Collaboration.new(@site)).collaborate?
|
||||
= link_to t('.public_invite'),
|
||||
site_collaborate_path(@site),
|
||||
class: 'btn',
|
||||
data: { toggle: 'tooltip' },
|
||||
title: t('.help.public_invite')
|
||||
%p= t(".help.#{u}")
|
||||
%h2.mt-5= t(".#{u}")
|
||||
.btn-group{ role: 'group', 'aria-label': t('.actions') }
|
||||
- if @policy.invite?
|
||||
= link_to t('.invite'),
|
||||
site_usuaries_invite_path(@site, invite_as: u.to_s),
|
||||
class: 'btn',
|
||||
data: { toggle: 'tooltip' },
|
||||
title: t('.help.invite', invite_as: u.to_s)
|
||||
- if policy(Collaboration.new(@site)).collaborate?
|
||||
= link_to t('.public_invite'),
|
||||
site_collaborate_path(@site),
|
||||
class: 'btn',
|
||||
data: { toggle: 'tooltip' },
|
||||
title: t('.help.public_invite')
|
||||
%p.lead= t(".help.#{u}")
|
||||
%table.table.table-condensed
|
||||
%tbody
|
||||
- @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