diff --git a/.env.example b/.env.example index a664acb7..fb086224 100644 --- a/.env.example +++ b/.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= diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index 860487ca..00000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -2.7.1 diff --git a/Dockerfile b/Dockerfile index 68fe8edf..ee6ba871 100644 --- a/Dockerfile +++ b/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 " 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 diff --git a/Gemfile b/Gemfile index 56b6ab99..2b304ee0 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index a9ab7311..8df2d77e 100644 --- a/Gemfile.lock +++ b/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 diff --git a/Makefile b/Makefile index f05e9cc5..5a9ad7a8 100644 --- a/Makefile +++ b/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 diff --git a/README.md b/README.md index ef889f9b..25d0d31c 100644 --- a/README.md +++ b/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/) diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 0a215c48..e806a032 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -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; + } } /* diff --git a/app/assets/stylesheets/editor.scss b/app/assets/stylesheets/editor.scss index 5d218c7e..30fab60a 100644 --- a/app/assets/stylesheets/editor.scss +++ b/app/assets/stylesheets/editor.scss @@ -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] { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 38b2b72c..acd0134d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index a4b47a16..dbdd4d0a 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -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 diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index d7d2f9f6..bdaa9011 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -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') diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index 07baaf1a..44073c1f 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -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: %(
%{loading}
) + } + 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 diff --git a/app/controllers/usuaries_controller.rb b/app/controllers/usuaries_controller.rb index 71deee91..6d02a35a 100644 --- a/app/controllers/usuaries_controller.rb +++ b/app/controllers/usuaries_controller.rb @@ -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 diff --git a/app/javascript/editor/editor.ts b/app/javascript/editor/editor.ts index 167d40db..880547de 100644 --- a/app/javascript/editor/editor.ts +++ b/app/javascript/editor/editor.ts @@ -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 y en y // 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
    , 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
      , 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
      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(parentEl: HTMLElement, selector: string): T { - const el = parentEl.querySelector(selector) - if (!el) throw new Error(`No pude encontrar un componente \`${selector}\``) - return el + const el = parentEl.querySelector(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('.js-flash') + const flash = document.querySelector(".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('.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( + ".editor[data-editor]" + )) { + try { + setupEditor(editorEl); + } catch (error) { + // TODO: mostrar error + console.error("no se pudo iniciar el editor, error completo", error); + } + } +}); diff --git a/app/javascript/editor/storage.ts b/app/javascript/editor/storage.ts index df27d59c..e914a242 100644 --- a/app/javascript/editor/storage.ts +++ b/app/javascript/editor/storage.ts @@ -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('[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( + '[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; } diff --git a/app/javascript/editor/types.ts b/app/javascript/editor/types.ts index 8034e3e4..ac3030ce 100644 --- a/app/javascript/editor/types.ts +++ b/app/javascript/editor/types.ts @@ -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); + }); } diff --git a/app/javascript/editor/types/blocks.ts b/app/javascript/editor/types/blocks.ts index 52ad157a..2e2dea7e 100644 --- a/app/javascript/editor/types/blocks.ts +++ b/app/javascript/editor/types/blocks.ts @@ -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; + }); + } } diff --git a/app/javascript/editor/types/link.ts b/app/javascript/editor/types/link.ts index 40a26e1e..eb85db90 100644 --- a/app/javascript/editor/types/link.ts +++ b/app/javascript/editor/types/link.ts @@ -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('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( + "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(); + }); } diff --git a/app/javascript/editor/types/mark.ts b/app/javascript/editor/types/mark.ts index 1e63e368..4735c799 100644 --- a/app/javascript/editor/types/mark.ts +++ b/app/javascript/editor/types/mark.ts @@ -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('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( + "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( + "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(); + }); } diff --git a/app/javascript/editor/types/marks.ts b/app/javascript/editor/types/marks.ts index 3790c749..0ea5a5ad 100644 --- a/app/javascript/editor/types/marks.ts +++ b/app/javascript/editor/types/marks.ts @@ -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; + }); + } } diff --git a/app/javascript/editor/types/multimedia.ts b/app/javascript/editor/types/multimedia.ts index 54c430f3..2af9643a 100644 --- a/app/javascript/editor/types/multimedia.ts +++ b/app/javascript/editor/types/multimedia.ts @@ -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 { - return new Promise((resolve, reject) => { - const upload = new ActiveStorage.DirectUpload( - file, - origin + '/rails/active_storage/direct_uploads', - ) +function uploadFile(file: File): Promise { + 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('[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("[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('figure[data-editor-selected]') - if (!selectedEl) - throw new Error('No pude encontrar el elemento para setear el archivo') + const selectedEl = editor.contentEl.querySelector( + "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('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( + "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('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( + "figure[data-editor-selected]" + ); + if (!selectedEl) + throw new Error("No pude encontrar el multimedia para setear el alt"); - const innerEl = selectedEl.querySelector('[data-multimedia-inner]') - if (!innerEl) throw new Error('No hay multimedia a para setear el alt') + const innerEl = selectedEl.querySelector( + "[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; + }); } diff --git a/app/javascript/editor/types/parentBlocks.ts b/app/javascript/editor/types/parentBlocks.ts index 55a8c3d8..ffe40bdf 100644 --- a/app/javascript/editor/types/parentBlocks.ts +++ b/app/javascript/editor/types/parentBlocks.ts @@ -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; + }); + } } diff --git a/app/javascript/editor/utils.ts b/app/javascript/editor/utils.ts index 7ac4c186..167c0a6d 100644 --- a/app/javascript/editor/utils.ts +++ b/app/javascript/editor/utils.ts @@ -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; } diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index 6aa3a2e1..492ca736 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -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() diff --git a/app/jobs/backtrace_job.rb b/app/jobs/backtrace_job.rb index 50ee155b..86a9b2a6 100644 --- a/app/jobs/backtrace_job.rb +++ b/app/jobs/backtrace_job.rb @@ -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 diff --git a/app/jobs/deploy_job.rb b/app/jobs/deploy_job.rb index 98e474ac..70997ce1 100644 --- a/app/jobs/deploy_job.rb +++ b/app/jobs/deploy_job.rb @@ -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 diff --git a/app/jobs/gitlab_notifier_job.rb b/app/jobs/gitlab_notifier_job.rb new file mode 100644 index 00000000..7218f68a --- /dev/null +++ b/app/jobs/gitlab_notifier_job.rb @@ -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 diff --git a/app/jobs/periodic_job.rb b/app/jobs/periodic_job.rb new file mode 100644 index 00000000..8d9453a3 --- /dev/null +++ b/app/jobs/periodic_job.rb @@ -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 diff --git a/app/jobs/stat_collection_job.rb b/app/jobs/stat_collection_job.rb new file mode 100644 index 00000000..2aa8d702 --- /dev/null +++ b/app/jobs/stat_collection_job.rb @@ -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 diff --git a/app/jobs/uri_collection_job.rb b/app/jobs/uri_collection_job.rb new file mode 100644 index 00000000..9ec333cd --- /dev/null +++ b/app/jobs/uri_collection_job.rb @@ -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 diff --git a/app/lib/exception_notifier/gitlab_notifier.rb b/app/lib/exception_notifier/gitlab_notifier.rb new file mode 100644 index 00000000..18bfc6d4 --- /dev/null +++ b/app/lib/exception_notifier/gitlab_notifier.rb @@ -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 diff --git a/app/lib/gitlab_api_client.rb b/app/lib/gitlab_api_client.rb new file mode 100644 index 00000000..5b1287d6 --- /dev/null +++ b/app/lib/gitlab_api_client.rb @@ -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 diff --git a/app/models/access_log.rb b/app/models/access_log.rb index 85cd4c36..3a066b33 100644 --- a/app/models/access_log.rb +++ b/app/models/access_log.rb @@ -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 diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb index 02b837f0..4fa588f5 100644 --- a/app/models/deploy_local.rb +++ b/app/models/deploy_local.rb @@ -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 diff --git a/app/models/indexed_post.rb b/app/models/indexed_post.rb new file mode 100644 index 00000000..7f6865f6 --- /dev/null +++ b/app/models/indexed_post.rb @@ -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 diff --git a/app/models/metadata_array.rb b/app/models/metadata_array.rb index 5c0b16f7..368aa546 100644 --- a/app/models/metadata_array.rb +++ b/app/models/metadata_array.rb @@ -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 diff --git a/app/models/metadata_belongs_to.rb b/app/models/metadata_belongs_to.rb index 0626ba0c..be1fa670 100644 --- a/app/models/metadata_belongs_to.rb +++ b/app/models/metadata_belongs_to.rb @@ -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? diff --git a/app/models/metadata_boolean.rb b/app/models/metadata_boolean.rb index 5e4b456f..90c002a7 100644 --- a/app/models/metadata_boolean.rb +++ b/app/models/metadata_boolean.rb @@ -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 diff --git a/app/models/metadata_content.rb b/app/models/metadata_content.rb index b11818a2..9d3a1040 100644 --- a/app/models/metadata_content.rb +++ b/app/models/metadata_content.rb @@ -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*" 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. diff --git a/app/models/post.rb b/app/models/post.rb index 7be6c8c8..de647758 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -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? diff --git a/app/models/post/indexable.rb b/app/models/post/indexable.rb new file mode 100644 index 00000000..7757e7f7 --- /dev/null +++ b/app/models/post/indexable.rb @@ -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 diff --git a/app/models/post_relation.rb b/app/models/post_relation.rb index 850a83dc..531d3cc4 100644 --- a/app/models/post_relation.rb +++ b/app/models/post_relation.rb @@ -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) diff --git a/app/models/site.rb b/app/models/site.rb index fe64f57e..5b78d625 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -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 diff --git a/app/models/site/index.rb b/app/models/site/index.rb new file mode 100644 index 00000000..e10fa523 --- /dev/null +++ b/app/models/site/index.rb @@ -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 diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb index d9695227..74db2549 100644 --- a/app/models/site/repository.rb +++ b/app/models/site/repository.rb @@ -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 diff --git a/app/models/stat.rb b/app/models/stat.rb new file mode 100644 index 00000000..5f72ccd0 --- /dev/null +++ b/app/models/stat.rb @@ -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 diff --git a/app/policies/post_policy.rb b/app/policies/post_policy.rb index c22202af..69ecb188 100644 --- a/app/policies/post_policy.rb +++ b/app/policies/post_policy.rb @@ -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 diff --git a/app/policies/site_stat_policy.rb b/app/policies/site_stat_policy.rb index a797034c..cb62b507 100644 --- a/app/policies/site_stat_policy.rb +++ b/app/policies/site_stat_policy.rb @@ -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 diff --git a/app/services/site_service.rb b/app/services/site_service.rb index 4f3905a5..5e2fc706 100644 --- a/app/services/site_service.rb +++ b/app/services/site_service.rb @@ -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) diff --git a/app/views/devise/confirmations/new.haml b/app/views/devise/confirmations/new.haml index b1080788..59568cb7 100644 --- a/app/views/devise/confirmations/new.haml +++ b/app/views/devise/confirmations/new.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/devise/invitations/edit.haml b/app/views/devise/invitations/edit.haml index b8bb4315..565429a8 100644 --- a/app/views/devise/invitations/edit.haml +++ b/app/views/devise/invitations/edit.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/devise/invitations/new.haml b/app/views/devise/invitations/new.haml index 8a0e318e..44ceec2e 100644 --- a/app/views/devise/invitations/new.haml +++ b/app/views/devise/invitations/new.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/devise/passwords/edit.haml b/app/views/devise/passwords/edit.haml index d5e0778c..7f7b16fb 100644 --- a/app/views/devise/passwords/edit.haml +++ b/app/views/devise/passwords/edit.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/devise/passwords/new.haml b/app/views/devise/passwords/new.haml index 75e22859..3c80b8a0 100644 --- a/app/views/devise/passwords/new.haml +++ b/app/views/devise/passwords/new.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/devise/registrations/edit.haml b/app/views/devise/registrations/edit.haml index ece85540..6a25da65 100644 --- a/app/views/devise/registrations/edit.haml +++ b/app/views/devise/registrations/edit.haml @@ -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' diff --git a/app/views/devise/registrations/new.haml b/app/views/devise/registrations/new.haml index 92a44aec..cb6ff0d1 100644 --- a/app/views/devise/registrations/new.haml +++ b/app/views/devise/registrations/new.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/devise/sessions/new.haml b/app/views/devise/sessions/new.haml index 2826be44..b5223e5f 100644 --- a/app/views/devise/sessions/new.haml +++ b/app/views/devise/sessions/new.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/devise/unlocks/new.haml b/app/views/devise/unlocks/new.haml index af5bf50b..ac511115 100644 --- a/app/views/devise/unlocks/new.haml +++ b/app/views/devise/unlocks/new.haml @@ -1,5 +1,3 @@ -= render 'layouts/breadcrumb', crumbs: nil - = content_for :body do - 'black-bg' diff --git a/app/views/env/index.js.haml b/app/views/env/index.js.haml index 68ea73a4..f4bd69cf 100644 --- a/app/views/env/index.js.haml +++ b/app/views/env/index.js.haml @@ -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']}" } diff --git a/app/views/exception_notifier/_backtrace.text.erb b/app/views/exception_notifier/_backtrace.text.erb index d62b5719..aed7adbe 100644 --- a/app/views/exception_notifier/_backtrace.text.erb +++ b/app/views/exception_notifier/_backtrace.text.erb @@ -1,4 +1,4 @@ -<% unless @data[:_backtrace] %> +<% unless @data[:javascript_backtrace] %> ``` <%= raw @backtrace.join("\n") %> ``` diff --git a/app/views/exception_notifier/_data.text.erb b/app/views/exception_notifier/_data.text.erb index 3493d68b..acb94b89 100644 --- a/app/views/exception_notifier/_data.text.erb +++ b/app/views/exception_notifier/_data.text.erb @@ -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 %> diff --git a/app/views/i18n/edit.haml b/app/views/i18n/edit.haml deleted file mode 100644 index 3e44af6f..00000000 --- a/app/views/i18n/edit.haml +++ /dev/null @@ -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' diff --git a/app/views/layouts/_breadcrumb.haml b/app/views/layouts/_breadcrumb.haml index 01765d0d..c4920bc7 100644 --- a/app/views/layouts/_breadcrumb.haml +++ b/app/views/layouts/_breadcrumb.haml @@ -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 diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 891d635c..85d5ab22 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -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] } diff --git a/app/views/posts/_form.haml b/app/views/posts/_form.haml index a9819a1b..e46b2eda 100644 --- a/app/views/posts/_form.haml +++ b/app/views/posts/_form.haml @@ -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, diff --git a/app/views/posts/attribute_ro/_float.haml b/app/views/posts/attribute_ro/_float.haml new file mode 100644 index 00000000..67642e2c --- /dev/null +++ b/app/views/posts/attribute_ro/_float.haml @@ -0,0 +1,3 @@ +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td{ dir: dir, lang: locale }= metadata.value diff --git a/app/views/posts/attribute_ro/_html.haml b/app/views/posts/attribute_ro/_html.haml new file mode 100644 index 00000000..97931960 --- /dev/null +++ b/app/views/posts/attribute_ro/_html.haml @@ -0,0 +1,3 @@ +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td{ lang: locale, dir: dir }= metadata.value.html_safe diff --git a/app/views/posts/attribute_ro/_predefined_value.haml b/app/views/posts/attribute_ro/_predefined_value.haml new file mode 100644 index 00000000..67642e2c --- /dev/null +++ b/app/views/posts/attribute_ro/_predefined_value.haml @@ -0,0 +1,3 @@ +%tr{ id: attribute } + %th= post_label_t(attribute, post: post) + %td{ dir: dir, lang: locale }= metadata.value diff --git a/app/views/posts/attributes/_content.haml b/app/views/posts/attributes/_content.haml index 235dca30..4ae70ba0 100644 --- a/app/views/posts/attributes/_content.haml +++ b/app/views/posts/attributes/_content.haml @@ -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 diff --git a/app/views/posts/attributes/_float.haml b/app/views/posts/attributes/_float.haml new file mode 100644 index 00000000..6239c613 --- /dev/null +++ b/app/views/posts/attributes/_float.haml @@ -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 diff --git a/app/views/posts/attributes/_html.haml b/app/views/posts/attributes/_html.haml new file mode 100644 index 00000000..25d5709a --- /dev/null +++ b/app/views/posts/attributes/_html.haml @@ -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) diff --git a/app/views/posts/attributes/_markdown.haml b/app/views/posts/attributes/_markdown.haml index 325beb5c..8042009f 100644 --- a/app/views/posts/attributes/_markdown.haml +++ b/app/views/posts/attributes/_markdown.haml @@ -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 diff --git a/app/views/posts/attributes/_predefined_value.haml b/app/views/posts/attributes/_predefined_value.haml new file mode 100644 index 00000000..b0d21f35 --- /dev/null +++ b/app/views/posts/attributes/_predefined_value.haml @@ -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 diff --git a/app/views/posts/edit.haml b/app/views/posts/edit.haml index 282d9d05..6ec252fe 100644 --- a/app/views/posts/edit.haml +++ b/app/views/posts/edit.haml @@ -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 diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml index 0fbcf50a..9852e128 100644 --- a/app/views/posts/index.haml +++ b/app/views/posts/index.haml @@ -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) diff --git a/app/views/posts/new.haml b/app/views/posts/new.haml index adcc843d..6ec252fe 100644 --- a/app/views/posts/new.haml +++ b/app/views/posts/new.haml @@ -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 diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index 9dd4faa0..e46114af 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -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 diff --git a/app/views/sites/_build.haml b/app/views/sites/_build.haml index 4115e970..6bc4d11b 100644 --- a/app/views/sites/_build.haml +++ b/app/views/sites/_build.haml @@ -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? diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml index d26eeebf..6f15d570 100644 --- a/app/views/sites/_form.haml +++ b/app/views/sites/_form.haml @@ -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/ diff --git a/app/views/sites/edit.haml b/app/views/sites/edit.haml index cc5977cf..4ae7308d 100644 --- a/app/views/sites/edit.haml +++ b/app/views/sites/edit.haml @@ -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) diff --git a/app/views/sites/fetch.haml b/app/views/sites/fetch.haml index 6a16e2e0..f5d049c8 100644 --- a/app/views/sites/fetch.haml +++ b/app/views/sites/fetch.haml @@ -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') diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index 07ea670a..d69dbeac 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -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 diff --git a/app/views/sites/new.haml b/app/views/sites/new.haml index fa724421..68c17882 100644 --- a/app/views/sites/new.haml +++ b/app/views/sites/new.haml @@ -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') diff --git a/app/views/stats/index.haml b/app/views/stats/index.haml index f49cdd15..bfcf33ef 100644 --- a/app/views/stats/index.haml +++ b/app/views/stats/index.haml @@ -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]) diff --git a/app/views/usuaries/index.haml b/app/views/usuaries/index.haml index c3c6c3b4..124fb04b 100644 --- a/app/views/usuaries/index.haml +++ b/app/views/usuaries/index.haml @@ -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| diff --git a/app/views/usuaries/invite.haml b/app/views/usuaries/invite.haml index 62552e70..26eb5039 100644 --- a/app/views/usuaries/invite.haml +++ b/app/views/usuaries/invite.haml @@ -1,17 +1,9 @@ - invite_as = t("usuaries.invite_as.#{params[:invite_as]}") -= render 'layouts/breadcrumb', - crumbs: [link_to(t('sites.index.title'), sites_path), - @site.name, - link_to(t('posts.index'), site_usuaries_path(@site)), - t('.title', invite_as: invite_as)] - -.row - .col +.row.justify-content-center + .col.col-md-8 %h1= t('.title', invite_as: invite_as) -.row - .col = form_with url: site_usuaries_invite_path(@site), local: true do |f| = f.hidden_field :invited_as, value: params[:invite_as].singularize .form-group diff --git a/config/database.yml b/config/database.yml index 28e195b8..cd599a24 100644 --- a/config/database.yml +++ b/config/database.yml @@ -5,25 +5,29 @@ # gem 'sqlite3' # default: &default - adapter: sqlite3 - pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> timeout: 5000 + adapter: postgresql + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + encoding: unicode development: <<: *default - database: db/development.sqlite3 + database: 'sutty' + host: 'postgresql.sutty.local' + user: <%= ENV['USER'] %> # Warning: The database defined as "test" will be erased and # re-generated from your development database when you run "rake". # Do not set this db to the same as development or production. test: <<: *default - database: db/test.sqlite3 + database: 'sutty_test' + host: 'postgresql.sutty.local' + user: <%= ENV['USER'] %> production: adapter: postgresql pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> - database: sutty + database: <%= ENV.fetch('DATABASE') { 'sutty' } %> user: sutty host: postgresql - encoding: unicode diff --git a/config/environments/production.rb b/config/environments/production.rb index b2f91a3d..d121bdbd 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -100,7 +100,7 @@ Rails.application.configure do .new(Syslog::Logger.new('sutty')) if ENV['RAILS_LOG_TO_STDOUT'].present? - logger = ActiveSupport::Logger.new(STDOUT) + logger = ActiveSupport::Logger.new($stdout) logger.formatter = config.log_formatter config.logger = ActiveSupport::TaggedLogging.new(logger) end @@ -145,16 +145,9 @@ Rails.application.configure do domain: ENV.fetch('SUTTY', 'sutty.nl'), enable_starttls_auto: false } - config.action_mailer.default_options = { from: ENV['DEFAULT_FROM'] } + config.action_mailer.default_options = { from: ENV.fetch('DEFAULT_FROM', "noreply@sutty.nl") } - config.middleware.use ExceptionNotification::Rack, - error_grouping: true, - email: { - email_prefix: '', - sender_address: ENV['DEFAULT_FROM'], - exception_recipients: ENV['EXCEPTION_TO'], - normalize_subject: true - } + config.middleware.use ExceptionNotification::Rack, gitlab: {} Rails.application.routes.default_url_options[:host] = "panel.#{ENV.fetch('SUTTY', 'sutty.nl')}" Rails.application.routes.default_url_options[:protocol] = 'https' diff --git a/config/environments/test.rb b/config/environments/test.rb index 599c5f28..bf72d234 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -73,4 +73,13 @@ Rails.application.configure do # Annotate rendered view with file names. # config.action_view.annotate_rendered_view_with_filenames = true + + config.middleware.use ExceptionNotification::Rack, + error_grouping: true, + email: { + email_prefix: '', + sender_address: ENV.fetch('DEFAULT_FROM', 'noreply@sutty.nl'), + exception_recipients: ENV.fetch('EXCEPTION_TO', 'errors@sutty.nl'), + normalize_subject: true + } end diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb index e37b2be4..66d2c92b 100644 --- a/config/initializers/core_extensions.rb +++ b/config/initializers/core_extensions.rb @@ -32,31 +32,66 @@ module ActionDispatch end # Lazy Loading de Jekyll, deshabilitando la instanciación de elementos -# que no necesitamos +# que no necesitamos. Esto permite que podamos leer el sitio por partes +# en lugar de todo junto. # # TODO: Aplicar monkey patches en otro lado... module Jekyll Reader.class_eval do + # No necesitamos otros posts def retrieve_posts(_); end + # No necesitamos otros directorios def retrieve_dirs(_, _, _); end + # No necesitamos las páginas def retrieve_pages(_, _); end + # No necesitamos los archivos estáticos def retrieve_static_files(_, _); end + + # Solo lee los datos + def read_data + @site.data = DataReader.new(site).read(site.config['data_dir']) + end + + # Lee los layouts + def read_layouts + @site.layouts = LayoutReader.new(site).read unless @site.layouts.present? + end + + # Lee todos los artículos del sitio + def read_collections + read_directories + read_included_excludes + sort_files! + CollectionReader.new(site).read + end end + # No necesitamos los archivos de la plantilla ThemeAssetsReader.class_eval do def read; end end - # Prevenir la lectura del documento + # Aplazar la lectura del documento Document.class_eval do alias_method :read!, :read def read; end + + # Permitir restablecer el documento sin crear uno nuevo + def reset + @path = @extname = @has_yaml_header = @relative_path = nil + @basename_without_ext = @data = @basename = nil + @renderer = @url_placeholders = @url = nil + @to_liquid = @write_p = @excerpt_separator = @id = nil + @related_posts = @cleaned_relative_path = self.content = nil + end end - # https://github.com/jekyll/jekyll/pull/8425 + # Prevenir la instanciación de Time + # + # @see {https://github.com/jekyll/jekyll/pull/8425} Utils.class_eval do def parse_date(input, msg = 'Input could not be parsed.') @parse_date_cache ||= {} @@ -66,3 +101,27 @@ module Jekyll end end end + +# No aplicar el orden por ranking para poder obtener los artículos en el +# orden que tendrían en el sitio final. +module PgSearch + ScopeOptions.class_eval do + def apply(scope) + scope = include_table_aliasing_for_rank(scope) + rank_table_alias = scope.pg_search_rank_table_alias(include_counter: true) + + scope.joins(rank_join(rank_table_alias)) + end + end +end + +# JekyllData::Reader del plugin jekyll-data modifica Jekyll::Site#reader +# para también leer los datos que vienen en el theme. +module JekyllData + Reader.class_eval do + def read_data + super + read_theme_data + end + end +end diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 35b309ea..0e18b987 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -11,6 +11,8 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.singular 'licencias', 'licencia' inflect.plural 'rol', 'roles' inflect.singular 'roles', 'rol' + inflect.plural 'rollup', 'rollups' + inflect.singular 'rollups', 'rollup' end ActiveSupport::Inflector.inflections(:es) do |inflect| @@ -24,4 +26,6 @@ ActiveSupport::Inflector.inflections(:es) do |inflect| inflect.singular 'roles', 'rol' inflect.plural 'licencia', 'licencias' inflect.singular 'licencias', 'licencia' + inflect.plural 'rollup', 'rollups' + inflect.singular 'rollups', 'rollup' end diff --git a/config/locales/devise.views.es.yml b/config/locales/devise.views.es.yml index d0f57934..73166afc 100644 --- a/config/locales/devise.views.es.yml +++ b/config/locales/devise.views.es.yml @@ -102,7 +102,7 @@ es: update: Actualizar mi perfil we_need_your_current_password_to_confirm_your_changes: Necesitamos tu contraseña actual para confirmar los cambios. new: - sign_up: Registrarme por primera vez + sign_up: Registrarme help: Para registrarte solo pedimos una dirección de correo y una contraseña. La contraseña se almacena de forma segura, ¡nadie más que vos la sabe! Recibirás un correo de confirmación de cuenta. signed_up: Bienvenide. Tu cuenta fue creada. signed_up_but_inactive: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque tu cuenta aún no está activada. @@ -124,8 +124,7 @@ es: forgot_your_password: "¿Has olvidado tu contraseña?" sign_in: Iniciar sesión sign_in_with_provider: Iniciar sesión con %{provider} - sign_up: Registrarme por primera vez - i_dont_have_account: ¿Nunca te registraste en LUNAR? + sign_up: Crear cuenta i_have_account: ¿Ya tenés cuenta? minimum_password_length: one: "%{count} caracter como mínimo." diff --git a/config/locales/en.yml b/config/locales/en.yml index 02570b12..b814796d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,5 +1,8 @@ en: dir: ltr + en: English + es: Castellano + es-AR: Castellano rioplatense locales: es: name: Castillian Spanish @@ -16,8 +19,8 @@ en: remember_me: 'Keeps session open for %{remember_for}' actions: sr-help: "After this form you'll find links to recover your account and other actions." - _true: Yes - _false: No + _true: 'Yes' + _false: 'No' svg: sutty: title: Sutty @@ -34,24 +37,26 @@ en: metadata: cant_be_empty: 'This field cannot be empty' image: + site_invalid: 'The image cannot be stored if the site configuration is not valid' not_an_image: 'Not an image' path_required: 'Missing image for upload' - no_file_for_description: "There's a description with no associated image" + no_file_for_description: "Description with no associated image" file: + site_invalid: 'The file cannot be stored if the site configuration is not valid' path_required: "Missing file for upload" - no_file_for_description: "There's a description with no associated file" + no_file_for_description: "Description with no associated file" event: - zone_missing: 'Timezone is incorrect' + zone_missing: 'Inexistent timezone' date_missing: 'Event date is required' date_non_parseable: 'Time is not in the correct format' time_non_parseable: 'Date is not in the correct format' - end_in_the_past: "Event end can't happen before the start" + end_in_the_past: "Event can't end before it starts" belongs_to: missing_post: "Couldn't find the related post" has_many: missing_posts: "Couldn't find some related posts" date: - invalid_format: "It seems that your browser doesn't support dates and the date is on the incorrect format, please use yyyy-mm-dd, ie. 2021-01-31" + invalid_format: "Incorrect date format, please use yyyy-mm-dd, ie. 2021-01-31" exceptions: post: site_missing: 'Needs an instance of Site' @@ -64,10 +69,10 @@ en: subject: "[Sutty] The site %{site} has been built" hi: "Hi!" explanation: | - This e-mail is to notify you that Sutty has built your site and - it's available at . + This e-mail is to notify you that Sutty has built your site, which is + available at . - You'll find details bellow. + You'll find details below. th: type: Type status: Status @@ -79,10 +84,6 @@ en: title: Link to www success: Success! error: Error - deploy_private: - title: Private version - success: Success! - error: Error deploy_zip: title: Build ZIP file success: Available for download @@ -91,22 +92,26 @@ en: title: Host as Tor Hidden Service success: Success! error: Error + deploy_private: + title: Private version + success: Success! + error: Error deploy_alternative_domain: title: Alternative domain name success: Success! error: Error - help: You can contact us by replying this e-mail + help: You can contact us by replying to this e-mail maintenance_mailer: notice: subject: 'Maintenance notice' hi: 'Hi!' - message: "We're getting in contact with you to let you know we'll be doing maintenance work in our servers." + message: "We're getting in contact with you to let you know we'll be doing maintenance work on our servers." reason: 'The reason is:' estimated_from: 'The maintenance period starts at %{from} (visit %{time_is} to convert into your time zone)' estimated_to: 'Up to %{to} (approximately, visit %{time_is} to convert into your time zone)' estimated_from_html: 'The maintenance period starts at %{from} (convert into your time zone)' estimated_to_html: 'Up to %{to} (approximately, convert into your time zone)' - thanks: 'Thanks for your patience' + thanks: 'Thank you for your patience' were_back: subject: 'Maintenance period ended' hi: 'Hi!' @@ -118,6 +123,7 @@ en: models: usuarie: User licencia: License + design: Design attributes: usuarie: email: 'E-mail address' @@ -125,31 +131,26 @@ en: password_confirmation: 'Password confirmation' current_password: 'Current password' lang: 'Main language' + remember_me: Remember me site: name: 'Name' title: 'Title' description: 'Description' - colaboracion_anonima: Enable anonymous collaboration acepta_invitades: Enable collaboration + colaboracion_anonima: Enable anonymous collaboration contact: Enable contact forms + tienda_url: Store URL + tienda_api_key: Store access key errors: models: site: attributes: deploys: deploy_local_presence: 'We need to be build the site!' - invitadx: - attributes: - email: - taken: 'This e-mail address is already taken, please choose another' - password_confirmation: - confirmation: "The passwords don't match" - acepta_politicas_de_privacidad: - no_acepta_politicas_de_privacidad: "Please read and accept the privacy policy" design_id: layout_incompatible: - error: "Design can't be changed because there're posts with incompatible layouts" - help: "Your site has posts with layout only compatible to the current design. If you change it, the site won't work as you expect. If you're trying out designs, you can delete posts in the following incompatible layouts:: %{layouts}." + error: "Design can't be changed because there are posts with incompatible layouts" + help: "Your site has posts with layouts only compatible with the current design. If you change it, the site won't work as you expect. If you're trying out designs, you can delete posts in the following incompatible layouts:: %{layouts}." errors: argument_error: 'Argument `%{argument}` must be an instance of %{class}' unknown_locale: 'Unknown %{locale} locale' @@ -162,7 +163,7 @@ en: signature: 'With love, Sutty' breadcrumb: title: 'Your location in Sutty' - logout: Exit + logout: Log out mutual_aid: Mutual aid collaborations: collaborate: @@ -177,100 +178,27 @@ en: usuarie: edit: Edit my profile category: 'Category' - i18n: - top: 'Back to top' - index: "Here you can edit your site's texts that don't belong to a post, like its description, sections, buttons... If you change languages up there in the title to be the same, you can edit them. If they're different, you can translate from one into the other." - count: 'This is the amount of texts.' - toc: 'Jump to this section' - meta: 'Metadata' - navegacion: 'Navigation' - inicio: 'Home' - volver: 'Back' - entrar: 'Enter' - cerrar: 'Close' - anchor: 'Internal links' - nav: 'Menu' - nav-lang: 'Language menu' - modulos: 'Modules' - header: 'Header' - sobre: 'About' - metodologia: 'Methodology' - planeando_recursos: 'Planning resources' - rutas: 'Agendas' - complementarios: 'Materials' - recursos: 'Resources' - contacta: 'Contact us' - agradecimientos: 'Acknowledgments' - sesion: 'Sessions' - sesiones: 'Sessions' - anexo: 'Appendix' - simple: 'Simple' sites: index: 'This is the list of sites you can edit.' - edit_translations: "You can edit texts from your site other than - posts', and you can also translate them to other languages." - edit_posts: "When you enter here, you'll see a list of every - article and edit them. You can also create new ones." enqueued: "The site is on queue to be generated. Once this process finishes, you'll get an email telling you the status. If everything went well, your site will be published :)" enqueue: "When you finish making changes to your site, you can publish them with this action. You'll receive an email when it finishes." - build_log: "This is the log for what happened during site - generation. If there was an issue, you'll see it here." - invitade: "Invited users can only add and modify entries but can't publish until reviewed by a user" invitations: - accept: 'Someone invited you to collaborate on their site. If you accept the invitation, you can access the site.' + accept: "Someone invited you to collaborate on their site. If you accept the invitation, you can access the site's edit mode." reject: "If you decline, you won't have access." + pull: 'You have pending upgrades!' close: 'Close help' - markdown: - intro: 'The text is formatted using a syntax called Markdown, a - simple format that can be easily written just by remembering some - rules, and that can be converted to a web page, or to PDF and ePub - files. You can use the buttons below for basic formatting. If - you need help, here you have a markdown - cheatsheet.' - back: 'Go back' - input: 'If we write...' - output: 'We get...' - bold: 'Bold' - italic: 'Emphasis' - heading: 'Title' - link: - text: 'A link' - url: 'https://example.org' - quote: 'A quote from a text we liked' - ul: 'Our TODO list' - ol: 'Steps for our machiavelic plans' - img: - text: 'Kéfir island' - url: 'https://kefir.red/images/isla.png' - ltr: 'Introduction' - rtl: 'مقدمة' - dir: "These are tricky. If you want to use an expression in a - language using another direction, like using an Arabic expression - on an English text, or viceversa, you have to inform both - direction and language so the markdown processor understands it - has to change it internally. Otherwise you may see out of order - words, specially in the PDF results." - distraction_free_html: 'You can have a distraction free writing session - by clicking the button' - preview_html: 'Click the preview - button to see the results.' - autocomplete_html: "Some of these fields can be auto-completed. If - you know what to put on them, just start writing and the - auto-complete will suggest available options. If the option doesn't - exist, finish writing and press Enter to add a new one. - To empty the field, click the × button on your right." deploys: deploy_local: title: 'Host at Sutty' help: | The site will be available at . - We're working out the details for allowing your own site - domains, you can help us! + We're working out the details to allow you to use your own site + domains, you can [help us](https://sutty.nl/en/index.html#contact)! ejemplo: 'example' deploy_private: title: 'Generate private version' @@ -285,20 +213,20 @@ en: When you enable this option, your site will also be available under . - The www prefix to web addresses has been a way of refering to - computers that are available on the World Wide Web. But since - the Web has become the hegemonic way of accessing the Internet, - it has become less used. Even so, people still uses them. + The www prefix has been a way of referring to + computers that are available on the World Wide Web. Since + the Web became the hegemonic mode of accessing the Internet, + it has lost popularity. All the same, some people still use them. deploy_zip: title: 'Generate a ZIP file' help: | - ZIP files contain and compress all the files of your site. With - this option you can download and also share your whole site + ZIP files contain and compress all your site's files. With + this option you can download and also share your entire site through the address, keep it as backup - or have an strategy of solidarity hosting, were many people - shares a copy of your site. + or have a strategy of solidary hosting, where many people + share a copy of your site. - It also helps with site archival for historical purposes :) + It also helps with site archiving for historical purposes! ejemplo: 'example' deploy_hidden_service: title: 'Host as Tor Hidden Service' @@ -310,7 +238,7 @@ en: anonymous**. Visitors can still access your site publicly at <%{public_url}>. - [Know more](https://sutty.nl/en/hidden-sites-with-tor/) + [Learn more](https://sutty.nl/en/hidden-sites-with-tor/) help_2: | The hidden address for your site is: @@ -322,11 +250,40 @@ en: index: title: Statistics help: | - Statistics show information about how your site is generated and + These statistics show information about how your site is generated and how many resources it uses. - build: - average: 'Average building time' - maximum: 'Maximum building time' + last_update: 'Updated every hour. Last update on ' + empty: 'There is no enough information yet. We invite you to come back in %{please_return_at}!' + loading: 'Loading...' + hour: 'Hourly' + day: 'Daily' + week: 'Weekly' + month: 'Monthly' + year: 'Yearly' + host: + title: + zero: 'Site visits' + one: 'Site visits' + other: 'Visits by domain name' + description: 'Counts visited pages on your site, grouped by domain names in use.' + urls: + title: 'Visits by URL' + description: 'Counts visits or downloads on any URL.' + label: 'URLs ("links")' + help: 'Copy and paste a single URL per line' + submit: 'Update graph' + resources: + title: 'Resource usage' + description: "In this section you can find statistics on your site's use of Sutty's shared resources" + builds: + title: 'Site publication' + description: 'Times you published your site.' + space_used: + title: 'Server disk usage' + description: 'Average storage space used by your site.' + build_time: + title: 'Publication time' + description: 'Average time your site takes to build.' sites: donations: url: 'https://donaciones.sutty.nl/en/' @@ -335,23 +292,19 @@ en: static_file_migration: 'File migration' find_and_replace: 'Search and replace' index: - title: 'Sites' + title: 'My Sites' pull: 'Upgrade' - help: 'This is the list of sites you can edit' + help: 'Here are all the sites you can edit' visit: 'Visit the site' welcome: | # Welcome! - You have no sites yet. You can generate all the sites you want + You don't have any sites yet. You can generate as many sites as you want with the **Create site** button. - Para ver los cambios, usa el botón **Publicar cambios** en cada - sitio y espera unos segundos. También recibirás un correo de - notificación. - - To see your changes, use the **Publish changes** button on each - site and wait a few seconds. You'll also receive an e-mail - notification. + To see your changes, use the **Publish changes** button that corresponds to the + site you've modified and wait a few seconds. You will receive an e-mail + notification when the changes are loaded. [Create my first site](/sites/new) repository: @@ -370,16 +323,20 @@ en: new: title: 'Create site' submit: 'Create site' + help: 'You can edit any of these options after site creation.' edit: title: 'Edit %{site}' submit: 'Save changes' + btn: 'Configuration' form: errors: - title: There're errors and we couldn't save your changes :( + title: There were errors and we couldn't save your changes :( help: Please, look for the invalid fields to fix them help: - name: "Your site's name. It can only contain numbers and letters." - design: 'Select the design for your site. You can change it later. We add more designs from time to time.' + name: "The name of your site. It can only include numbers and letters." + title: 'The title can be anything you want' + description: 'You site description that appears in search engines. Between 50 and 160 characters.' + design: 'Select the design for your site. You can change it later. We add more designs from time to time!' licencia: 'Everything we publish has automatic copyright. This means nobody can use our works without explicit permission. By using licenses, we stablish conditions by which we want to share @@ -387,14 +344,14 @@ en: privacidad: | The [privacy policy](https://sutty.nl/en/privacy-policy/) and [code of conduct](https://sutty.nl/en/code-of-conduct/) inform - your visitors about their privacy and expected conduct of the + your visitors about their privacy and the conduct expected from members of the site's community. We suggest you use the same documents Sutty uses. You can modify them as articles after creating the site. deploys: | Sutty allows you to host your site in different places at the - same time. This strategy makes your site available even when - some of them become unavailable. + same time. This strategy makes your site available even if + one of them goes down. design: title: 'Design' actions: 'Information about this design' @@ -409,26 +366,26 @@ en: title: 'Where do you want your site to be hosted?' colaboracion_anonima: title: 'Accept anonymous collaboration' - help: 'By allowing anonymous collaboration, you enable visitors to send articles without a Sutty account. Nothing is published without your consent, so make sure to check drafts regularly. This feature can expose you to attacks and violence, so we recommend you enable it with care.' + help: 'By allowing anonymous collaboration, you enable visitors to send articles without a Sutty account. Nothing is published without your consent, so make sure to check drafts regularly. This feature can expose you to attacks and violence, so we recommend you enable it with careful consideration.' acepta_invitades: title: 'Accept collaboration' help: 'By enabling this option, you can invite other people to collaborate on your site.' contact: title: 'Enable contact forms' - help: 'If your site has contact forms, you can enable them here. If your site is under spam or trolls attack, you can disable them temporarily.' + help: 'If your site has contact forms, you can enable them here. If your site is under a spam or troll attack, you can disable them temporarily.' tienda: title: Store - first_time_html: 'The store is an optional service that allows you to commertialize through your Sutty web site. To configure it, we invite you to contact us :).' - help: 'Puedes configurar tu tienda aquí.' + first_time_html: 'The store is an optional service that allows you to commercialize through your Sutty web site. To configure it, we invite you to contact us :).' + help: 'You can configure your store here.' fetch: title: 'Upgrade the site' help: - fetch: 'Any changes made to the site are saved into a _git_ repository. Git saves the differences between previous and current versions of files so we can explore them as the history of the project. Also, we can bring and send changes between repositories. In this case, every site managed with Sutty share a common root that we call [skeleton](https://0xacab.org/sutty/skel.sutty.nl). When we upgrade this skeleton, you can explore the changes here and accept them to make your site better.' + fetch: 'Any changes made to the site are saved into a _git_ repository. Git saves the differences between previous and current versions of files so we can explore them as the history of the project. Additionally, we can bring and send changes between repositories. In this case, every site managed with Sutty shares a common root that we call [skeleton](https://0xacab.org/sutty/skel.sutty.nl). When we upgrade this skeleton, you can explore the changes here and accept them to improve your site.' toc: 'Table of contents' merge: request: 'Upgrade my site with these changes' success: 'Site upgrade has been completed. Your next build will run this upgrade :)' - error: "There was an error when we were trying to upgrade your site. This could be due to conflicts that couldn't be solved automatically. We've sent a report of the issue to Sutty's admins so they already know about it. Sorry! :(" + error: "There was an error when trying to upgrade your site. This could be due to conflicts that couldn't be solved automatically. A report of the issue has already been sent to our admins. Sorry for the inconvenience! :(" message: 'Skeleton upgrade' footer: powered_by: 'is developed by' @@ -436,18 +393,22 @@ en: index: 'Translations' edit: 'Edit texts and translations' edit_same: 'Edit texts in' - translate_into: '. Translate into' + translate_into: '. Translate to' save: 'Save' change: 'Change' translate: 'Translate' jump: 'Jump to section' translating: from: 'Translating from' - to: 'into' + to: 'to' es: 'Castillian Spanish' en: 'English' ar: 'Arabic' posts: + prev: Previous page + next: Next page + empty: "There are no results for those search parameters." + caption: Post list attribute_ro: file: download: Download file @@ -464,7 +425,7 @@ en: label: Language date: label: Date - help: Date for this post. If you use a date in the future the post won't be published until you publish changes on that day. + help: Date for this post. If you choose a future date, the post won't be published until you publish changes on that day. required: label: ' (required)' feedback: 'This field cannot be empty!' @@ -483,10 +444,14 @@ en: destroy: Remove image belongs_to: empty: "(Empty)" + predefined_value: + empty: "(Empty)" + draft: + label: Draft reorder: submit: 'Save order' select: 'Select this post' - unselect: 'Deselected all' + unselect: 'Deselect all' top: 'Send to top' bottom: 'Send to bottom' up: 'Up' @@ -501,13 +466,14 @@ en: add: 'Add' filter: 'Filter' remove_filter: 'Back' + remove_filter_help: 'Remove the filter: %{filter}' categories: 'Everything' index: 'Posts' edit: 'Edit' preview: btn: 'Preliminary version' - alert: 'Not every article type has a preliminary version :)' - message: 'This is a preliminary version, use the Publish changes button back on the panel to publish the article on your site.' + alert: 'Not every article type has a preliminary version' + message: 'This is a preliminary version, use the Publish changes button back on the panel to publish the article onto your site.' open: 'Tip: You can add new options by typing them and pressing Enter' private: '🔒 The values of this field will remain private' select: @@ -529,6 +495,10 @@ en: blank: Nothing destroy: Delete confirm_destroy: Are you sure? + form: + errors: + title: There are some errors on the form + help: Please, verify that all values are correct. usuaries: invite_as: usuaries: users @@ -542,17 +512,17 @@ en: demote: Removes privileges for this user promote: Gives privileges to this guest invite: 'Invites %{invite_as} to this site' - public_invite: Copy this address and share it with everyone you want to accept public collaborations. These guests can only create and modify their own posts and any change needs to be approved. + public_invite: Copy this address and share it with everyone you want to collaborate on your site. These guests can only create and modify their own posts and any change needs to be approved. title: Users and Guests usuaries: Users invitades: Guests destroy: text: 'Remove access' - confirm: "Remove access to this site? The account itself is not deleted, but it won't be able to make changes to this site." + confirm: "Remove access to this site? The account itself will not be deleted, but it won't be able to make changes to this site." denied: 'The site needs at least one user!' demote: text: 'Convert to guest' - confirm: 'Convert to guest? They can only edit their own posts and will need approval from other user to publish them.' + confirm: 'Convert to guest? They can only edit their own posts and will need approval from another user to publish them.' denied: 'The site needs at least one user!' promote: text: 'Convert to user' @@ -574,10 +544,10 @@ en: lockbox: help: title: Encrypted content - description: The field contents are encrypted before being stored and won't be available on the public website or its source code. You can save private information here and it will only be readable to this site's users through Sutty's panel. - decryption_error: There was an error trying to decrypt the content, Sutty's team has been notified! + description: The field contents are encrypted before being stored and won't be available on the public website or its source code. You can save private information here and it will only be readable to this site's users through the Sutty panel. + decryption_error: There was an error trying to decrypt the content. The Sutty team has been notified. editor: - alert: "Hi! This our new editor, supporting more formats. Editors are complex machines and we can only polish them with your help. If you have a few minutes, [we'll like to read about your experience](https://sutty.nl/en/#contact) :)" + alert: "Hi! This our new editor, which supports more formats. Editors are complex machines and we can only polish them with your help. If you have a few minutes, [we would like to read about your experience](https://sutty.nl/en/#contact) :)" bold: Bold italic: Emphasis deleted: Strikethrough @@ -599,14 +569,15 @@ en: right: Right center: Center color: Color + text-color: Text color multimedia: Media multimedia-select: Select file multimedia-upload: Upload multimedia-remove: Remove media - description: Description for blind people and search engines + description: Description for the visually impaired and search engines url: Address more: More heading levels - word: "If you're pasting from an hegemonic word processor, please be patient with us, it's not always simple to recover the whole format :)" + word: "If you're pasting from another word processor, please be patient with us, as it isn't always simple to recover the whole format" email_address: address_unknown: "the address is unknown" domain_does_not_accept_email: "this domain is not configured to accept email" @@ -622,8 +593,19 @@ en: ip_address_no_localhost: "localhost IP addresses are not allowed" ipv4_address_invalid: "the address is not a valid IPv4 address" ipv6_address_invalid: "the address is not a valid IPv6 address" - local_size_long: "the account name is too long" - local_size_short: "the account name is too short" + local_size_long: "account name too long" + local_size_short: "account name too short" local_invalid: "format is incorrect" - not_allowed: "is not welcome here" - server_not_available: "the remote email server is not available" + not_allowed: "that email provider is not welcome here" + server_not_available: "remote email server not available" + loaf: + breadcrumbs: + sites: + index: 'My sites' + new: 'Create' + edit: 'Configure' + posts: + new: 'New %{layout}' + edit: 'Editing' + usuaries: + index: 'Users' diff --git a/config/locales/es.yml b/config/locales/es.yml index 5bb4a221..a6fbd407 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,7 @@ es: + es: Castellano + en: English + es-AR: Castellano Rioplatense dir: ltr locales: es: @@ -16,8 +19,8 @@ es: remember_me: 'Mantiene la sesión abierta por %{remember_for}' actions: sr-help: 'Después del formulario encontrarás vínculos para recuperar tu cuenta, entre otras acciones.' - _true: Sí - _false: No + _true: 'Sí' + _false: 'No' svg: sutty: title: Sutty @@ -34,10 +37,12 @@ es: metadata: cant_be_empty: 'El campo no puede estar vacío' image: + site_invalid: 'La imagen no se puede almacenar si la configuración del sitio no es válida' not_an_image: 'No es una imagen' path_required: 'Se necesita una imagen' no_file_for_description: 'Se envió una descripción sin imagen asociada' file: + site_invalid: 'El archivo no se puede almacenar si la configuración del sitio no es válida' path_required: 'Se necesita un archivo' no_file_for_description: 'se envió una descripción sin archivo asociado' event: @@ -158,7 +163,7 @@ es: signature: 'Con cariño, Sutty' breadcrumb: title: 'Tu ubicación en Sutty' - logout: Salir + logout: Cerrar sesión mutual_aid: Ayuda mutua collaborations: collaborate: @@ -173,45 +178,9 @@ es: usuarie: edit: Editar mi perfil category: 'Categoría' - i18n: - top: 'Volver al principio' - index: 'Aquí puedes editar todos los textos del sitio que no se - corresponden con artículos, como la descripción, secciones, textos - de botones... Si cambias los idiomas arriba para que coincidan, - puedes editar los textos en el mismo idioma. Si los idiomas no - coinciden, puedes traducirlos de uno a otro.' - count: 'Esta es la cantidad de textos.' - toc: 'Saltar hasta esta sección' - meta: 'Metadata' - navegacion: 'Navegación' - inicio: 'Inicio' - volver: 'Volver' - entrar: 'Entrar' - cerrar: 'Cerrar' - anchor: 'Links internos' - nav: 'Menú' - nav-lang: 'Menú de idiomas' - modulos: 'Módulos' - header: 'Portada' - sobre: 'Acerca' - metodologia: 'Metodología' - planeando_recursos: 'Planeando recursos' - rutas: 'Rutas' - complementarios: 'Materiales complementarios' - recursos: 'Recursos' - contacta: 'Contacta' - agradecimientos: 'Agradecimientos' - sesion: 'Sesiones' - sesiones: 'Sesiones' - anexo: 'Anexo' - simple: 'Simple' sites: index: 'Este es el listado de sitios que puedes editar.' - edit_translations: 'Puedes editar los textos que salen en tu sitio - que no corresponden a artículos aquí, además de traducirlos a - otros idiomas.' - edit_posts: 'Aquí verás el listado de todos los artículos y podrás - editarlos o crear nuevos' + edit_posts: 'Aquí verás el listado de todos los artículos y podrás editarlos o crear nuevos' enqueued: 'El sitio está en la cola de espera para ser generado. Una vez que este proceso termine, recibirás un correo indicando el estado y si todo fue bien, se publicarán los cambios en tu sitio @@ -219,52 +188,11 @@ es: enqueue: 'Cuando termines de hacer cambios en tu sitio, puedes publicarlos con esta acción. Al finalizar recibirás un correo avisándote cómo fue todo.' - build_log: 'Este es el registro de lo que sucedió mientras se - generaba el sitio. Si hubo algún problema, saldrá aquí.' - invitade: 'Les invitades a un sitio solo pueden crear y modificar entradas propias y no pueden publicar sin la revisión de une usuarie' invitations: accept: 'Alguien te invitó a colaborar en su sitio. Si aceptas la invitación, tendrás acceso a este sitio.' reject: 'Si rechazas la invitación, no tendrás acceso.' pull: 'Tienes actualizaciones pendientes :)' close: 'Cerrar ayuda' - markdown: - intro: 'El formato del texto se llama Markdown. Es un formato - simple que puede ser escrito recordando unas pocas reglas y que - luego puede ser convertido a una página web, o un PDF, o un ePub. - Puedes usar los botones para dar formato básico a tu texto. Para - cuestiones más avanzadas, aquí tienes un recordatorio de markdown.' - back: 'Volver' - input: 'Si escribimos...' - output: 'Obtenemos...' - bold: 'Negrita' - italic: 'Énfasis' - heading: 'Título' - link: - text: 'Un vínculo' - url: 'https://ejemplo.org' - quote: 'Un recorte de texto que nos gustó' - ul: 'Lista de cosas para hacer' - ol: 'Pasos para un plan maquiavélico' - img: - text: 'La isla de Kéfir' - url: 'https://kefir.red/images/isla.png' - ltr: 'Introducción' - rtl: 'مقدمة' - dir: 'Esta sintaxis puede ser complicada. Si quieres mezclar - expresiones en idiomas que usan otra dirección, como usar una - expresión en árabe en medio de un texto en castellano, o - viceversa, tienes que especificar la dirección y el idioma de esta - manera. De otra forma verás palabras fuera de orden, - especialmente en la versión en PDF.' - distraction_free_html: 'Puedes escribir sin distracciones presionando el botón ' - preview_html: 'Utiliza el botón de previsualización para ver el texto generado' - autocomplete_html: 'Algunos de estos campos se autocompletan. Si ya - sabes qué quieres poner, solo empieza a escribir y el autocompletado - te sugerirá las opciones disponibles. Si no existe lo que quieres - poner, termina de escribir y presiona Entrar para agregar - opciones que aun no existen. Para vaciar las opciones, usa el botón - × a la derecha.' deploys: deploy_local: title: 'Alojar en Sutty' @@ -329,9 +257,38 @@ es: help: | Las estadísticas visibilizan información sobre cómo se genera y cuántos recursos utiliza tu sitio. - build: - average: 'Tiempo promedio de generación' - maximum: 'Tiempo máximo de generación' + last_update: 'Actualizadas cada hora. Última actualización hace ' + empty: 'Todavía no hay información suficiente. Te invitamos a volver en %{please_return_at} :)' + loading: 'Cargando...' + hour: 'Por hora' + day: 'Diarias' + week: 'Semanales' + month: 'Mensuales' + year: 'Anuales' + host: + title: + zero: 'Visitas del sitio' + one: 'Visitas del sitio' + other: 'Visitas agrupadas por nombre de dominio del sitio' + description: 'Cuenta la cantidad de páginas visitadas en tu sitio.' + urls: + title: 'Visitas por dirección' + description: 'Cantidad de visitas o descargas por dirección.' + label: 'Direcciones web (URL, "links", vínculos)' + help: 'Copia y pega una dirección por línea.' + submit: 'Actualizar gráfico' + resources: + title: 'Uso de recursos' + description: 'En esta sección podrás acceder a estadísticas del uso de recursos compartidos con otros sitios alojados en Sutty.' + builds: + title: 'Publicaciones del sitio' + description: 'Cantidad de veces que publicaste tu sitio.' + space_used: + title: 'Espacio utilizado en el servidor' + description: 'Espacio en disco que ocupa en promedio tu sitio.' + build_time: + title: 'Tiempo de publicación' + description: 'Tiempo promedio que toma en publicarse tu sitio.' sites: donations: url: 'https://donaciones.sutty.nl/' @@ -340,9 +297,9 @@ es: static_file_migration: 'Migración de archivos' find_and_replace: 'Búsqueda y reemplazo' index: - title: 'Sitios' + title: 'Mis sitios' pull: 'Actualizar' - help: 'Este es el listado de sitios que puedes editar' + help: 'Acá están todos los sitios que puedes editar' visit: 'Visitar el sitio' welcome: | # ¡Bienvenide! @@ -456,6 +413,9 @@ es: en: 'inglés' ar: 'árabe' posts: + prev: Página anterior + next: Página siguiente + empty: No hay artículos con estos parámetros de búsqueda. caption: Lista de artículos attribute_ro: file: @@ -492,6 +452,10 @@ es: destroy: 'Eliminar imagen' belongs_to: empty: "(Vacío)" + predefined_value: + empty: "(Vacío)" + draft: + label: Borrador reorder: submit: 'Guardar orden' select: 'Seleccionar este artículo' @@ -511,6 +475,7 @@ es: add: 'Agregar' filter: 'Filtrar' remove_filter: 'Volver' + remove_filter_help: 'Quitar este filtro: %{filter}' index: 'Artículos' edit: 'Editar' preview: @@ -612,6 +577,7 @@ es: right: Derecha center: Centro color: Color + text-color: Color del texto multimedia: Multimedia multimedia-select: Seleccionar archivo multimedia-upload: Subir @@ -640,3 +606,14 @@ es: local_invalid: "el formato es incorrecto" not_allowed: "no es bienvenida" server_not_available: "el proveedor no está disponible" + loaf: + breadcrumbs: + sites: + index: 'Mis sitios' + new: 'Crear' + edit: 'Configurar' + posts: + new: 'Nuevo %{layout}' + edit: 'Editando' + usuaries: + index: 'Usuaries' diff --git a/config/puma.rb b/config/puma.rb index 48ff0e62..414507ed 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -19,8 +19,15 @@ worker_timeout 3600 if ENV.fetch('RAILS_ENV', 'development') == 'development' # Specifies the `port` that Puma will listen on to receive requests; # default is 3000. # -# port ENV.fetch('PORT') { 3000 } -bind 'tcp://[::]:3100' +# XXX: Por alguna razón el puerto en el contenedor es históricamente +# 3100, aunque en desarrollo es 3000. En algún momento deberíamos +# establecer un solo puerto. +if ENV['RAILS_ENV'] == 'production' + bind 'tcp://[::]:3100' +else + sutty = ENV.fetch('SUTTY', 'sutty.local') + bind "ssl://[::]:3000?key=/etc/ssl/private/#{sutty}.key&cert=/etc/ssl/certs/#{sutty}.crt" +end # Specifies the `environment` that Puma will run in. # diff --git a/config/routes.rb b/config/routes.rb index 3a807285..2aa0056f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -8,8 +8,6 @@ Rails.application.routes.draw do root 'application#index' - get 'markdown', to: 'application#markdown' - constraints(Constraints::ApiSubdomain.new) do scope module: 'api' do namespace :v1 do @@ -33,7 +31,8 @@ Rails.application.routes.draw do # detectar el nombre del sitio. get '/sites/private/:site_id(*file)', to: 'private#show', constraints: { site_id: %r{[^/]+} } # Obtener archivos estáticos desde el directorio público - get '/sites/:site_id/static_file/(*file)', to: 'sites#static_file', as: 'site_static_file', constraints: { site_id: %r{[^/]+} } + get '/sites/:site_id/static_file/(*file)', to: 'sites#static_file', as: 'site_static_file', + constraints: { site_id: %r{[^/]+} } get '/env.js', to: 'env#index' match '/api/v3/projects/:site_id/notices' => 'api/v1/notices#create', via: %i[post] @@ -58,9 +57,10 @@ Rails.application.routes.draw do # Gestionar artículos según idioma nested do - scope '(:locale)' do + scope '/(:locale)', constraint: /[a-z]{2}/ do post :'posts/reorder', to: 'posts#reorder' resources :posts do + get 'p/:page', action: :index, on: :collection get :preview, to: 'posts#preview' end end @@ -76,5 +76,8 @@ Rails.application.routes.draw do post 'reorder_posts', to: 'sites#reorder_posts' resources :stats, only: [:index] + get :'stats/host', to: 'stats#host' + get :'stats/uris', to: 'stats#uris' + get :'stats/resources', to: 'stats#resources' end end diff --git a/config/webpacker.yml b/config/webpacker.yml index 25519907..d555770d 100644 --- a/config/webpacker.yml +++ b/config/webpacker.yml @@ -56,8 +56,8 @@ development: # Reference: https://webpack.js.org/configuration/dev-server/ dev_server: https: - key: '../sutty.local/domain/sutty.local.key' - cert: '../sutty.local/domain/sutty.local.crt' + key: '/etc/ssl/private/sutty.local.key' + cert: '/etc/ssl/certs/sutty.local.crt' # XXX: esto está hardcodeado, debería conseguirlo del ENV host: sutty.local port: 3035 diff --git a/db/migrate/20210504224144_create_pg_search_extensions.rb b/db/migrate/20210504224144_create_pg_search_extensions.rb new file mode 100644 index 00000000..18eebe95 --- /dev/null +++ b/db/migrate/20210504224144_create_pg_search_extensions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Habilitar las extensiones de búsqueda de texto libre +class CreatePgSearchExtensions < ActiveRecord::Migration[6.1] + def change + enable_extension 'plpgsql' + enable_extension 'pg_trgm' + end +end diff --git a/db/migrate/20210504224343_create_indexed_posts.rb b/db/migrate/20210504224343_create_indexed_posts.rb new file mode 100644 index 00000000..9cf21538 --- /dev/null +++ b/db/migrate/20210504224343_create_indexed_posts.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# Crea la tabla donde se indexa el contenido de los artículos, los +# IndexedPosts van a estar relacionados con un Post del mismo UUID. +# +# Solo contienen la información mínima necesaria para mostrar los +# resultados de búsqueda. +class CreateIndexedPosts < ActiveRecord::Migration[6.1] + def change + # Necesario para gen_random_uuid() + # + # XXX: En realidad no lo necesitamos porque cada IndexedPost va a + # tener el UUID del Post correspondiente. + enable_extension 'pgcrypto' + + create_table :indexed_posts, id: false do |t| + t.primary_key :id, :uuid, default: 'public.gen_random_uuid()' + t.belongs_to :site, index: true + t.timestamps + + # Filtramos por idioma + t.string :locale, default: 'simple', index: true + # Vamos a querer filtrar por layout + t.string :layout, null: false, index: true + # Esta es la ruta al artículo + t.string :path, null: false + # Queremos mostrar el título por separado + t.string :title, default: '' + # También vamos a mostrar las categorías + t.jsonb :front_matter, default: '{}' + t.string :content, default: '' + t.tsvector :indexed_content + + t.index :indexed_content, using: 'gin' + t.index :front_matter, using: 'gin' + end + + # Crea un trigger que actualiza el índice tsvector con el título y + # contenido del artículo y su idioma. + create_trigger(compatibility: 1).on(:indexed_posts).before(:insert, :update) do + "new.indexed_content := to_tsvector(('pg_catalog.' || new.locale)::regconfig, coalesce(new.title, '') || '\n' || coalesce(new.content,''));" + end + end +end diff --git a/db/migrate/20210506212356_add_order_to_indexed_posts.rb b/db/migrate/20210506212356_add_order_to_indexed_posts.rb new file mode 100644 index 00000000..4b1a9fcf --- /dev/null +++ b/db/migrate/20210506212356_add_order_to_indexed_posts.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Agrega el orden y fecha del post en el post indexado, de forma que los +# resultados se puedan obtener en el mismo orden. +class AddOrderToIndexedPosts < ActiveRecord::Migration[6.1] + def change + add_column :indexed_posts, :order, :integer, default: 0 + end +end diff --git a/db/migrate/20210507221120_add_dictionary_to_indexed_posts.rb b/db/migrate/20210507221120_add_dictionary_to_indexed_posts.rb new file mode 100644 index 00000000..f79309fd --- /dev/null +++ b/db/migrate/20210507221120_add_dictionary_to_indexed_posts.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Para no estar calculando todo el tiempo el diccionario del idioma, +# agregamos una columna más. +class AddDictionaryToIndexedPosts < ActiveRecord::Migration[6.1] + LOCALES = { + 'english' => 'en', + 'spanish' => 'es' + } + + def up + add_column :indexed_posts, :dictionary, :string + + create_trigger(compatibility: 1).on(:indexed_posts).before(:insert, :update) do + "new.indexed_content := to_tsvector(('pg_catalog.' || new.dictionary)::regconfig, coalesce(new.title, '') || '\n' || coalesce(new.content,''));" + end + + IndexedPost.find_each do |post| + locale = post.locale + + post.update dictionary: locale, locale: LOCALES[locale] + end + end + + def down + remove_column :indexed_posts, :locale + rename_column :indexed_posts, :dictionary, :locale + end +end diff --git a/db/migrate/20210511211357_change_bytes_to_big_int.rb b/db/migrate/20210511211357_change_bytes_to_big_int.rb new file mode 100644 index 00000000..4db65308 --- /dev/null +++ b/db/migrate/20210511211357_change_bytes_to_big_int.rb @@ -0,0 +1,5 @@ +class ChangeBytesToBigInt < ActiveRecord::Migration[6.1] + def change + change_column :build_stats, :bytes, :bigint + end +end diff --git a/db/migrate/20210722191718_add_request_uri_to_access_logs.rb b/db/migrate/20210722191718_add_request_uri_to_access_logs.rb new file mode 100644 index 00000000..3d225aed --- /dev/null +++ b/db/migrate/20210722191718_add_request_uri_to_access_logs.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Agrega la columna de request_uri a la tabla de logs +class AddRequestUriToAccessLogs < ActiveRecord::Migration[6.1] + def change + return unless Rails.env.production? + + add_column :access_logs, :request_uri, :string, default: '' + end +end diff --git a/db/migrate/20210807003928_create_rollups.rb b/db/migrate/20210807003928_create_rollups.rb new file mode 100644 index 00000000..932513a4 --- /dev/null +++ b/db/migrate/20210807003928_create_rollups.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Crear la tabla de Rollups +class CreateRollups < ActiveRecord::Migration[6.1] + def change + create_table :rollups do |t| + t.string :name, null: false + t.string :interval, null: false + t.datetime :time, null: false + t.jsonb :dimensions, null: false, default: {} + t.float :value + end + add_index :rollups, %i[name interval time dimensions], unique: true + end +end diff --git a/db/migrate/20210807004941_add_create_at_to_access_logs.rb b/db/migrate/20210807004941_add_create_at_to_access_logs.rb new file mode 100644 index 00000000..0e106061 --- /dev/null +++ b/db/migrate/20210807004941_add_create_at_to_access_logs.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Cambia los msec a datetime para poder agregar por tiempos +class AddCreateAtToAccessLogs < ActiveRecord::Migration[6.1] + def up + add_column :access_logs, :created_at, :datetime, precision: 6 + + create_trigger(compatibility: 1).on(:access_logs).before(:insert) do + 'new.created_at := to_timestamp(new.msec)' + end + + ActiveRecord::Base.connection.execute('update access_logs set created_at = to_timestamp(msec);') + end + + def down + remove_column :access_logs, :created_at + end +end diff --git a/db/migrate/20210926205448_add_uniqueness.rb b/db/migrate/20210926205448_add_uniqueness.rb new file mode 100644 index 00000000..7399ba4c --- /dev/null +++ b/db/migrate/20210926205448_add_uniqueness.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Agrega índices únicos que pensábamos que ya existían. +class AddUniqueness < ActiveRecord::Migration[6.1] + def change + add_index :designs, :name, unique: true + add_index :designs, :gem, unique: true + add_index :licencias, :name, unique: true + end +end diff --git a/db/migrate/20211008201239_create_stats.rb b/db/migrate/20211008201239_create_stats.rb new file mode 100644 index 00000000..e1aff8f6 --- /dev/null +++ b/db/migrate/20211008201239_create_stats.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Una tabla que lleva el recuento de recolección de estadísticas, solo +# es necesario para saber cuándo se hicieron, si se hicieron y usar como +# caché. +class CreateStats < ActiveRecord::Migration[6.1] + def change + create_table :stats do |t| + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 4df09176..107e7be7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2,195 +2,372 @@ # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. # -# This file is the source Rails uses to define your schema when running `rails -# db:schema:load`. When creating a new database, `rails db:schema:load` tends to +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to # be faster and is potentially less error prone than running all of your # migrations from scratch. Old migrations may fail to apply correctly if those # migrations use external dependencies or application code. # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_200_816_003_344) do - create_table 'action_text_rich_texts', force: :cascade do |t| - t.string 'name', null: false - t.text 'body' - t.string 'record_type', null: false - t.integer 'record_id', null: false - t.datetime 'created_at', precision: 6, null: false - t.datetime 'updated_at', precision: 6, null: false - t.index %w[record_type record_id name], name: 'index_action_text_rich_texts_uniqueness', unique: true +ActiveRecord::Schema.define(version: 2021_05_14_165639) do + + # These are extensions that must be enabled in order to support this database + enable_extension "pg_trgm" + enable_extension "pgcrypto" + enable_extension "plpgsql" + + create_table "access_logs", id: :uuid, default: nil, force: :cascade do |t| + t.string "host" + t.float "msec" + t.string "server_protocol" + t.string "request_method" + t.string "request_completion" + t.string "uri" + t.string "query_string" + t.integer "status" + t.string "sent_http_content_type" + t.string "sent_http_content_encoding" + t.string "sent_http_etag" + t.string "sent_http_last_modified" + t.string "http_accept" + t.string "http_accept_encoding" + t.string "http_accept_language" + t.string "http_pragma" + t.string "http_cache_control" + t.string "http_if_none_match" + t.string "http_dnt" + t.string "http_user_agent" + t.string "http_origin" + t.float "request_time" + t.integer "bytes_sent" + t.integer "body_bytes_sent" + t.integer "request_length" + t.string "http_connection" + t.string "pipe" + t.integer "connection_requests" + t.string "geoip2_data_country_name" + t.string "geoip2_data_city_name" + t.string "ssl_server_name" + t.string "ssl_protocol" + t.string "ssl_early_data" + t.string "ssl_session_reused" + t.string "ssl_curves" + t.string "ssl_ciphers" + t.string "ssl_cipher" + t.string "sent_http_x_xss_protection" + t.string "sent_http_x_frame_options" + t.string "sent_http_x_content_type_options" + t.string "sent_http_strict_transport_security" + t.string "nginx_version" + t.integer "pid" + t.string "remote_user" + t.boolean "crawler", default: false + t.string "http_referer" + t.index ["geoip2_data_city_name"], name: "index_access_logs_on_geoip2_data_city_name" + t.index ["geoip2_data_country_name"], name: "index_access_logs_on_geoip2_data_country_name" + t.index ["host"], name: "index_access_logs_on_host" + t.index ["http_origin"], name: "index_access_logs_on_http_origin" + t.index ["http_user_agent"], name: "index_access_logs_on_http_user_agent" + t.index ["status"], name: "index_access_logs_on_status" + t.index ["uri"], name: "index_access_logs_on_uri" end - create_table 'active_storage_attachments', force: :cascade do |t| - t.string 'name', null: false - t.string 'record_type', null: false - t.integer 'record_id', null: false - t.integer 'blob_id', null: false - t.datetime 'created_at', null: false - t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' - t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', unique: true + create_table "action_text_rich_texts", force: :cascade do |t| + t.string "name", null: false + t.text "body" + t.string "record_type", null: false + t.bigint "record_id", null: false + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end - create_table 'active_storage_blobs', force: :cascade do |t| - t.string 'key', null: false - t.string 'filename', null: false - t.string 'content_type' - t.text 'metadata' - t.bigint 'byte_size', null: false - t.string 'checksum', null: false - t.datetime 'created_at', null: false - t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true + create_table "active_storage_attachments", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end - create_table 'build_stats', force: :cascade do |t| - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'deploy_id' - t.integer 'bytes' - t.float 'seconds' - t.string 'action', null: false - t.text 'log' - t.index ['deploy_id'], name: 'index_build_stats_on_deploy_id' + create_table "active_storage_blobs", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end - create_table 'deploys', force: :cascade do |t| - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'site_id' - t.string 'type' - t.text 'values' - t.index ['site_id'], name: 'index_deploys_on_site_id' - t.index ['type'], name: 'index_deploys_on_type' + create_table "active_storage_variant_records", force: :cascade do |t| + t.bigint "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end - create_table 'designs', force: :cascade do |t| - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.string 'name' - t.text 'description' - t.string 'gem' - t.string 'url' - t.string 'license' - t.boolean 'disabled', default: false - t.text 'credits' - t.string 'designer_url' + create_table "blazer_audits", force: :cascade do |t| + t.bigint "user_id" + t.bigint "query_id" + t.text "statement" + t.string "data_source" + t.datetime "created_at" + t.index ["query_id"], name: "index_blazer_audits_on_query_id" + t.index ["user_id"], name: "index_blazer_audits_on_user_id" end - create_table 'licencias', force: :cascade do |t| - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.string 'name' - t.text 'description' - t.text 'deed' - t.string 'url' - t.string 'icons' + create_table "blazer_checks", force: :cascade do |t| + t.bigint "creator_id" + t.bigint "query_id" + t.string "state" + t.string "schedule" + t.text "emails" + t.text "slack_channels" + t.string "check_type" + t.text "message" + t.datetime "last_run_at" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["creator_id"], name: "index_blazer_checks_on_creator_id" + t.index ["query_id"], name: "index_blazer_checks_on_query_id" end - create_table 'log_entries', force: :cascade do |t| - t.datetime 'created_at', precision: 6, null: false - t.datetime 'updated_at', precision: 6, null: false - t.integer 'site_id' - t.text 'text' - t.index ['site_id'], name: 'index_log_entries_on_site_id' + create_table "blazer_dashboard_queries", force: :cascade do |t| + t.bigint "dashboard_id" + t.bigint "query_id" + t.integer "position" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["dashboard_id"], name: "index_blazer_dashboard_queries_on_dashboard_id" + t.index ["query_id"], name: "index_blazer_dashboard_queries_on_query_id" end - create_table 'maintenances', force: :cascade do |t| - t.datetime 'created_at', precision: 6, null: false - t.datetime 'updated_at', precision: 6, null: false - t.text 'message' - t.datetime 'estimated_from' - t.datetime 'estimated_to' - t.boolean 'are_we_back', default: false + create_table "blazer_dashboards", force: :cascade do |t| + t.bigint "creator_id" + t.text "name" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["creator_id"], name: "index_blazer_dashboards_on_creator_id" end - create_table 'mobility_string_translations', force: :cascade do |t| - t.string 'locale', null: false - t.string 'key', null: false - t.string 'value' - t.string 'translatable_type' - t.integer 'translatable_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index %w[translatable_id translatable_type key], name: 'index_mobility_string_translations_on_translatable_attribute' - t.index %w[translatable_id translatable_type locale key], name: 'index_mobility_string_translations_on_keys', unique: true - t.index %w[translatable_type key value locale], name: 'index_mobility_string_translations_on_query_keys' + create_table "blazer_queries", force: :cascade do |t| + t.bigint "creator_id" + t.string "name" + t.text "description" + t.text "statement" + t.string "data_source" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.index ["creator_id"], name: "index_blazer_queries_on_creator_id" end - create_table 'mobility_text_translations', force: :cascade do |t| - t.string 'locale', null: false - t.string 'key', null: false - t.text 'value' - t.string 'translatable_type' - t.integer 'translatable_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index %w[translatable_id translatable_type key], name: 'index_mobility_text_translations_on_translatable_attribute' - t.index %w[translatable_id translatable_type locale key], name: 'index_mobility_text_translations_on_keys', unique: true + create_table "build_stats", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "deploy_id" + t.bigint "bytes" + t.float "seconds" + t.string "action", null: false + t.text "log" + t.boolean "status", default: false + t.index ["deploy_id"], name: "index_build_stats_on_deploy_id" end - create_table 'roles', force: :cascade do |t| - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'site_id' - t.integer 'usuarie_id' - t.string 'rol' - t.boolean 'temporal' - t.index %w[site_id usuarie_id], name: 'index_roles_on_site_id_and_usuarie_id', unique: true - t.index ['site_id'], name: 'index_roles_on_site_id' - t.index ['usuarie_id'], name: 'index_roles_on_usuarie_id' + create_table "csp_reports", id: :uuid, default: nil, force: :cascade do |t| + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "disposition" + t.string "referrer" + t.string "blocked_uri" + t.string "document_uri" + t.string "effective_directive" + t.string "original_policy" + t.string "script_sample" + t.string "status_code" + t.string "violated_directive" + t.integer "column_number" + t.integer "line_number" + t.string "source_file" end - create_table 'sites', force: :cascade do |t| - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.string 'name' - t.integer 'design_id' - t.integer 'licencia_id' - t.string 'status', default: 'waiting' - t.text 'description' - t.string 'title' - t.boolean 'colaboracion_anonima', default: false - t.boolean 'contact', default: false - t.string 'private_key_ciphertext' - t.index ['design_id'], name: 'index_sites_on_design_id' - t.index ['licencia_id'], name: 'index_sites_on_licencia_id' - t.index ['name'], name: 'index_sites_on_name', unique: true + create_table "deploys", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "site_id" + t.string "type" + t.text "values" + t.index ["site_id"], name: "index_deploys_on_site_id" + t.index ["type"], name: "index_deploys_on_type" end - create_table 'usuaries', force: :cascade do |t| - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.string 'email', default: '', null: false - t.string 'encrypted_password', default: '', null: false - t.string 'reset_password_token' - t.datetime 'reset_password_sent_at' - t.datetime 'remember_created_at' - t.string 'confirmation_token' - t.datetime 'confirmed_at' - t.datetime 'confirmation_sent_at' - t.string 'unconfirmed_email' - t.integer 'failed_attempts', default: 0, null: false - t.string 'unlock_token' - t.datetime 'locked_at' - t.boolean 'acepta_politicas_de_privacidad', default: false - t.string 'invitation_token' - t.datetime 'invitation_created_at' - t.datetime 'invitation_sent_at' - t.datetime 'invitation_accepted_at' - t.integer 'invitation_limit' - t.string 'invited_by_type' - t.integer 'invited_by_id' - t.integer 'invitations_count', default: 0 - t.string 'lang', default: 'es' - t.index ['confirmation_token'], name: 'index_usuaries_on_confirmation_token', unique: true - t.index ['email'], name: 'index_usuaries_on_email', unique: true - t.index ['invitation_token'], name: 'index_usuaries_on_invitation_token', unique: true - t.index ['invitations_count'], name: 'index_usuaries_on_invitations_count' - t.index ['invited_by_id'], name: 'index_usuaries_on_invited_by_id' - t.index %w[invited_by_type invited_by_id], name: 'index_usuaries_on_invited_by_type_and_invited_by_id' - t.index ['reset_password_token'], name: 'index_usuaries_on_reset_password_token', unique: true - t.index ['unlock_token'], name: 'index_usuaries_on_unlock_token', unique: true + create_table "designs", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "name" + t.text "description" + t.string "gem" + t.string "url" + t.string "license" + t.boolean "disabled", default: false + t.text "credits" + t.string "designer_url" + end + + create_table "indexed_posts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.bigint "site_id" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.string "locale", default: "simple" + t.string "layout", null: false + t.string "path", null: false + t.string "title", default: "" + t.jsonb "front_matter", default: "{}" + t.string "content", default: "" + t.tsvector "indexed_content" + t.integer "order", default: 0 + t.string "dictionary" + t.index ["front_matter"], name: "index_indexed_posts_on_front_matter", using: :gin + t.index ["indexed_content"], name: "index_indexed_posts_on_indexed_content", using: :gin + t.index ["layout"], name: "index_indexed_posts_on_layout" + t.index ["locale"], name: "index_indexed_posts_on_locale" + t.index ["site_id"], name: "index_indexed_posts_on_site_id" + end + + create_table "licencias", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "name" + t.text "description" + t.text "deed" + t.string "url" + t.string "icons" + end + + create_table "log_entries", force: :cascade do |t| + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "site_id" + t.text "text" + t.boolean "sent", default: false + t.index ["site_id"], name: "index_log_entries_on_site_id" + end + + create_table "maintenances", force: :cascade do |t| + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.text "message" + t.datetime "estimated_from" + t.datetime "estimated_to" + t.boolean "are_we_back", default: false + end + + create_table "mobility_string_translations", force: :cascade do |t| + t.string "locale", null: false + t.string "key", null: false + t.string "value" + t.string "translatable_type" + t.bigint "translatable_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["translatable_id", "translatable_type", "key"], name: "index_mobility_string_translations_on_translatable_attribute" + t.index ["translatable_id", "translatable_type", "locale", "key"], name: "index_mobility_string_translations_on_keys", unique: true + t.index ["translatable_type", "key", "value", "locale"], name: "index_mobility_string_translations_on_query_keys" + end + + create_table "mobility_text_translations", force: :cascade do |t| + t.string "locale", null: false + t.string "key", null: false + t.text "value" + t.string "translatable_type" + t.bigint "translatable_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["translatable_id", "translatable_type", "key"], name: "index_mobility_text_translations_on_translatable_attribute" + t.index ["translatable_id", "translatable_type", "locale", "key"], name: "index_mobility_text_translations_on_keys", unique: true + end + + create_table "roles", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "site_id" + t.bigint "usuarie_id" + t.string "rol" + t.boolean "temporal" + t.index ["site_id", "usuarie_id"], name: "index_roles_on_site_id_and_usuarie_id", unique: true + t.index ["site_id"], name: "index_roles_on_site_id" + t.index ["usuarie_id"], name: "index_roles_on_usuarie_id" + end + + create_table "sites", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "name" + t.bigint "design_id" + t.bigint "licencia_id" + t.string "status", default: "waiting" + t.text "description" + t.string "title" + t.boolean "colaboracion_anonima", default: false + t.boolean "contact", default: false + t.string "private_key_ciphertext" + t.boolean "acepta_invitades", default: false + t.string "tienda_api_key_ciphertext", default: "" + t.string "tienda_url", default: "" + t.string "api_key_ciphertext" + t.index ["design_id"], name: "index_sites_on_design_id" + t.index ["licencia_id"], name: "index_sites_on_licencia_id" + t.index ["name"], name: "index_sites_on_name", unique: true + end + + create_table "usuaries", force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" + t.integer "failed_attempts", default: 0, null: false + t.string "unlock_token" + t.datetime "locked_at" + t.boolean "acepta_politicas_de_privacidad", default: false + t.string "invitation_token" + t.datetime "invitation_created_at" + t.datetime "invitation_sent_at" + t.datetime "invitation_accepted_at" + t.integer "invitation_limit" + t.string "invited_by_type" + t.bigint "invited_by_id" + t.integer "invitations_count", default: 0 + t.string "lang", default: "es" + t.index ["confirmation_token"], name: "index_usuaries_on_confirmation_token", unique: true + t.index ["email"], name: "index_usuaries_on_email", unique: true + t.index ["invitation_token"], name: "index_usuaries_on_invitation_token", unique: true + t.index ["invitations_count"], name: "index_usuaries_on_invitations_count" + t.index ["invited_by_id"], name: "index_usuaries_on_invited_by_id" + t.index ["invited_by_type", "invited_by_id"], name: "index_usuaries_on_invited_by" + t.index ["reset_password_token"], name: "index_usuaries_on_reset_password_token", unique: true + t.index ["unlock_token"], name: "index_usuaries_on_unlock_token", unique: true + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + create_trigger("indexed_posts_before_insert_update_row_tr", :compatibility => 1). + on("indexed_posts"). + before(:insert, :update) do + <<-SQL_ACTIONS +new.indexed_content := to_tsvector(('pg_catalog.' || new.dictionary)::regconfig, coalesce(new.title, '') || ' +' || coalesce(new.content,'')); + SQL_ACTIONS end - add_foreign_key 'active_storage_attachments', 'active_storage_blobs', column: 'blob_id' end diff --git a/db/seeds/designs.yml b/db/seeds/designs.yml index 9d0ddd8a..126a9b12 100644 --- a/db/seeds/designs.yml +++ b/db/seeds/designs.yml @@ -6,12 +6,12 @@ disabled: true description_en: "Upload your own theme. [This feature is in development, help us!](https://sutty.nl/en/#contact)" description_es: "Subir tu propio diseño. [Esta posibilidad está en desarrollo, ¡ayudanos!](https://sutty.nl/#contacto)" -- name_en: 'I want you to create a site for me' +- name_en: 'I want you to develop a site for me' name_es: 'Quiero que desarrollen mi sitio' gem: 'sutty-theme-custom' url: 'https://sutty.nl' disabled: true - description_en: "If you want us to create your site, you're welcome to [contact us!](https://sutty.nl/en/#contact) :)" + description_en: "If you want us to develop your site, you're welcome to [contact us!](https://sutty.nl/en/#contact) :)" description_es: "Si querés que desarrollemos tu sitio, [escribinos](https://sutty.nl/#contacto) :)" - name_en: 'Minima' name_es: 'Mínima' @@ -24,11 +24,11 @@ name_es: 'Sutty' gem: 'sutty-jekyll-theme' url: 'https://rubygems.org/gems/sutty-jekyll-theme/' - description_en: "Sutty's design" + description_en: "The Sutty design" description_es: 'El diseño de Sutty' license: 'https://0xacab.org/sutty/jekyll/sutty-jekyll-theme/-/blob/master/LICENSE.txt' credits_es: 'Sutty es parte de la economía solidaria :)' - credits_en: 'Sutty is a solidarity economy project :)' + credits_en: 'Sutty is a solidarity economy project!' - name_en: 'Self-managed Book Publisher' name_es: 'Editorial Autogestiva' gem: 'editorial-autogestiva-jekyll-theme' @@ -42,11 +42,11 @@ name_es: 'Donaciones' gem: 'sutty-donaciones-jekyll-theme' url: 'https://donaciones.sutty.nl/' - description_en: "Make your own donations campaign with payment buttons." + description_en: "Make your own donation campaign with payment buttons." description_es: 'Realizá campañas de donaciones con botones de pago.' license: 'https://0xacab.org/sutty/jekyll/sutty-donaciones-jekyll-theme/-/blob/master/LICENSE.txt' credits_es: 'Diseñamos esta plantilla para [visibilizar campañas de donaciones](https://sutty.nl/plantilla-para-donaciones/) durante la cuarentena.' - credits_en: 'We designed this theme to increase [requests for donations visibility](https://sutty.nl/template-for-donations/) during the quarantine.' + credits_en: 'We designed this theme to increase [visibility for donation requests](https://sutty.nl/template-for-donations/) during the quarantine.' - name_en: 'Support campaign' name_es: 'Adhesiones' gem: 'adhesiones-jekyll-theme' @@ -55,29 +55,29 @@ description_es: 'Realizá campañas de adhesión.' license: 'https://0xacab.org/sutty/jekyll/adhesiones-jekyll-theme/-/blob/master/LICENSE.txt' credits_es: 'Desarrollamos esta plantilla junto con [Librenauta](https://sutty.nl/plantilla-para-campa%C3%B1as-de-adhesiones/)' - credits_en: 'We made this template with Librenauta :)' + credits_en: 'This template was made in collaboration with Librenauta' designer_url: 'https://copiona.com/donaunbit/' - name_en: 'Community Radio' name_es: 'Radio comunitaria' gem: 'radios-comunitarias-jekyll-theme' url: 'https://radio.sutty.nl/' - description_en: "A theme with streaming support, designed for community radios" + description_en: "A theme with live streaming support, designed for community radios" description_es: 'Con soporte para transmisión en vivo, pensada para radios comunitarias' license: 'https://0xacab.org/sutty/jekyll/radios-comunitarias-jekyll-theme/-/blob/master/LICENSE.txt' credits_es: 'Desarrollamos esta plantilla junto con Librenauta en 15 horas :)' - credits_en: 'We made this template with Librenauta in 15 hours :)' + credits_en: 'This template was made in collaboration with Librenauta in 15 hours!' designer_url: 'https://copiona.com/donaunbit/' - name_en: 'Resource toolkit' name_es: 'Recursero' gem: 'recursero-jekyll-theme' url: 'https://recursero.info/' disabled: true - description_en: "We're working to add more themes for you to use. [Contact us!](https://sutty.nl/en/#contact)" + description_en: "We're working towards adding more themes for you to use. [Contact us!](https://sutty.nl/en/#contact)" description_es: "Estamos trabajando para que puedas tener más diseños. [¡Escribinos!](https://sutty.nl/#contacto)" - name_en: 'Other themes' name_es: 'Mi propio diseño' gem: 'sutty-theme-own' url: 'https://jekyllthemes.org' disabled: true - description_en: "We're working to add more themes for you to use. [Contact us!](https://sutty.nl/en/#contact)" + description_en: "We're working towards adding more themes for you to use. [Contact us!](https://sutty.nl/en/#contact)" description_es: "Estamos trabajando para que puedas tener más diseños. [¡Escribinos!](https://sutty.nl/#contacto)" diff --git a/db/seeds/licencias.yml b/db/seeds/licencias.yml index fb5c8f0a..cbe3bace 100644 --- a/db/seeds/licencias.yml +++ b/db/seeds/licencias.yml @@ -104,7 +104,7 @@ description_en: "This license gives everyone the freedom to use, adapt, and redistribute the contents of your site by requiring attribution only. We recommend this license if you're publishing - articles that require maximum diffusion, even in commercial media, but + articles that require maximum dissemination, even in commercial media, but you want to be attributed. Users of the site will have to mention the source and indicate if they made changes to it." url_en: 'https://creativecommons.org/licenses/by/4.0/' @@ -198,13 +198,13 @@ url_es: 'https://creativecommons.org/licenses/by-sa/4.0/deed.es' description_en: "This license is the same as the CC-BY 4.0 but it adds a requirement of sharing the work and its derivatives under the same - license. This is a reciprocitary, _copyleft_, license that keeps + license. This is a reciprocal, _copyleft_, license that keeps culture free. Though commercial uses are allowed, they must be shared under the same license, so any modifications done for profit are free as well." description_es: "Esta licencia es igual que la CC-BY 4.0 con el requisito agregado de compartir la obra y sus obras derivadas con la - misma licencia. Esta es una licencia reciprocitaria, _copyleft_, que + misma licencia. Esta es una licencia recíproca, _copyleft_, que mantiene y profundiza la cultura libre. Aunque los usos comerciales están permitidos, las mejoras hechas con fines de lucro deben ser compartidas bajo la misma licencia." diff --git a/monit.conf b/monit.conf index b8c9e442..f574c56d 100644 --- a/monit.conf +++ b/monit.conf @@ -6,10 +6,6 @@ check process prometheus with pidfile /tmp/prometheus.pid start program = "/usr/local/bin/sutty prometheus" stop program = "/bin/sh -c 'cat /tmp/prometheus.pid | xargs kill'" -check program sync_assets - with path /usr/local/bin/sync_assets - if status = 0 then unmonitor - check program blazer_5m with path "/bin/sh -c 'cd /srv/http && foreman start blazer_5m'" as uid "app" and gid "www-data" diff --git a/ota.sh b/ota.sh new file mode 100755 index 00000000..68a0642f --- /dev/null +++ b/ota.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -e + +cd /srv/http + +for patch in /tmp/patches-${1}/*.patch; do + su -c "patch -Np 1 -i ${patch}" app && rm $patch +done + +cat tmp/puma.pid | xargs -r kill -USR2 diff --git a/package.json b/package.json index 0a2458a6..d520c8f5 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "@rails/ujs": "^6.1.3-1", "@rails/webpacker": "5.2.1", "babel-loader": "^8.2.2", + "chart.js": "^3.5.1", + "chartkick": "^4.0.5", "circular-dependency-plugin": "^5.2.2", "commonmark": "^0.29.0", "fork-awesome": "^1.1.7", diff --git a/sync_assets.sh b/sync_assets.sh deleted file mode 100644 index 1c1a6ca1..00000000 --- a/sync_assets.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/sh -# Sincronizar assets desde public a _public para que estén disponibles -# en el contenedor web. - -rsync -a --delete-after /srv/http/public/ /srv/http/_public/ diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb index 5f67092a..a7e2f68b 100644 --- a/test/controllers/sites_controller_test.rb +++ b/test/controllers/sites_controller_test.rb @@ -102,6 +102,13 @@ class SitesControllerTest < ActionDispatch::IntegrationTest 'index.html')) end + test 'no se pueden encolar varias veces seguidas' do + assert_enqueued_jobs 2 do + post site_enqueue_url(@site), headers: @authorization + post site_enqueue_url(@site), headers: @authorization + end + end + test 'se pueden actualizar' do name = SecureRandom.hex design = Design.all.where.not(id: @site.design_id).sample diff --git a/test/fixtures/site_with_relationships/README.md b/test/fixtures/site_with_relationships/README.md new file mode 100644 index 00000000..89b5b892 --- /dev/null +++ b/test/fixtures/site_with_relationships/README.md @@ -0,0 +1,2 @@ +This is site where posts can have many authors and viceversa and posts +can be replies to others. diff --git a/test/fixtures/site_with_relationships/_config.yml b/test/fixtures/site_with_relationships/_config.yml new file mode 100644 index 00000000..da2d25c8 --- /dev/null +++ b/test/fixtures/site_with_relationships/_config.yml @@ -0,0 +1,2 @@ +locales: +- en diff --git a/test/fixtures/site_with_relationships/_data/layouts/author.yml b/test/fixtures/site_with_relationships/_data/layouts/author.yml new file mode 100644 index 00000000..afe620e2 --- /dev/null +++ b/test/fixtures/site_with_relationships/_data/layouts/author.yml @@ -0,0 +1,9 @@ +--- +title: + type: 'string' + required: true +posts: + type: 'has_and_belongs_to_many' + inverse: 'authors' + filter: + layout: 'post' diff --git a/test/fixtures/site_with_relationships/_data/layouts/post.yml b/test/fixtures/site_with_relationships/_data/layouts/post.yml new file mode 100644 index 00000000..c98baf7d --- /dev/null +++ b/test/fixtures/site_with_relationships/_data/layouts/post.yml @@ -0,0 +1,23 @@ +--- +title: + type: 'string' + required: true +authors: + type: 'has_and_belongs_to_many' + inverse: 'posts' + filter: + layout: 'author' +posts: + type: 'has_many' + inverse: 'in_reply_to' + filter: + layout: 'post' +in_reply_to: + type: 'belongs_to' + inverse: 'posts' + filter: + layout: 'post' +recommended_posts: + type: 'related_posts' + filter: + layout: 'post' diff --git a/test/fixtures/site_with_relationships/_en/.keep b/test/fixtures/site_with_relationships/_en/.keep new file mode 100644 index 00000000..e69de29b diff --git a/test/fixtures/site_with_relationships/_posts b/test/fixtures/site_with_relationships/_posts new file mode 120000 index 00000000..3da1d67b --- /dev/null +++ b/test/fixtures/site_with_relationships/_posts @@ -0,0 +1 @@ +_en \ No newline at end of file diff --git a/test/jobs/backtrace_job_test.rb b/test/jobs/backtrace_job_test.rb new file mode 100644 index 00000000..e0061800 --- /dev/null +++ b/test/jobs/backtrace_job_test.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require 'test_helper' + +class DeployJobTest < ActiveSupport::TestCase + def site + @site ||= create :site + end + + # Mockup + def job + job = BacktraceJob.new + job.instance_variable_set :@site, site + job.instance_variable_set :@params, notice + + job + end + + # setTimeout(() => { throw('Prueba') }, 1000) + def notice + @notice ||= { + 'errors' => [ + { + 'type' => '', + 'message' => 'Prueba', + 'backtrace' => [ + { + 'function' => 'pt 'https://tintalimon.com.ar/assets/js/pack.js', + 'line' => 89, + 'column' => 74_094 + }, + { + 'function' => 'pt 'https://tintalimon.com.ar/assets/js/pack.js', + 'line' => 89, + 'column' => 74_731 + }, + { + 'function' => 'pt 'https://tintalimon.com.ar/assets/js/pack.js', + 'line' => 89, + 'column' => 71_925 + }, + { + 'function' => 'setTimeout handler*', + 'file' => 'debugger eval code', + 'line' => 1, + 'column' => 11 + } + ] + } + ], + 'context' => { + 'severity' => 'error', + 'history' => [ + { + 'type' => 'error', + 'target' => 'html. > head. > script.[type="text/javascript"][src="//stats.habitapp.org/piwik.js"]', + 'date' => '2021-04-26T22:06:58.390Z' + }, + { + 'type' => 'DOMContentLoaded', + 'target' => '[object HTMLDocument]', + 'date' => '2021-04-26T22:06:58.510Z' + }, + { + 'type' => 'load', + 'target' => '[object HTMLDocument]', + 'date' => '2021-04-26T22:06:58.845Z' + }, + { + 'type' => 'xhr', + 'date' => '2021-04-26T22:06:58.343Z', + 'method' => 'GET', + 'url' => 'assets/data/site.json', + 'statusCode' => 200, + 'duration' => 506 + }, + { + 'type' => 'xhr', + 'date' => '2021-04-26T22:06:58.886Z', + 'method' => 'GET', + 'url' => 'assets/templates/cart.html', + 'statusCode' => 200, + 'duration' => 591 + } + ], + 'windowError' => true, + 'notifier' => { + 'name' => 'airbrake-js/browser', + 'version' => '1.4.2', + 'url' => 'https://github.com/airbrake/airbrake-js/tree/master/packages/browser' + }, + 'userAgent' => 'Mozilla/5.0 (Windows NT 6.1; rv:85.0) Gecko/20100101 Firefox/85.0', + 'url' => 'https://tintalimon.com.ar/carrito/', + 'rootDirectory' => 'https://tintalimon.com.ar', + 'language' => 'JavaScript' + }, + 'params' => {}, + 'environment' => {}, + 'session' => {} + } + + # XXX: Siempre devolvemos un duplicado porque BacktraceJob lo + # modifica + @notice.dup + end + + # Asegurarse que el sitio se destruye al terminar de usarlo + teardown do + site&.destroy + end + + test 'al recibir un backtrace enviamos un error' do + ActionMailer::Base.deliveries.clear + + assert BacktraceJob.perform_now site_id: site.id, params: notice + + email = ActionMailer::Base.deliveries.first + + assert email + assert_equal ' (BacktraceJob::BacktraceException) "tintalimon.com.ar: Prueba"', email.subject + assert(%r{webpack://} =~ email.body.to_s) + end + + test 'los errores se basan en un sitio' do + assert_equal site, job.send(:site) + end + + test 'los errores tienen archivos fuente' do + assert_equal %w[https://tintalimon.com.ar/assets/js/pack.js], job.send(:sources) + end + + test 'los errores tienen una url de origen' do + assert_equal 'tintalimon.com.ar', job.send(:origin) + end + + test 'los errores tienen un sourcemap' do + local_job = job + sourcemap = local_job.send :sourcemap + + assert_equal SourceMap::Map, sourcemap.class + assert_equal 'assets/js/pack.js', sourcemap.filename + assert sourcemap.sources.size.positive? + end +end diff --git a/test/models/indexed_post_test.rb b/test/models/indexed_post_test.rb new file mode 100644 index 00000000..27d4e29e --- /dev/null +++ b/test/models/indexed_post_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'test_helper' + +class IndexedPostTest < ActiveSupport::TestCase + def site + @site ||= create :site + end + + teardown do + @site&.destroy + end + + test 'se pueden convertir los diccionarios' do + IndexedPost::DICTIONARIES.each do |locale, dict| + assert_equal dict, IndexedPost.to_dictionary(locale: locale) + end + end + + test 'se pueden buscar por categoría' do + assert(post = site.posts.create(title: SecureRandom.hex, description: SecureRandom.hex, + categories: [SecureRandom.hex, SecureRandom.hex])) + assert_not_empty site.indexed_posts.in_category(post.categories.value.sample) + end + + test 'se pueden encontrar por usuarie' do + usuarie = create :usuarie + assert(post = site.posts.create(title: SecureRandom.hex, description: SecureRandom.hex)) + + post.usuaries << usuarie + post.save + + assert_not_empty site.indexed_posts.by_usuarie(usuarie.id) + end +end diff --git a/test/models/metadata_belongs_to_test.rb b/test/models/metadata_belongs_to_test.rb new file mode 100644 index 00000000..09b9714f --- /dev/null +++ b/test/models/metadata_belongs_to_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'test_helper' +require_relative 'metadata_test' + +class MetadataBelongsToTest < ActiveSupport::TestCase + include MetadataTest + + test 'se pueden relacionar artículos' do + post = @site.posts.create(layout: :post, title: SecureRandom.hex) + reply = @site.posts.create(layout: :post, title: SecureRandom.hex, in_reply_to: post.uuid.value) + + assert_equal post, reply.in_reply_to.belongs_to + assert_includes post.posts.has_many, reply + + assert post.save + + assert_equal reply.document.data['in_reply_to'], post.document.data['uuid'] + assert_includes post.document.data['posts'], reply.document.data['uuid'] + end + + test 'se puede eliminar la relación' do + post = @site.posts.create(layout: :post, title: SecureRandom.hex) + reply = @site.posts.create(layout: :post, title: SecureRandom.hex, in_reply_to: post.uuid.value) + + reply.in_reply_to.value = '' + assert reply.save + + assert_not_equal post, reply.in_reply_to.belongs_to + assert_equal post, reply.in_reply_to.belonged_to + assert_nil reply.in_reply_to.belongs_to + assert_not_includes post.posts.has_many, reply + + assert post.save + + assert_nil reply.document.data['in_reply_to'] + assert_not_includes post.document.data['posts'], reply.document.data['uuid'] + end + + test 'se puede cambiar la relación' do + post1 = @site.posts.create(layout: :post, title: SecureRandom.hex) + post2 = @site.posts.create(layout: :post, title: SecureRandom.hex) + reply = @site.posts.create(layout: :post, title: SecureRandom.hex, in_reply_to: post1.uuid.value) + + reply.in_reply_to.value = post2.uuid.value + assert reply.save + + assert_not_equal post1, reply.in_reply_to.belongs_to + assert_equal post1, reply.in_reply_to.belonged_to + assert_not_includes post1.posts.has_many, reply + + assert_equal post2, reply.in_reply_to.belongs_to + assert_includes post2.posts.has_many, reply + + assert post1.save + assert post2.save + + assert_equal post2.document.data['uuid'], reply.document.data['in_reply_to'] + assert_includes post2.document.data['posts'], reply.document.data['uuid'] + assert_not_includes post1.document.data['posts'], reply.document.data['uuid'] + end +end diff --git a/test/models/metadata_has_and_belongs_to_many_test.rb b/test/models/metadata_has_and_belongs_to_many_test.rb new file mode 100644 index 00000000..4887a96e --- /dev/null +++ b/test/models/metadata_has_and_belongs_to_many_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'test_helper' +require_relative 'metadata_test' + +class MetadataHasAndBelongsManyTest < ActiveSupport::TestCase + include MetadataTest + + test 'se pueden relacionar artículos' do + author = @site.posts.create(layout: :author, title: SecureRandom.hex) + post = @site.posts.create(layout: :post, title: SecureRandom.hex) + + post.authors.value = [author.uuid.value] + assert post.save + + assert_includes author.posts.has_many, post + assert_includes post.authors.has_many, author + + assert author.save + + assert_includes author.document.data['posts'], post.document.data['uuid'] + assert_includes post.document.data['authors'], author.document.data['uuid'] + end + + test 'se puede eliminar la relación' do + author = @site.posts.create(layout: :author, title: SecureRandom.hex) + post = @site.posts.create(layout: :post, title: SecureRandom.hex, authors: [author.uuid.value]) + + assert_includes post.authors.value, author.uuid.value + assert_includes author.posts.value, post.uuid.value + + post.authors.value = [] + assert post.save + + assert_not_includes author.posts.has_many, post + assert_not_includes post.authors.has_many, author + + assert_includes author.posts.had_many, post + assert_includes post.authors.had_many, author + + assert author.save + + assert_not_includes author.document.data['posts'], post.document.data['uuid'] + assert_not_includes post.document.data['authors'], author.document.data['uuid'] + end + + test 'se puede cambiar la relación' do + author = @site.posts.create(layout: :author, title: SecureRandom.hex) + post1 = @site.posts.create(layout: :post, title: SecureRandom.hex, authors: [author.uuid.value]) + post2 = @site.posts.create(layout: :post, title: SecureRandom.hex) + + author.posts.value = [post2.uuid.value] + assert author.save + + assert_not_includes author.posts.has_many, post1 + assert_not_includes post1.authors.has_many, author + + assert_includes author.posts.had_many, post1 + assert_includes post1.authors.had_many, author + + assert_not_includes author.posts.had_many, post2 + assert_not_includes post2.authors.had_many, author + + assert_includes author.posts.has_many, post2 + assert_includes post2.authors.has_many, author + + assert post1.save + assert post2.save + + assert_not_includes author.document.data['posts'], post1.document.data['uuid'] + assert_not_includes post1.document.data['authors'], author.document.data['uuid'] + + assert_includes author.document.data['posts'], post2.document.data['uuid'] + assert_includes post2.document.data['authors'], author.document.data['uuid'] + end +end diff --git a/test/models/metadata_has_many_test.rb b/test/models/metadata_has_many_test.rb new file mode 100644 index 00000000..38b4d46a --- /dev/null +++ b/test/models/metadata_has_many_test.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require 'test_helper' +require_relative 'metadata_test' + +class MetadataHasManyTest < ActiveSupport::TestCase + include MetadataTest + + test 'se pueden relacionar artículos' do + reply = @site.posts.create(layout: :post, title: SecureRandom.hex) + post = @site.posts.create(layout: :post, title: SecureRandom.hex, posts: [reply.uuid.value]) + + assert_equal post, reply.in_reply_to.belongs_to + assert_includes post.posts.has_many, reply + + assert reply.save + + assert_equal reply.document.data['in_reply_to'], post.document.data['uuid'] + assert_includes post.document.data['posts'], reply.document.data['uuid'] + end + + test 'se puede eliminar la relación' do + reply = @site.posts.create(layout: :post, title: SecureRandom.hex) + post = @site.posts.create(layout: :post, title: SecureRandom.hex, posts: [reply.uuid.value]) + + post.posts.value = [] + assert post.save + + assert_not_equal post, reply.in_reply_to.belongs_to + assert_equal post, reply.in_reply_to.belonged_to + assert_nil reply.in_reply_to.belongs_to + assert_not_includes post.posts.has_many, reply + + assert reply.save + + assert_nil reply.document.data['in_reply_to'] + assert_not_includes post.document.data['posts'], reply.document.data['uuid'] + end + + test 'se puede cambiar la relación' do + reply = @site.posts.create(layout: :post, title: SecureRandom.hex) + post1 = @site.posts.create(layout: :post, title: SecureRandom.hex, posts: [reply.uuid.value]) + post2 = @site.posts.create(layout: :post, title: SecureRandom.hex) + + reply.in_reply_to.value = post2.uuid.value + assert reply.save + + assert_not_equal post1, reply.in_reply_to.belongs_to + assert_equal post1, reply.in_reply_to.belonged_to + assert_not_includes post1.posts.has_many, reply + + assert_equal post2, reply.in_reply_to.belongs_to + assert_includes post2.posts.has_many, reply + + assert post1.save + assert post2.save + + assert_equal post2.document.data['uuid'], reply.document.data['in_reply_to'] + assert_includes post2.document.data['posts'], reply.document.data['uuid'] + assert_not_includes post1.document.data['posts'], reply.document.data['uuid'] + end +end diff --git a/test/models/metadata_test.rb b/test/models/metadata_test.rb new file mode 100644 index 00000000..24d955ae --- /dev/null +++ b/test/models/metadata_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module MetadataTest + extend ActiveSupport::Concern + + included do + setup do + name = SecureRandom.hex + # TODO: Poder cambiar el nombre + FileUtils.cp_r(Rails.root.join('test', 'fixtures', 'site_with_relationships'), Rails.root.join('_sites', name)) + + @site = create :site, name: name + end + + teardown do + @site&.destroy + end + end +end diff --git a/test/models/post/indexable_test.rb b/test/models/post/indexable_test.rb new file mode 100644 index 00000000..6110bcf0 --- /dev/null +++ b/test/models/post/indexable_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Post::IndexableTest < ActiveSupport::TestCase + setup do + @site = create :site + end + + teardown do + @site&.destroy + end + + test 'los posts se indexan apenas se crean' do + post = @site.posts.create(title: SecureRandom.hex, description: SecureRandom.hex) + indexed_post = @site.indexed_posts.find_by_title post.title.value + + assert indexed_post + assert_equal post.locale.value.to_s, indexed_post.locale + assert_equal post.order.value, indexed_post.order + assert_equal post.path.basename, indexed_post.path + assert_equal post.layout.name.to_s, indexed_post.layout + end + + test 'se pueden encontrar posts' do + post = @site.posts.sample + + assert @site.indexed_posts.where(locale: post.lang.value).search(post.lang.value, post.title.value) + assert @site.indexed_posts.where(locale: post.lang.value).search(post.lang.value, post.description.value) + end + + test 'se pueden actualizar posts' do + post = @site.posts.sample + post.description.value = SecureRandom.hex + + assert post.save + assert @site.indexed_posts.where(locale: post.lang.value).search(post.lang.value, post.description.value) + end + + test 'al borrar el post se borra el indice' do + post = @site.posts.sample + assert post.destroy + assert_not @site.indexed_posts.find_by_id(post.uuid.value) + end +end diff --git a/test/models/post_test.rb b/test/models/post_test.rb index f98d7af3..52e59a8e 100644 --- a/test/models/post_test.rb +++ b/test/models/post_test.rb @@ -110,7 +110,6 @@ class PostTest < ActiveSupport::TestCase end test 'se puede cambiar la fecha' do - assert_not @post.date.changed? assert @post.date.valid? ex_date = @post.date.value diff --git a/test/models/site_test.rb b/test/models/site_test.rb index 9afbf20a..e53fff06 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -109,4 +109,10 @@ class SiteTest < ActiveSupport::TestCase assert_equal %w[book cart confirmation editorial menu payment post shipment], site.data['layouts'].keys assert_equal %i[book cart confirmation editorial menu payment post shipment], site.layouts.to_h.keys end + + test 'se pueden encolar una sola vez' do + assert site.enqueue! + assert site.enqueued? + assert_not site.enqueue! + end end diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 00000000..e69de29b diff --git a/yarn.lock b/yarn.lock index 11ff78cb..68b7fd23 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2119,6 +2119,25 @@ chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chart.js@>=3.0.2, chart.js@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.5.1.tgz#73e24d23a4134a70ccdb5e79a917f156b6f3644a" + integrity sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ== + +chartjs-adapter-date-fns@>=2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b" + integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw== + +chartkick@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/chartkick/-/chartkick-4.0.5.tgz#310a60c931e8ceedc39adee2ef8e9d1e474cb0e6" + integrity sha512-xKak4Fsgfvp1hj/LykRKkniDMaZASx2A4TdVc/sfsiNFFNf1m+D7PGwP1vgj1UsbsCjOCSfGWWyJpOYxkUCBug== + optionalDependencies: + chart.js ">=3.0.2" + chartjs-adapter-date-fns ">=2.0.0" + date-fns ">=2.0.0" + chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -2744,6 +2763,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +date-fns@>=2.0.0: + version "2.24.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.24.0.tgz#7d86dc0d93c87b76b63d213b4413337cfd1c105d" + integrity sha512-6ujwvwgPID6zbI0o7UbURi2vlLDR9uP26+tW6Lg+Ji3w7dd0i3DOcjcClLjLPranT60SSEFBwdSyYwn/ZkPIuw== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"