diff options
171 files changed, 5731 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9612375 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# See https://docs.docker.com/engine/reference/builder/#dockerignore-file for more about ignoring files. + +# Ignore git directory. +/.git/ + +# Ignore bundler config. +/.bundle + +# Ignore all environment files (except templates). +/.env* +!/.env*.erb + +# Ignore all default key files. +/config/master.key +/config/credentials/*.key + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/.keep + +# Ignore assets. +/node_modules/ +/app/assets/builds/* +!/app/assets/builds/.keep +/public/assets diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8dc4323 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored +config/credentials/*.yml.enc diff=rails_credentials +config/credentials.yml.enc diff=rails_credentials diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md new file mode 100644 index 0000000..fd66e04 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/custom.md @@ -0,0 +1,27 @@ +--- +name: Custom issue template +about: General issues, both bugs and features. +title: '' +labels: '' +assignees: mssola +--- + +### Description + +Check out the [contribution guidelines](../CONTRIBUTING.md) file for some considerations before submitting a new issue. + +### Steps to reproduce + +1. First I did this... +2. Then that... +3. And this happened! + +- **Expected behavior**: I expected this to happen! +- **Actual behavior**: But this happened... + +Providing logs would also be useful. + +### Deployment information + +- **Deployment method**: how have you deployed this application? If possible, could you paste your configuration? (don't forget to strip passwords or other sensitive data!) +- Commit SHA. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..7e5026e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,10 @@ +Provide a general description of the changes in your pull request. If this pull request fixes a known issue, please tag it as well (e.g.: `Fixes #1`). + +Before submitting a PR make sure the following things have been done (and denote this by checking the relevant checkboxes): + +- [ ] The commits are consistent with the [contribution guidelines](../CONTRIBUTING.md). +- [ ] `bundle exec rails test`, `bundle exec rails test:system` and `bundle exec rubocop` are passing. +- [ ] You've updated the [changelog](../CHANGELOG.md) (if adding/changing user-visible functionality). +- [ ] You've updated the [readme](../README.md) (if adding/changing user-visible functionality). + +Thanks for contributing to this project! diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..54baa0e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Run linting + run: | + bundle exec rubocop --parallel + + - name: Run security checks + run: | + bundle exec bundler-audit --update + bundle exec brakeman -q -w2 + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + + - name: Setup database + env: + RAILS_ENV: "test" + run: | + bin/rails db:setup + + - name: Check autoloading (zeitwerk) + env: + RAILS_ENV: "test" + run: | + bin/rails zeitwerk:check + + - name: Run tests + env: + RAILS_ENV: "test" + run: | + bin/rails test + bin/rails test:system diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fb66c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# See https://help.github.com/articles/ignoring-files for more about ignoring files. +# +# If you find yourself ignoring temporary files generated by your text editor +# or operating system, you probably want to add a global ignore instead: +# git config --global core.excludesfile '~/.gitignore_global' + +# Ignore bundler config. +/.bundle + +# Ignore all environment files (except templates). +/.env* +!/.env*.erb + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore storage (uploaded files in development and any SQLite databases). +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep + +/public/assets + +# Ignore master key for decrypting credentials and more. +/config/master.key diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..555e7e8 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,106 @@ +AllCops: + TargetRubyVersion: 3.2 + TargetRailsVersion: 7.1 + + DisplayCopNames: true + DisplayStyleGuide: false + SuggestExtensions: false + + NewCops: enable + + Exclude: + # Files that are out of our control and that are not excluded in the + # default config of rubocop. + - bin/bundle + - db/schema.rb + - db/migrate/* + - db/seeds.rb + - vendor/**/* + +## +# Plugins + +require: + - rubocop-rails + - rubocop-performance + +# +# Layout +# + +# The table format is more human readable. +Layout/HashAlignment: + EnforcedHashRocketStyle: table + EnforcedColonStyle: table + +# The default is just too small. A limit of 100 looks reasonable. +Layout/LineLength: + Max: 100 + +# +# Metrics +# + +# The default is just too small. +Metrics/AbcSize: + Max: 30 + +# We will skip it for tests. +Metrics/ClassLength: + Max: 200 + Exclude: + - test/**/* + +# Default is just too low. +Metrics/CyclomaticComplexity: + Max: 10 + +# Default is just too low. +Metrics/PerceivedComplexity: + Max: 10 + +# We will skip it for Rake tasks. +Metrics/BlockLength: + Exclude: + - config/**/* + - lib/tasks/**/* + - test/**/* + +# The default is just too small. +Metrics/MethodLength: + Max: 20 + +# +# Style +# + +# It's not needed to add documentation for obvious modules or classes. The main +# idea is that documentation will be asked during the review process if needed. +Style/Documentation: + Enabled: false + +# This forces us to create a new object for no real reason. +Style/MultipleComparison: + Enabled: false + +# There are some false positives (e.g. "module ::Module", in which we want to +# make sure there are no clashes or misunderstandings). Therefore, we just +# disable this cop. +Style/ClassAndModuleChildren: + Enabled: false + +# I do need to write non-ASCII words from time to time since, you know, this is +# a language application and latin words can have macrons. +Style/AsciiComments: + Enabled: false + +# +# Naming +# + +# The default minimum length is 3, which is too long for good names like +# "js". Variables with only one letter are usually disallowed, but there are +# some names which are easy to understand (e.g. convention). +Naming/MethodParameterName: + MinNameLength: 2 + AllowedNames: [_, n] diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..9e79f6c --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +ruby-3.2.2 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d2fe77d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Changelog + +Under development diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..838ecda --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +## Why? + +Do you want to fix an error you have found? Do you have a suggestion to improve +this application? I am open for discussion and welcome any help! + +## How? + +There are many ways to help me out. One way might be to open an issue on +[Github's tracker](https://github.com/mssola/operum/issues) and start a +discussion. For this, mind the following: + +- Check that the issue has not already been reported or fixed in `main`. +- Try to be concise and precise in your description. +- If you have found a problem, provide a step by step guide on how to reproduce it. +- Provide the version you are using (git commit SHA). + +Another way is to simply submit a pull request. For this, also mind these: + +- Write a [good commit message](https://chris.beams.io/posts/git-commit/). +- Tests continue to work (see `Testing` below). +- The application continues to work. +- The pull request has *only* one subject and a clear title. You are not + submitting a pull request with tons of different unrelated commits. + +## Testing + +Before doing anything at all make sure that your ruby version is as specified in +the `.ruby-version` file, and that you have also installed `bundler`. After +that, just run `bundle` to get all the gems as needed. + +Then prepare the database: + +```sh +$ bundle exec rake db:setup +``` + +And finally, in order to run unit tests, simply run: + +```sh +$ bundle exec rails test +``` + +System tests can also be run quite simply: + +```sh +$ bundle exec rails test:system +``` + +And finally, make sure that the code follows the proper style: + +```sh +$ bundle exec rubocop +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5cb8402 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax = docker/dockerfile:1 + +# Make sure RUBY_VERSION matches the Ruby version in .ruby-version and Gemfile +ARG RUBY_VERSION=3.2.2 +FROM registry.docker.com/library/ruby:$RUBY_VERSION-slim as base + +# Rails app lives here +WORKDIR /rails + +# Set production environment +ENV RAILS_ENV="production" \ + BUNDLE_DEPLOYMENT="1" \ + BUNDLE_PATH="/usr/local/bundle" \ + BUNDLE_WITHOUT="development" + + +# Throw-away build stage to reduce size of final image +FROM base as build + +# Install packages needed to build gems +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential git libvips pkg-config + +# Install application gems +COPY Gemfile Gemfile.lock ./ +RUN bundle install && \ + rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \ + bundle exec bootsnap precompile --gemfile + +# Copy application code +COPY . . + +# Precompile bootsnap code for faster boot times +RUN bundle exec bootsnap precompile app/ lib/ + +# Precompiling assets for production without requiring secret RAILS_MASTER_KEY +RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile + + +# Final stage for app image +FROM base + +# Install packages needed for deployment +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y curl libsqlite3-0 libvips && \ + rm -rf /var/lib/apt/lists /var/cache/apt/archives + +# Copy built artifacts: gems, application +COPY --from=build /usr/local/bundle /usr/local/bundle +COPY --from=build /rails /rails + +# Run and own only the runtime files as a non-root user for security +RUN useradd rails --create-home --shell /bin/bash && \ + chown -R rails:rails db log storage tmp +USER rails:rails + +# Entrypoint prepares the database. +ENTRYPOINT ["/rails/bin/docker-entrypoint"] + +# Start the server by default, this can be overwritten at runtime +EXPOSE 3000 +CMD ["thrust", "./bin/rails", "server"] @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +ruby '3.2.2' + +# Rails itself :) +gem 'rails', '~> 7.1.3' +gem 'rails-i18n', '~> 7.0.0' + +# The original asset pipeline for Rails. +gem 'sprockets-rails' + +# sqlite3 should be fine for this project, and we keep things simpler. +gem 'sqlite3', '~> 1.4' + +# Web server. +gem 'puma', '>= 5.0' +gem 'thruster' + +# Use JavaScript with ESM import maps. +gem 'importmap-rails' + +# Hotwire's SPA-like page accelerator. +gem 'turbo-rails' + +# Hotwire's modest JavaScript framework. +gem 'stimulus-rails' + +# Use Active Model has_secure_password. +gem 'bcrypt', '~> 3.1.7' + +# Reduces boot times through caching; required in config/boot.rb +gem 'bootsnap', require: false + +# Use Active Storage variants. +gem 'image_processing', '~> 1.2' + +group :development, :test do + gem 'debug', platforms: %i[mri windows] + + # Security + gem 'brakeman', require: false + gem 'bundle-audit', require: false +end + +group :development do + # Use console on exceptions pages. + gem 'web-console' + + # Convenient & prettier printer. + gem 'awesome_print' + + # Style + gem 'rubocop', require: false + gem 'rubocop-performance', require: false + gem 'rubocop-rails', require: false +end + +group :test do + # System testing. + gem 'capybara' + gem 'selenium-webdriver' +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..803d4e7 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,337 @@ +GEM + remote: https://rubygems.org/ + specs: + actioncable (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + mail (>= 2.7.1) + net-imap + net-pop + net-smtp + actionmailer (7.1.3.2) + actionpack (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activesupport (= 7.1.3.2) + mail (~> 2.5, >= 2.5.4) + net-imap + net-pop + net-smtp + rails-dom-testing (~> 2.2) + actionpack (7.1.3.2) + actionview (= 7.1.3.2) + activesupport (= 7.1.3.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actiontext (7.1.3.2) + actionpack (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (7.1.3.2) + activesupport (= 7.1.3.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activejob (7.1.3.2) + activesupport (= 7.1.3.2) + globalid (>= 0.3.6) + activemodel (7.1.3.2) + activesupport (= 7.1.3.2) + activerecord (7.1.3.2) + activemodel (= 7.1.3.2) + activesupport (= 7.1.3.2) + timeout (>= 0.4.0) + activestorage (7.1.3.2) + actionpack (= 7.1.3.2) + activejob (= 7.1.3.2) + activerecord (= 7.1.3.2) + activesupport (= 7.1.3.2) + marcel (~> 1.0) + activesupport (7.1.3.2) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + minitest (>= 5.1) + mutex_m + tzinfo (~> 2.0) + addressable (2.8.6) + public_suffix (>= 2.0.2, < 6.0) + ast (2.4.2) + awesome_print (1.9.2) + base64 (0.2.0) + bcrypt (3.1.20) + bigdecimal (3.1.6) + bindex (0.8.1) + bootsnap (1.18.3) + msgpack (~> 1.2) + brakeman (6.0.1) + builder (3.2.4) + bundle-audit (0.1.0) + bundler-audit + bundler-audit (0.9.1) + bundler (>= 1.2.0, < 3) + thor (~> 1.0) + capybara (3.40.0) + addressable + matrix + mini_mime (>= 0.1.3) + nokogiri (~> 1.11) + rack (>= 1.6.0) + rack-test (>= 0.6.3) + regexp_parser (>= 1.5, < 3.0) + xpath (~> 3.2) + concurrent-ruby (1.2.3) + connection_pool (2.4.1) + crass (1.0.6) + date (3.3.4) + debug (1.9.1) + irb (~> 1.10) + reline (>= 0.3.8) + drb (2.2.0) + ruby2_keywords + erubi (1.12.0) + ffi (1.16.3) + globalid (1.2.1) + activesupport (>= 6.1) + i18n (1.14.1) + concurrent-ruby (~> 1.0) + image_processing (1.12.2) + mini_magick (>= 4.9.5, < 5) + ruby-vips (>= 2.0.17, < 3) + importmap-rails (2.0.1) + actionpack (>= 6.0.0) + activesupport (>= 6.0.0) + railties (>= 6.0.0) + io-console (0.7.2) + irb (1.11.2) + rdoc + reline (>= 0.4.2) + json (2.6.3) + language_server-protocol (3.17.0.3) + loofah (2.22.0) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + mail (2.8.1) + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.0.2) + matrix (0.4.2) + mini_magick (4.12.0) + mini_mime (1.1.5) + minitest (5.22.2) + msgpack (1.7.2) + mutex_m (0.2.0) + net-imap (0.4.10) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.4.0.1) + net-protocol + nio4r (2.7.0) + nokogiri (1.16.2-aarch64-linux) + racc (~> 1.4) + nokogiri (1.16.2-arm-linux) + racc (~> 1.4) + nokogiri (1.16.2-arm64-darwin) + racc (~> 1.4) + nokogiri (1.16.2-x86-linux) + racc (~> 1.4) + nokogiri (1.16.2-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.16.2-x86_64-linux) + racc (~> 1.4) + parallel (1.23.0) + parser (3.2.2.4) + ast (~> 2.4.1) + racc + psych (5.1.2) + stringio + public_suffix (5.0.4) + puma (6.4.2) + nio4r (~> 2.0) + racc (1.7.3) + rack (3.0.9.1) + rack-session (2.0.0) + rack (>= 3.0.0) + rack-test (2.1.0) + rack (>= 1.3) + rackup (2.1.0) + rack (>= 3) + webrick (~> 1.8) + rails (7.1.3.2) + actioncable (= 7.1.3.2) + actionmailbox (= 7.1.3.2) + actionmailer (= 7.1.3.2) + actionpack (= 7.1.3.2) + actiontext (= 7.1.3.2) + actionview (= 7.1.3.2) + activejob (= 7.1.3.2) + activemodel (= 7.1.3.2) + activerecord (= 7.1.3.2) + activestorage (= 7.1.3.2) + activesupport (= 7.1.3.2) + bundler (>= 1.15.0) + railties (= 7.1.3.2) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rails-i18n (7.0.8) + i18n (>= 0.7, < 2) + railties (>= 6.0.0, < 8) + railties (7.1.3.2) + actionpack (= 7.1.3.2) + activesupport (= 7.1.3.2) + irb + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.1.0) + rdoc (6.6.2) + psych (>= 4.0.0) + regexp_parser (2.9.0) + reline (0.4.2) + io-console (~> 0.5) + rexml (3.2.6) + rubocop (1.57.2) + json (~> 2.3) + language_server-protocol (>= 3.17.0) + parallel (~> 1.10) + parser (>= 3.2.2.4) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 1.8, < 3.0) + rexml (>= 3.2.5, < 4.0) + rubocop-ast (>= 1.28.1, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.30.0) + parser (>= 3.2.1.0) + rubocop-performance (1.19.1) + rubocop (>= 1.7.0, < 2.0) + rubocop-ast (>= 0.4.0) + rubocop-rails (2.21.1) + activesupport (>= 4.2.0) + rack (>= 1.1) + rubocop (>= 1.33.0, < 2.0) + ruby-progressbar (1.13.0) + ruby-vips (2.2.1) + ffi (~> 1.12) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + selenium-webdriver (4.18.1) + base64 (~> 0.2) + rexml (~> 3.2, >= 3.2.5) + rubyzip (>= 1.2.2, < 3.0) + websocket (~> 1.0) + sprockets (4.2.1) + concurrent-ruby (~> 1.0) + rack (>= 2.2.4, < 4) + sprockets-rails (3.4.2) + actionpack (>= 5.2) + activesupport (>= 5.2) + sprockets (>= 3.0.0) + sqlite3 (1.7.2-aarch64-linux) + sqlite3 (1.7.2-arm-linux) + sqlite3 (1.7.2-arm64-darwin) + sqlite3 (1.7.2-x86-linux) + sqlite3 (1.7.2-x86_64-darwin) + sqlite3 (1.7.2-x86_64-linux) + stimulus-rails (1.3.3) + railties (>= 6.0.0) + stringio (3.1.0) + thor (1.3.0) + thruster (0.1.0) + thruster (0.1.0-aarch64-linux) + thruster (0.1.0-arm64-darwin) + thruster (0.1.0-x86_64-darwin) + thruster (0.1.0-x86_64-linux) + timeout (0.4.1) + turbo-rails (2.0.4) + actionpack (>= 6.0.0) + activejob (>= 6.0.0) + railties (>= 6.0.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (2.5.0) + web-console (4.2.1) + actionview (>= 6.0.0) + activemodel (>= 6.0.0) + bindex (>= 0.4.0) + railties (>= 6.0.0) + webrick (1.8.1) + websocket (1.2.10) + websocket-driver (0.7.6) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + xpath (3.2.0) + nokogiri (~> 1.8) + zeitwerk (2.6.13) + +PLATFORMS + aarch64-linux + arm-linux + arm64-darwin + x86-linux + x86_64-darwin + x86_64-linux + +DEPENDENCIES + awesome_print + bcrypt (~> 3.1.7) + bootsnap + brakeman + bundle-audit + capybara + debug + image_processing (~> 1.2) + importmap-rails + puma (>= 5.0) + rails (~> 7.1.3) + rails-i18n (~> 7.0.0) + rubocop + rubocop-performance + rubocop-rails + selenium-webdriver + sprockets-rails + sqlite3 (~> 1.4) + stimulus-rails + thruster + turbo-rails + web-console + +RUBY VERSION + ruby 3.2.2p53 + +BUNDLED WITH + 2.5.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef40c7c --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +<p align="center"> + <a href="https://github.com/mssola/operum/actions?query=workflow%3ACI" title="CI status for the main branch"><img src="https://github.com/mssola/operum/workflows/CI/badge.svg" alt="Build Status for main branch" /></a> +</p> + +--- + +> mille dea est operum: certe dea carminis illa est; <br /> +>   si mereor, studiis adsit amica meis. +> +> — P. Ovidi Nasonis - Fasti 3.833-834 + +This is a small application in order to keep track of books, articles, papers, +etc.; that you might have and need to know where they are, reference it on a +paper, etc. To sum things up, it's a way to keep your bibliography under +control. + +## Basic usage + +To do. + +## Deployment + +To do. + +OPERUM_BASE_TITLE +OPERUM_DEFAULT_LOCALE + +## Contributing + +Do you want to contribute with code, or to report an issue you are facing? Read +the [CONTRIBUTING.md](./CONTRIBUTING.md) file. + +## [Changelog](https://pbs.twimg.com/media/DJDYCcLXcAA_eIo?format=jpg&name=small) + +Read the [CHANGELOG.md](./CHANGELOG.md) file. + +## License + +```txt +Copyright (C) 2023-Ω Miquel SabatĂ© SolĂ <mikisabate@gmail.com> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..488c551 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative 'config/application' + +Rails.application.load_tasks diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 0000000..ddd546a --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,4 @@ +//= link_tree ../images +//= link_directory ../stylesheets .css +//= link_tree ../../javascript .js +//= link_tree ../../../vendor/javascript .js diff --git a/app/assets/images/.keep b/app/assets/images/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/assets/images/.keep diff --git a/app/assets/stylesheets/actiontext.css b/app/assets/stylesheets/actiontext.css new file mode 100644 index 0000000..3cfcb2b --- /dev/null +++ b/app/assets/stylesheets/actiontext.css @@ -0,0 +1,31 @@ +/* + * Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and + * the trix-editor content (whether displayed or under editing). Feel free to incorporate this + * inclusion directly in any other asset bundle and remove this file. + * + *= require trix +*/ + +/* + * We need to override trix.css’s image gallery styles to accommodate the + * <action-text-attachment> element we wrap around attachments. Otherwise, + * images in galleries will be squished by the max-width: 33%; rule. +*/ +.trix-content .attachment-gallery > action-text-attachment, +.trix-content .attachment-gallery > .attachment { + flex: 1 0 33%; + padding: 0 0.5em; + max-width: 33%; +} + +.trix-content .attachment-gallery.attachment-gallery--2 > action-text-attachment, +.trix-content .attachment-gallery.attachment-gallery--2 > .attachment, .trix-content .attachment-gallery.attachment-gallery--4 > action-text-attachment, +.trix-content .attachment-gallery.attachment-gallery--4 > .attachment { + flex-basis: 50%; + max-width: 50%; +} + +.trix-content action-text-attachment .attachment { + padding: 0 !important; + max-width: 100% !important; +} diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css new file mode 100644 index 0000000..042a686 --- /dev/null +++ b/app/assets/stylesheets/application.css @@ -0,0 +1,40 @@ +/* + * This is a manifest file that'll be compiled into application.css, which will include all the files + * listed below. + * + * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's + * vendor/assets/stylesheets directory can be referenced here using a relative path. + * + * You're free to add application-wide styles to this file and they'll appear at the bottom of the + * compiled file so the styles you add here take precedence over styles defined in any other CSS + * files in this directory. Styles in this file should be added after the last require_* statement. + * It is generally better to create a new file per style scope. + * + *= require simple + *= require_tree . + *= require_self + */ + +body { + /* Make the central block wider as it is with the default simple.css layout. */ + grid-template-columns: 1fr min(65rem,90%) 1fr !important; +} + +header { + padding: 0px !important; +} + +/* Tweaks on dark mode */ +@media (prefers-color-scheme: dark) { + /* Change the accent from simple.css */ + :root, + ::backdrop { + --accent: #d7af87 !important; + --accent-hover: #ffe099 !important; + } + + /* Make the buttons from Trix a bit more visible on dark mode */ + trix-toolbar .trix-button { + background: var(--text) !important; + } +} diff --git a/app/assets/stylesheets/comment.css b/app/assets/stylesheets/comment.css new file mode 100644 index 0000000..4472c62 --- /dev/null +++ b/app/assets/stylesheets/comment.css @@ -0,0 +1,27 @@ +/* + * Single comment. + */ + +.comment { + margin: 1rem .5rem 1rem .5rem; + border: 1px solid var(--border); + border-radius: var(--standard-border-radius); + padding: .5rem 1rem; + + display: grid; + grid-template-columns: 1fr; + gap: 10px; +} + +.comment .comment-header { + display: flex; + gap: 10px; +} + +.comment .comment-header .comment-header-title { + flex-grow: 1; +} + +.comment .comment-header .comment-header-actions { + flex-grow: 0; +} diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css new file mode 100644 index 0000000..484265e --- /dev/null +++ b/app/assets/stylesheets/icons.css @@ -0,0 +1,51 @@ +/* + * Icons taken and adapted from: https://github.com/astrit/css.gg. + */ + +/* + * Arrow down. + */ + +.gg-chevron-down { + box-sizing: border-box; + position: relative; + transform: scale(1); + border: 2px solid transparent; +} +.gg-chevron-down::after { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 10px; + height: 10px; + border-bottom: 2px solid; + border-right: 2px solid; + transform: rotate(45deg); + left: -5px; + top: 4px +} + +/* + * Arrow up. + */ + +.gg-chevron-up { + box-sizing: border-box; + position: relative; + transform: scale(1); + border: 2px solid transparent; +} +.gg-chevron-up::after { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 10px; + height: 10px; + border-top: 2px solid; + border-right: 2px solid; + transform: rotate(-45deg); + left: -5px; + bottom: 4px +} diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css new file mode 100644 index 0000000..eb72638 --- /dev/null +++ b/app/assets/stylesheets/search.css @@ -0,0 +1,75 @@ +/* + * Root + */ + +#hidden-nav { + padding: 1rem .5rem 2rem .5rem; + display: flex; + align-items: center; + gap: 10px; + justify-content: center; +} + +#hidden-nav div:not(:last-child) { + padding-right: 1rem; + border-right: 1px solid var(--border); +} + +#hidden-nav a { + margin-left: .5rem; +} + +/* + * New search. + */ + +#search_form { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + padding-bottom: 1rem; + margin-bottom: 2rem; + border-bottom: 1px solid var(--border); +} + +#search_form input[type=text] { + width: 100%; +} + +/* + * Export. + */ + +@media only screen and (max-width: 720px) { + #export-format-button { + width: 100%; + text-align: center; + } +} + +/* + * Shared searches. + */ + +#public-search-table { + margin: 0px; + width: 100%; +} + +#public-search-table td { + padding: 0px; + border: 0px; +} + +#public-search-table details { + background: 0; + margin: 0px; + border-radius: 0px; +} + +#public-search-table .details-flex { + display: flex; + align-items: flex-start; + gap: 20px; + flex-wrap: wrap; +} diff --git a/app/assets/stylesheets/tags.css b/app/assets/stylesheets/tags.css new file mode 100644 index 0000000..141cbca --- /dev/null +++ b/app/assets/stylesheets/tags.css @@ -0,0 +1,27 @@ +#tag-index h2 span { + font-size: 1rem; + vertical-align: middle; +} + +#tag-new h2 span { + font-size: 1rem; + vertical-align: middle; +} + +#tags-list { + display: grid; + grid-template-columns: repeat(1, 1fr); +} + +.tag-container { + display: flex; + align-items: flex-start; +} + +.tag-container .name { + flex-grow: 1; +} + +.tag-container .action { + flex-grow: 0; +} diff --git a/app/assets/stylesheets/things.css b/app/assets/stylesheets/things.css new file mode 100644 index 0000000..af8487f --- /dev/null +++ b/app/assets/stylesheets/things.css @@ -0,0 +1,94 @@ +/* + * Thing form. + */ + +#thing_form { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +@media only screen and (max-width: 720px) { + #thing_form { + grid-template-columns: 1fr; + } + + #thing-delete-button { + width: 100%; + } +} + +#thing_form input[type=text], input[type=number], input[type=date], select { + width: 100%; +} + +#thing_form .thing_large { + grid-column: 1 / -1; +} + +h5 a { + font-size: 1rem; +} + +/* + * Listing things. + */ + +.thing_row { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.thing_head { + flex-grow: 0; +} + +.thing_body { + flex-grow: 1; +} + +.thing_body a { + text-decoration: none; + color: var(--text) !important; +} + +.thing_body a:hover { + color: var(--accent) !important; +} + +.thing_tail { + flex-grow: 0; +} + +.dot { + height: 8px; + width: 8px; + border-radius: 50%; + display: inline-block; + vertical-align: middle; +} + +.dot_other { + background-color: black; +} + +.dot_poetry { + background-color: #facc15; +} + +.dot_theater { + background-color: #c084fc; +} + +.dot_essay { + background-color: #ef4444; +} + +.dot_shorts { + background-color: #a3e635; +} + +.dot_novel { + background-color: #60a5fa; +} diff --git a/app/assets/stylesheets/utils.css b/app/assets/stylesheets/utils.css new file mode 100644 index 0000000..335e595 --- /dev/null +++ b/app/assets/stylesheets/utils.css @@ -0,0 +1,24 @@ +.width-100 { + width: 100%; +} + +.center-contents { + display: grid; + grid-template-columns: repeat(3, 1fr); +} + +.flex-list { + display: flex; + align-items: start; + gap: 10px 20px; +} + +.flex-wrap { + display: flex; + align-items: start; + flex-wrap: wrap; +} + +.hidden { + display: none !important; +} diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..2a63b66 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::Base + before_action :user_authenticated! + before_action :set_searches + + helper_method :current_user + + def current_user + @current_user ||= User.find(session[:user_id]) if session[:user_id] + end + + def user_authenticated! + return if current_user + + redirect_to new_sessions_path + end + + def set_searches + @searches = Search.all + end +end diff --git a/app/controllers/comments_controller.rb b/app/controllers/comments_controller.rb new file mode 100644 index 0000000..d362460 --- /dev/null +++ b/app/controllers/comments_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class CommentsController < ApplicationController + include Taggable + + before_action :set_thing + before_action :set_comment, only: %i[update destroy] + + def create + ps = comment_params + from = ps.delete(:from) + tag_ids = ps.delete(:tag_ids) + + ok = ActiveRecord::Base.transaction do + comment = @thing.comments.create(ps) + next unless comment + + update_tags!(object: comment, ids: tag_ids) + end + + flash[:alert] = t('comments.bad-create') unless ok + handle_from_param!(from:) + end + + def update + ps = comment_params + from = ps.delete(:from) + tag_ids = ps.delete(:tag_ids) + + ok = ActiveRecord::Base.transaction do + next unless @comment.update(ps) + + update_tags!(object: @comment, ids: tag_ids) + end + + flash[:alert] = t('comments.bad-update') unless ok + handle_from_param!(from:) + end + + def destroy + comment = Comment.find(params[:id]) + comment.destroy + + handle_from_param!(from: params[:from]) + end + + protected + + def comment_params + params.required(:comment).permit(:content, :from, tag_ids: []) + end + + def set_thing + @thing = Thing.find(params[:thing_id]) + end + + def set_comment + @comment = Comment.find(params[:id]) + end + + def handle_from_param!(from:) + if from == 'things/edit' + redirect_to edit_thing_path(@thing) + else + redirect_to @thing + end + end +end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/controllers/concerns/.keep diff --git a/app/controllers/concerns/taggable.rb b/app/controllers/concerns/taggable.rb new file mode 100644 index 0000000..a9bda12 --- /dev/null +++ b/app/controllers/concerns/taggable.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# This module provides methods for controllers that have to deal with tag +# references (i.e. comments and things). This is closely tied to `TagReference` +# and its polymorphic `:taggable` column. +module Taggable + extend ActiveSupport::Concern + + # Update tag references for the given `object` with tags identified by the + # `ids` array. + def update_tags!(object:, ids:) + object.tag_references.destroy_all + return true unless ids + + ids.each { |id| TagReference.create!(tag_id: id, taggable: object) } + end +end diff --git a/app/controllers/exports_controller.rb b/app/controllers/exports_controller.rb new file mode 100644 index 0000000..4303ea6 --- /dev/null +++ b/app/controllers/exports_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ExportsController < ApplicationController + before_action :set_search + before_action :set_results, only: %i[show] + + def show + respond_to do |format| + format.csv { set_file_name!(ext: 'csv') } + format.uoc { set_file_name!(ext: 'tex') } + end + end + + def new; end + + protected + + # Sets the file name for the attachment according to the @search's name, and + # it adds the given string `ext` as the extension. + def set_file_name!(ext:) + response.headers['Content-Disposition'] = "attachment; filename=\"#{@search.name}.#{ext}\"" + end + + # Set @search. If there was no `:search_id` parameter, then we assume that the + # ID = 0, which is the fake search we use for the base 'Home' (i.e. get + # everything). + def set_search + @search = if params[:search_id].to_i.zero? + Search.new(id: 0, name: 'Home') + else + Search.find(params[:search_id]) + end + end + + def set_results + @results = @search.results.fetch(:things, []) + end +end diff --git a/app/controllers/licenses_controller.rb b/app/controllers/licenses_controller.rb new file mode 100644 index 0000000..03b6087 --- /dev/null +++ b/app/controllers/licenses_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class LicensesController < ApplicationController + skip_before_action :user_authenticated!, only: %i[show] + + def show; end +end diff --git a/app/controllers/searches_controller.rb b/app/controllers/searches_controller.rb new file mode 100644 index 0000000..752ccec --- /dev/null +++ b/app/controllers/searches_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class SearchesController < ApplicationController + before_action :set_search, only: %i[show edit update destroy] + + def index + current_user.update!(last_search_id: nil) + @search = Search.new + end + + def show + current_user.update!(last_search_id: @search.id) + end + + def new + @search = Search.new + end + + def edit; end + + def create + @search = Search.new(search_params) + @search.user_id = @current_user.id + + if @search.save + redirect_to @search + else + render :new, status: :unprocessable_entity + end + end + + def update + if @search.update(search_params) + redirect_to @search, status: :see_other + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @search.destroy! + redirect_to searches_url, status: :see_other + end + + def search + @search = Search.new + @search.body = params.permit(:body).fetch(:body, '') + render 'search', partial: true, locals: { items: @search.results } + end + + protected + + def set_search + @search = Search.find(params[:id]) + end + + def search_params + params.require(:search).permit(:name, :body, :shared) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..b79e3dd --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# SessionsController holds the logic for logging in and out users. +class SessionsController < ApplicationController + skip_before_action :user_authenticated!, only: %i[new create] + skip_before_action :set_searches, only: %i[new create] + + # We only need to render its template. + def new; end + + # Create a session for the current user. + def create + user = User.find_by(username: params[:username]) + if user&.authenticate(params[:password]) + session[:user_id] = user.id + redirect_to root_url + else + redirect_to root_url, alert: t('sessions.wrong-credentials') + end + end + + # Destroy the current session in order to log out. + def destroy + session[:user_id] = nil + redirect_to root_url + end +end diff --git a/app/controllers/shared_searches_controller.rb b/app/controllers/shared_searches_controller.rb new file mode 100644 index 0000000..3208e7c --- /dev/null +++ b/app/controllers/shared_searches_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SharedSearchesController < ApplicationController + skip_before_action :user_authenticated!, only: %i[show] + + def index + @searches = Search.where(shared: true) + end + + def show + @search = Search.find(params[:search_id]) + end +end diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb new file mode 100644 index 0000000..d967185 --- /dev/null +++ b/app/controllers/tags_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class TagsController < ApplicationController + before_action :set_tag, only: %i[destroy] + + def index + @tags = Tag.all + end + + def new + @tag = Tag.new + end + + def create + @tag = Tag.new(tag_params) + + if @tag.save + redirect_to tags_url, notice: t('tags.create-success') + else + render :new, status: :unprocessable_entity + end + end + + def destroy + @tag.destroy! + + redirect_to tags_url, notice: t('tags.destroy-success') + end + + private + + def set_tag + @tag = Tag.find(params[:id]) + end + + def tag_params + params.require(:tag).permit(:name) + end +end diff --git a/app/controllers/things_controller.rb b/app/controllers/things_controller.rb new file mode 100644 index 0000000..450b409 --- /dev/null +++ b/app/controllers/things_controller.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class ThingsController < ApplicationController + include Taggable + + before_action :set_thing, only: %i[edit show update destroy] + + def show; end + + def new + @thing = Thing.new + end + + def edit; end + + def create + ps = thing_params + tag_ids = ps.delete(:tag_ids) + @thing = Thing.new(ps) + @thing.user_id = @current_user.id + + updated = ActiveRecord::Base.transaction do + next unless @thing.save + + update_tags!(object: @thing, ids: tag_ids) + end + + if updated + redirect_to thing_url(@thing.reload), notice: t('things.create-success') + else + render :new, status: :unprocessable_entity + end + end + + def update + ps = thing_params + tag_ids = ps.delete(:tag_ids) + + updated = ActiveRecord::Base.transaction do + next unless @thing.update(ps) + + update_tags!(object: @thing, ids: tag_ids) + end + + if updated + redirect_to edit_thing_url(@thing), notice: t('things.update-success') + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @thing.destroy! + + redirect_to root_url, notice: t('things.destroy-success') + end + + private + + def set_thing + @thing = Thing.find(params[:id]) + end + + def thing_params + params.require(:thing).permit(:target, :title, :publisher, :address, :year, :url, :access, + :authors, :location, :insideof, :pages, :rate, :status, :kind, + :bought_at, :editors, :note, :where_is_it, tag_ids: []) + end +end diff --git a/app/helpers/shared_searches_helper.rb b/app/helpers/shared_searches_helper.rb new file mode 100644 index 0000000..dbbc67d --- /dev/null +++ b/app/helpers/shared_searches_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module SharedSearchesHelper + # Returns `arg` as is if not blank, otherwise it returns '-'. + def string_maybe(arg) + arg.presence || '-' + end + + # Format `ae.authors` depending on whether they are editors or not. + def authors_or_editors(ae) + if ae.editors + "#{ae.authors} (eds.)" + else + ae.authors + end + end +end diff --git a/app/javascript/application.js b/app/javascript/application.js new file mode 100644 index 0000000..9ae56c5 --- /dev/null +++ b/app/javascript/application.js @@ -0,0 +1,6 @@ +// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails +import "@hotwired/turbo-rails" +import "controllers" + +import "trix" +import "@rails/actiontext" diff --git a/app/javascript/controllers/application.js b/app/javascript/controllers/application.js new file mode 100644 index 0000000..1213e85 --- /dev/null +++ b/app/javascript/controllers/application.js @@ -0,0 +1,9 @@ +import { Application } from "@hotwired/stimulus" + +const application = Application.start() + +// Configure Stimulus development experience +application.debug = false +window.Stimulus = application + +export { application } diff --git a/app/javascript/controllers/comment_controller.js b/app/javascript/controllers/comment_controller.js new file mode 100644 index 0000000..22167c8 --- /dev/null +++ b/app/javascript/controllers/comment_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus" + +// Controller that handles actions for adding some dynamism like showing/hiding +// comments. +export default class extends Controller { + // Cancel the current operation for comments. + toggle(event) { + event.preventDefault(); + + const parent = event.target.closest('.comment'); + const regl_body = parent.getElementsByClassName('comment-body'); + const edit_body = parent.getElementsByClassName('comment-edit-body'); + if (regl_body.length !== 1 || edit_body.length !== 1) { + return; + } + + regl_body[0].classList.toggle('hidden'); + edit_body[0].classList.toggle('hidden'); + } + + // Hide/show the "New comment" button & form properly, while also focusing on + // the text area when shown. + toggleNewComment(event) { + event.preventDefault(); + + document.getElementById("comment-new-button").classList.toggle('hidden'); + + let body = document.getElementById("comment-new-body"); + body.classList.toggle('hidden'); + + // BUG (minor): this is not working! (bug in Trix?) + if (!body.classList.contains("hidden")) { + document.getElementById("comment_content").focus(); + } + } +} diff --git a/app/javascript/controllers/export_controller.js b/app/javascript/controllers/export_controller.js new file mode 100644 index 0000000..1807799 --- /dev/null +++ b/app/javascript/controllers/export_controller.js @@ -0,0 +1,19 @@ +import { Controller } from "@hotwired/stimulus" + +// Controller used in the exports#new page. That is, it allows us to change the +// format to be picked up by the exporter. +export default class extends Controller { + static targets = ["id"] + + connect() { + const url = `/searches/${this.idTarget.value}/exports.csv`; + document.getElementById("export-format-button").setAttribute('href', url); + } + + // Change the format to be used. + change() { + const format = document.getElementById("export-select-format").value; + const url = `/searches/${this.idTarget.value}/exports.${format}`; + document.getElementById("export-format-button").setAttribute('href', url); + } +} diff --git a/app/javascript/controllers/header_controller.js b/app/javascript/controllers/header_controller.js new file mode 100644 index 0000000..ceb53a6 --- /dev/null +++ b/app/javascript/controllers/header_controller.js @@ -0,0 +1,21 @@ +import { Controller } from "@hotwired/stimulus" + +// Controller being used by the top application header. It allows the user to +// show/hide the hidden menu with the different options. +export default class extends Controller { + // Hide/show the hidden navigation menu from the header. + toggle(event) { + event.preventDefault(); + + // Toggle the up/down arrow. + const el = document.getElementById("header-chevron"); + if (el.classList[0] === "gg-chevron-down") { + el.classList.replace("gg-chevron-down", "gg-chevron-up") + } else { + el.classList.replace("gg-chevron-up", "gg-chevron-down") + } + + // Hide/show the hidden navigation menu. + document.getElementById("hidden-nav").classList.toggle('hidden'); + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js new file mode 100644 index 0000000..54ad4ca --- /dev/null +++ b/app/javascript/controllers/index.js @@ -0,0 +1,11 @@ +// Import and register all your controllers from the importmap under controllers/* + +import { application } from "controllers/application" + +// Eager load all controllers defined in the import map under controllers/**/*_controller +import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading" +eagerLoadControllersFrom("controllers", application) + +// Lazy load controllers as they appear in the DOM (remember not to preload controllers in import map!) +// import { lazyLoadControllersFrom } from "@hotwired/stimulus-loading" +// lazyLoadControllersFrom("controllers", application) diff --git a/app/javascript/controllers/search_controller.js b/app/javascript/controllers/search_controller.js new file mode 100644 index 0000000..b35593a --- /dev/null +++ b/app/javascript/controllers/search_controller.js @@ -0,0 +1,36 @@ +import { Controller } from "@hotwired/stimulus" + +// Controller that dynamically fetches search results from the body as +// introduced on an input. Then it fills the `results` elements with them. +export default class extends Controller { + static targets = ["body"] + + connect() { + document.getElementById("search_body").focus(); + } + + load(event) { + event.preventDefault(); + + try { + fetch("/searches/search?" + new URLSearchParams({body: this.bodyTarget.value})) + .then(response => response.text()) + .then(html => { + document.getElementById("results").innerHTML = html; + document.getElementById("save-search").classList.toggle("hidden"); + }) + } catch (error) { + document.getElementById("results").innerHTML = '<div class="notice">Something went wrong!</div>' + } + } + + save(event) { + event.preventDefault(); + + document.getElementById("search-form-submit").classList.toggle("hidden"); + document.getElementById("save-search").classList.toggle("hidden"); + document.getElementById("search-form-name").classList.toggle("hidden"); + document.getElementById("search-form-shared").classList.toggle("hidden"); + document.getElementById("search_name").focus(); + } +} diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..08dc537 --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class +end diff --git a/app/models/comment.rb b/app/models/comment.rb new file mode 100644 index 0000000..deb6ad7 --- /dev/null +++ b/app/models/comment.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class Comment < ApplicationRecord + validates :content, presence: true + + belongs_to :thing + + has_many :tag_references, as: :taggable, dependent: :destroy + has_many :tags, through: :tag_references + + has_rich_text :content + + # Returns all comments which have a content matching the given text. + def self.like(text:) + Comment.joins(:rich_text_content) + .where('action_text_rich_texts.body LIKE ?', "%#{text}%") + end +end diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/app/models/concerns/.keep diff --git a/app/models/search.rb b/app/models/search.rb new file mode 100644 index 0000000..81a1c34 --- /dev/null +++ b/app/models/search.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class Search < ApplicationRecord + validates :name, presence: true, uniqueness: true + validates :body, presence: true, uniqueness: true + + belongs_to :user + + # Returns all the results that can be fetched with the current `body`. It's + # returned into a hash which groups the taggable types. + def results + # This can happen as part of an empty Search.new instance. That is, it's + # still invalid, but it's the object being initialized for the default + # search (i.e. just show me everything). + return { things: Thing.order('rate DESC, created_at'), comments: [] } if body.blank? + + # Parse the body of the search, and return early if nothing was able to be + # parsed (e.g. user wrote unknown identifiers). + parsed = parse_body + return { things: [], comments: [] } if parsed.all? { |_, v| v.blank? } + + # And add everything into the `res` hash with the results. + res = find_by_text(plain: parsed[:plain]) + find_by_tags(res:, tags: parsed[:tag]) + end + + protected + + # Returns a hash which contains the tags that has been specified (`tag`) and + # the plain text (`plain`) to check on the different fields. + def parse_body + res = { tag: [], plain: [] } + + body.split.each do |part| + if part.include?(':') + parts = part.split(':', 2) + parts[1] = clean_clause(part: parts[1]) + + next if parts[0] != 'tag' + + res[parts[0].to_sym] << parts[1] + else + res[:plain] << part + end + end + + res + end + + # Returns the argument without any leading/trailing quotes. + def clean_clause(part:) + part.gsub(/^("|')+/, '').gsub(/("|')+$/, '') + end + + # Returns a hash which groups into taggable types the results by looking the + # `plain` text into different fields. + def find_by_text(plain:) + res = { things: [], comments: [] } + + plain.each do |text| + res[:things] = if res[:things].any? + res[:things].and(Thing.like(text:)).order('rate DESC, created_at') + else + Thing.like(text:).order('rate DESC, created_at') + end + res[:comments] = if res[:comments].any? + res[:comments].and(Comment.like(text:)).order(:created_at) + else + Comment.like(text:).order(:created_at) + end + end + + res + end + + # Returns a hash which groups into taggable types the results by matching the + # given `tags` which their references. It expects `res` to be already + # initialized by `find_by_text` (yeah, great design, I know), which is also + # the structure that will be returned. + def find_by_tags(res:, tags:) + return res if tags.blank? + + tags = tags.map { |name| Tag.find_by(name:) } + + query = TagReference.where(tag: tags, taggable_type: 'Thing') + query = query.where(taggable_id: res[:things].pluck(:id)) if res[:things].present? + res[:things] = query.group(:taggable_id) + .having('count(taggable_id) = ?', tags.size) + .map(&:taggable) + + query = TagReference.where(tag: tags, taggable_type: 'Comment') + query = query.where(taggable_id: res[:comments].pluck(:id)) if res[:comments].present? + res[:comments] = query.group(:taggable_id) + .having('count(taggable_id) = ?', tags.size) + .map(&:taggable) + + res + end +end diff --git a/app/models/tag.rb b/app/models/tag.rb new file mode 100644 index 0000000..1f359d6 --- /dev/null +++ b/app/models/tag.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Tag < ApplicationRecord + validates :name, presence: true, uniqueness: true + + has_many :tag_references, dependent: :destroy +end diff --git a/app/models/tag_reference.rb b/app/models/tag_reference.rb new file mode 100644 index 0000000..bd72267 --- /dev/null +++ b/app/models/tag_reference.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class TagReference < ApplicationRecord + belongs_to :tag + belongs_to :taggable, polymorphic: true +end diff --git a/app/models/thing.rb b/app/models/thing.rb new file mode 100644 index 0000000..5c758fb --- /dev/null +++ b/app/models/thing.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class Thing < ApplicationRecord + validates :title, presence: true, uniqueness: true + validates :target, presence: true, uniqueness: true + validates :authors, presence: true + validates :rate, numericality: { in: 0..10 } + + belongs_to :user + + has_many :comments, dependent: :destroy + has_many :tag_references, as: :taggable, dependent: :destroy + has_many :tags, through: :tag_references + + enum :status, %i[read notread tobepublished], validate: true + enum :kind, %i[other poetry theater essay shorts novel paper], validate: true + + # Returns all things which match the given text for any of the string columns + # from the table. + def self.like(text:) + Thing.where('target LIKE ?', "%#{text}%") + .or(where('title LIKE ?', "%#{text}%")) + .or(where('publisher LIKE ?', "%#{text}%")) + .or(where('address LIKE ?', "%#{text}%")) + .or(where('url LIKE ?', "%#{text}%")) + .or(where('location LIKE ?', "%#{text}%")) + .or(where('insideof LIKE ?', "%#{text}%")) + .or(where('pages LIKE ?', "%#{text}%")) + .or(where('note LIKE ?', "%#{text}%")) + .or(where('authors LIKE ?', "%#{text}%")) + end +end diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..7eaba55 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class User < ApplicationRecord + has_secure_password + + validates :username, presence: true, uniqueness: true + validates :password, presence: true, on: :create + validates :password_confirmation, presence: true, on: :create +end diff --git a/app/views/active_storage/blobs/_blob.html.erb b/app/views/active_storage/blobs/_blob.html.erb new file mode 100644 index 0000000..49ba357 --- /dev/null +++ b/app/views/active_storage/blobs/_blob.html.erb @@ -0,0 +1,14 @@ +<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>"> + <% if blob.representable? %> + <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> + <% end %> + + <figcaption class="attachment__caption"> + <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <span class="attachment__name"><%= blob.filename %></span> + <span class="attachment__size"><%= number_to_human_size blob.byte_size %></span> + <% end %> + </figcaption> +</figure> diff --git a/app/views/comments/_comment.html.erb b/app/views/comments/_comment.html.erb new file mode 100644 index 0000000..8c0975e --- /dev/null +++ b/app/views/comments/_comment.html.erb @@ -0,0 +1,42 @@ +<div id="<%= dom_id(comment) %>" class="comment"> + <div class="comment-header"> + <div class="comment-header-title"> + <% if defined?(idx) %> + <span title="Commented on <%= comment.created_at %>"><b><%= I18n.t('comments.title') %></b> #<%= idx + 1 %></span> + <% else %> + <span title="Commented on <%= comment.created_at %>"><b><%= I18n.t('comments.title') %></b> <%= I18n.t('general.in') %> <%= link_to comment.thing.title, thing_path(comment.thing) %></span> + <% end %> + </div> + + <div class="comment-header-actions"> + <% if defined?(from) %> + <a title="<%= I18n.t('comments.edit-title') %>" href="#" class="edit-comment" data-action="comment#toggle"><%= I18n.t('general.edit').capitalize %></a> + <%= link_to I18n.t('general.delete').capitalize, thing_comment_path(comment.thing, comment, from: from), data: { "turbo-method": :delete }, title: I18n.t('comments.delete-title'), class: 'delete-comment' %> + <% end %> + </div> + </div> + + <div class="comment-body"> + <%= comment.content %> + </div> + + <% if comment.tag_references.any? %> + <div class="comment-footer"> + <small><b><%= I18n.t('tags.title') %></b>: <%= comment.tags.order(:name).pluck(:name).join(", ") %></small> + </div> + <% end %> + + <% if defined?(from) %> + <div class="comment-edit-body hidden"> + <%= form_with model: [comment.thing, comment] do |form| %> + <%= form.hidden_field :from, value: from %> + <%= form.rich_text_area :content, autofocus: true %> + + <%= render "tags/taggable", tag_references: comment.tag_references.pluck(:tag_id), form: form %> + + <%= form.submit %> + <span><%= I18n.t('general.or') %> <a title="Cancel editing of comment" href="#" data-action="comment#toggle"><%= I18n.t('general.cancel') %></a></span> + <% end %> + </div> + <% end %> +</div> diff --git a/app/views/comments/_list.html.erb b/app/views/comments/_list.html.erb new file mode 100644 index 0000000..2396140 --- /dev/null +++ b/app/views/comments/_list.html.erb @@ -0,0 +1,30 @@ +<div id="comment-list" data-controller="comment"> + <h5><%= I18n.t('comments.title-plural') %></h5> + + <div> + <% if thing.comments.any? %> + <% thing.comments.each_with_index do |comment, idx| %> + <%= render comment, from: from, idx: idx %> + <% end %> + <% else %> + <p><%= I18n.t('comments.none') %>.</p> + <% end %> + </div> + + <div id="comment-new-section"> + <a id="comment-new-button" class="button" href="#" data-action="comment#toggleNewComment"><%= I18n.t('comments.new') %></a> + + <div id="comment-new-body" class="hidden"> + <%= form_with model: [thing, Comment.new] do |form| %> + <%= form.rich_text_area :content, size: "20x5", autofocus: true %> + + <%= form.hidden_field :from, value: from %> + + <%= render "tags/taggable", tag_references: [], form: form %> + + <%= form.submit %> + <span><%= I18n.t('general.or') %> <a title="<%= I18n.t('general.cancel') %>" href="#" data-action="comment#toggleNewComment"><%= I18n.t('general.cancel') %></a></span> + <% end %> + </div> + </div> +</div> diff --git a/app/views/exports/new.html.erb b/app/views/exports/new.html.erb new file mode 100644 index 0000000..73481a7 --- /dev/null +++ b/app/views/exports/new.html.erb @@ -0,0 +1,14 @@ +<div class="notice"><b><%= I18n.t('searches.export.note') %></b>: <%= I18n.t('searches.export.note-msg') %>.</div> + +<div data-controller="export"> + <div id="errors"></div> + + <input type="hidden" data-export-target="id" value="<%= @search.id %>" /> + + <select id="export-select-format" data-action="export#change"> + <option selected="selected" value="csv">CSV</option> + <option value="uoc">UOC</option> + </select> + + <a id="export-format-button" class="button" href="#">Export</a> +</div> diff --git a/app/views/exports/show.csv.erb b/app/views/exports/show.csv.erb new file mode 100644 index 0000000..43c7b82 --- /dev/null +++ b/app/views/exports/show.csv.erb @@ -0,0 +1 @@ +<%= CsvExporter.new(things: @results).export.html_safe %> diff --git a/app/views/exports/show.uoc.erb b/app/views/exports/show.uoc.erb new file mode 100644 index 0000000..b8f73b1 --- /dev/null +++ b/app/views/exports/show.uoc.erb @@ -0,0 +1,7 @@ +\chapter{<%= I18n.t('searches.export.chapter') %>} + +{\setlength{\parskip}{-0.3cm} + +<%= UocExporter.new(things: @results.order(:authors)).export %> + +} diff --git a/app/views/layouts/_errors.html.erb b/app/views/layouts/_errors.html.erb new file mode 100644 index 0000000..9e5ce38 --- /dev/null +++ b/app/views/layouts/_errors.html.erb @@ -0,0 +1,13 @@ +<% if model.errors.size.positive? %> + <div class="notice"> + <% if model.errors.size == 1 %> + <span><%= model.errors.first.full_message %>.</span> + <% else %> + <ul> + <% model.errors.each do |error| %> + <li><%= error.full_message %>.</li> + <% end %> + </ul> + <% end %> + </div> +<% end %> diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb new file mode 100644 index 0000000..9e3c0d0 --- /dev/null +++ b/app/views/layouts/action_text/contents/_content.html.erb @@ -0,0 +1,3 @@ +<div class="trix-content"> + <%= yield -%> +</div> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb new file mode 100644 index 0000000..6042f43 --- /dev/null +++ b/app/views/layouts/application.html.erb @@ -0,0 +1,78 @@ +<!DOCTYPE html> +<html> + <head> + <title><%= ENV.fetch('OPERUM_BASE_TITLE', 'Operum') %></title> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + </head> + + <body> + <% if current_user && @searches %> + <header data-controller="header"> + <nav> + <a class="<%= 'current' if current_user.last_search_id.nil? %>" href="/"><%= I18n.t('searches.home') %></a> + <% Search.find_each do |s| %> + <a class="<%= 'current' if current_user.last_search_id == s.id %>" href="<%= search_path(s) %>"><%= s.name %></a> + <% end %> + <a id="toggle-hidden-global-menu" href="#" data-action="header#toggle"><i id="header-chevron" class="gg-chevron-down"></i></a> + </nav> + + <div id="hidden-nav" class="hidden"> + <div class="flex-wrap"> + <span><%= I18n.t('general.create').capitalize %>: </span> + <%= link_to I18n.t('searches.object'), new_search_path %> + <%= link_to I18n.t('things.object'), new_thing_path %> + </div> + + <div class="flex-wrap"> + <span><%= I18n.t('searches.title') %>: </span> + <% if @search&.id %> + <%= link_to I18n.t('general.edit'), edit_search_path(@search) %> + <%= link_to I18n.t('general.delete'), search_path(@search), data: { turbo_confirm: I18n.t('general.sure'), turbo_method: :delete } %> + <% end %> + + <%= link_to I18n.t('searches.export.action'), new_search_exports_path(current_user.last_search_id ? current_user.last_search_id : '0') %> + </div> + + <div class="flex-wrap"> + <span><%= I18n.t('general.list').capitalize %>: </span> + <%= link_to I18n.t('tags.title').downcase, tags_path %> + <%= link_to I18n.t('searches.shared.title').downcase, shared_searches_path %> + </div> + </div> + </header> + <% end %> + + <main> + <% if flash[:notice] %> + <div class="notice"> + <span><%= flash[:notice] %>.</span> + </div> + <% end %> + + <% if flash[:alert] %> + <div class="notice"> + <span><%= flash[:alert] %>.</span> + </div> + <% end %> + + <%= yield %> + </main> + + <footer> + <p> + <%= I18n.t('layout.created-by') %> <a href="http://jo.mssola.com/">Miquel SabatĂ© SolĂ </a> + <br> + <%= I18n.t('layout.this-page-license') %> <a href="/license">AGPLv3</a> (<a href="https://github.com/mssola/operum"><%= I18n.t('layout.source-code') %></a>) + <% if current_user %> + <br> + <%= link_to I18n.t('sessions.sign-out'), sessions_path, data: { turbo_method: :delete } %> + <% end %> + </p> + </footer> + </body> +</html> diff --git a/app/views/licenses/show.html.erb b/app/views/licenses/show.html.erb new file mode 100644 index 0000000..7687d54 --- /dev/null +++ b/app/views/licenses/show.html.erb @@ -0,0 +1,23 @@ +<h3><%= t('license.title') %> <a href="https://www.gnu.org/licenses/agpl-3.0.html">AGPLv3</a></h3> + +<p><%= t('license.at') %> <a href="https://github.com/mssola/operum"><%= t('license.here') %></a>.</p> + +<blockquote> +<pre> +Copyright (C) 2023-Ω Miquel SabatĂ© SolĂ <mikisabate@gmail.com> + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see <https://www.gnu.org/licenses/>. +</pre> + +</blockquote> diff --git a/app/views/searches/_form.html.erb b/app/views/searches/_form.html.erb new file mode 100644 index 0000000..c940fe2 --- /dev/null +++ b/app/views/searches/_form.html.erb @@ -0,0 +1,29 @@ +<div data-controller="search"> + <%= form_with(model: search) do |form| %> + <%= render "layouts/errors", model: search %> + + <div id="search_form"> + <div id="search-form-name" class="hidden"> + <%= form.label :name %> + <%= form.text_field :name, required: true, autocomplete: 'off' %> + </div> + + <div> + <%= form.text_field :body, "data-action": "keydown.enter->search#load", "data-search-target": "body", placeholder: 'Search body' %> + <a id="save-search" href="#" data-action="search#save" class="hidden"><%= I18n.t('searches.save') %></a> + </div> + + <div id="search-form-shared" class="hidden"> + <%= form.check_box :shared %> + <%= form.label :shared, I18n.t('searches.public') %> + </div> + + <div id="search-form-submit" class="hidden"> + <%= form.submit %> + </div> + </div> + <% end %> + + <div id="results"> + </div> +</div> diff --git a/app/views/searches/_search.html.erb b/app/views/searches/_search.html.erb new file mode 100644 index 0000000..a717a22 --- /dev/null +++ b/app/views/searches/_search.html.erb @@ -0,0 +1,5 @@ +<div id="items"> + <% items.values.flatten.each do |item| %> + <%= render item %> + <% end %> +</div> diff --git a/app/views/searches/edit.html.erb b/app/views/searches/edit.html.erb new file mode 100644 index 0000000..2702224 --- /dev/null +++ b/app/views/searches/edit.html.erb @@ -0,0 +1 @@ +<%= render "form", search: @search %> diff --git a/app/views/searches/index.html.erb b/app/views/searches/index.html.erb new file mode 100644 index 0000000..b7d3854 --- /dev/null +++ b/app/views/searches/index.html.erb @@ -0,0 +1 @@ +<%= render "search", items: @search.results %> diff --git a/app/views/searches/new.html.erb b/app/views/searches/new.html.erb new file mode 100644 index 0000000..2702224 --- /dev/null +++ b/app/views/searches/new.html.erb @@ -0,0 +1 @@ +<%= render "form", search: @search %> diff --git a/app/views/searches/show.html.erb b/app/views/searches/show.html.erb new file mode 100644 index 0000000..b7d3854 --- /dev/null +++ b/app/views/searches/show.html.erb @@ -0,0 +1 @@ +<%= render "search", items: @search.results %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..c6d7f4f --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,21 @@ +<div class="center-contents"> + <div></div> + <div> + <h2><%= I18n.t('sessions.title') %></h2> + + <%= form_tag sessions_path do |form| %> + <div class='field'> + <%= label_tag :username, I18n.t('activerecord.attributes.user.username') %> + <%= text_field_tag :username, '', autofocus: true, class: 'width-100' %> + </div> + <div class='field'> + <%= label_tag :password, I18n.t('activerecord.attributes.user.password') %> + <%= password_field_tag :password, '', class: 'width-100' %> + </div> + <div class='actions'> + <%= submit_tag I18n.t('sessions.sign-in') %> + </div> + <% end %> + </div> + <div></div> +</div> diff --git a/app/views/shared_searches/index.html.erb b/app/views/shared_searches/index.html.erb new file mode 100644 index 0000000..9a145f6 --- /dev/null +++ b/app/views/shared_searches/index.html.erb @@ -0,0 +1,13 @@ +<h3><%= I18n.t('searches.shared.title') %></h3> + +<% if @searches.empty? %> + <div><span><%= I18n.t('searches.shared.none') %>.</span></div> +<% else %> + <div id="shared-searches-list"> + <% @searches.each do |s| %> + <div> + <%= link_to s.name, search_shared_path(s) %> + </div> + <% end %> + </div> +<% end %> diff --git a/app/views/shared_searches/show.html.erb b/app/views/shared_searches/show.html.erb new file mode 100644 index 0000000..1430529 --- /dev/null +++ b/app/views/shared_searches/show.html.erb @@ -0,0 +1,28 @@ +<table id="public-search-table"> + <% @search.results.values.flatten.each do |res| %> + <tr> + <td> + <details> + <% if res.is_a? Thing %> + <summary><%= res.title %></summary> + <div class="details-flex"> + <div><span><b>Authors</b>: <%= authors_or_editors(res) %></span></div> + <div><span><b>Publisher</b>: <%= string_maybe(res.publisher) %></span></div> + <div><span><b>Year</b>: <%= string_maybe(res.year) %></span></div> + <div><span><b>Kind</b>: <%= res.kind %></span></div> + <div><span><b>Note</b>: <%= string_maybe(res.note) %></span></div> + <% unless res.url.blank? %> + <div><span><b>URL</b>: <a href="<%= res.url %>" target="_blank"><%= res.url %></a></span></div> + <% end %> + </div> + <% else %> + <summary><b><%= I18n.t('comments.title') %></b> <%= I18n.t('general.in') %> «<i><%= res.thing.title %></i>»</summary> + <div class="details-flex"> + <%= res.content %> + </div> + <% end %> + </details> + </td> + </tr> + <% end %> +</table> diff --git a/app/views/tags/_form.html.erb b/app/views/tags/_form.html.erb new file mode 100644 index 0000000..75fc003 --- /dev/null +++ b/app/views/tags/_form.html.erb @@ -0,0 +1,12 @@ +<%= form_with(model: tag) do |form| %> + <%= render "layouts/errors", model: tag %> + + <div> + <%= form.label :name, style: "display: block" %> + <%= form.text_field :name, autofocus: true, autocomplete: 'off', class: 'width-100' %> + </div> + + <div> + <%= form.submit %> + </div> +<% end %> diff --git a/app/views/tags/_tag.html.erb b/app/views/tags/_tag.html.erb new file mode 100644 index 0000000..65c6d4e --- /dev/null +++ b/app/views/tags/_tag.html.erb @@ -0,0 +1,8 @@ +<div class="tag-container" id="<%= dom_id tag %>"> + <div class="name"> + <%= tag.name %> + </div> + <div class="action"> + <%= link_to I18n.t('general.delete'), tag_path(tag), data: { "turbo-method": :delete } %> + </div> +</div> diff --git a/app/views/tags/_taggable.html.erb b/app/views/tags/_taggable.html.erb new file mode 100644 index 0000000..84e236b --- /dev/null +++ b/app/views/tags/_taggable.html.erb @@ -0,0 +1,17 @@ +<% if Tag.any? %> + <div> + <h5><%= I18n.t('tags.title') %> — <%= link_to I18n.t('tags.new-action'), new_tag_path %></h5> + + <div class="flex-list"> + <% Tag.find_each do |tag| %> + <div class="flex-element"> + <label> + <%= form.check_box :tag_ids, { multiple: true, checked: tag_references&.include?(tag.id) }, tag.id, nil %> + <%= tag.name %> + </label> + </div> + <% end %> + </div> + </div> + <p></p> +<% end %> diff --git a/app/views/tags/index.html.erb b/app/views/tags/index.html.erb new file mode 100644 index 0000000..329e069 --- /dev/null +++ b/app/views/tags/index.html.erb @@ -0,0 +1,13 @@ +<div class="center-contents"> + <div></div> + <div id="tag-index"> + <h2><%= I18n.t('tags.title') %> <span>— <%= link_to I18n.t('tags.new-action'), new_tag_path %></span></h2> + + <div id="tags-list"> + <% @tags.each do |tag| %> + <%= render tag %> + <% end %> + </div> + </div> + <div></div> +</div> diff --git a/app/views/tags/new.html.erb b/app/views/tags/new.html.erb new file mode 100644 index 0000000..bd6c586 --- /dev/null +++ b/app/views/tags/new.html.erb @@ -0,0 +1,9 @@ +<div class="center-contents"> + <div></div> + <div id="tag-new"> + <h2><%= I18n.t('tags.new') %> <span>— <%= link_to I18n.t('general.back-to-list'), tags_path %></span></h2> + + <%= render "form", tag: @tag %> + </div> + <div></div> +</div> diff --git a/app/views/things/_form.html.erb b/app/views/things/_form.html.erb new file mode 100644 index 0000000..da90302 --- /dev/null +++ b/app/views/things/_form.html.erb @@ -0,0 +1,102 @@ +<%= form_with(model: thing) do |form| %> + <%= render "layouts/errors", model: thing %> + + <div id="thing_form"> + <div class="thing_large"> + <%= form.label :title %> + <%= form.text_field :title, autofocus: true, required: true %> + </div> + + <div class="thing_large"> + <%= form.label :authors %> + <%= form.text_field :authors, required: true %> + <%= form.check_box :editors %> + <%= form.label :editors %> + </div> + + <div class="thing_large"> + <%= form.label :note %> + <%= form.text_field :note %> + </div> + + <div> + <%= form.label :target %> + <%= form.text_field :target, required: true %> + </div> + + <div> + <%= form.label :publisher %> + <%= form.text_field :publisher %> + </div> + + <div> + <%= form.label :year %> + <%= form.number_field :year %> + </div> + + <div> + <%= form.label :address %> + <%= form.text_field :address %> + </div> + + <div> + <%= form.label :url %> + <%= form.text_field :url %> + </div> + + <div> + <%= form.label :access %> + <%= form.date_field :access %> + </div> + + <div> + <%= form.label :location %> + <%= form.text_field :location %> + </div> + + <div> + <%= form.label :insideof %> + <%= form.text_field :insideof %> + </div> + + <div> + <%= form.label :pages %> + <%= form.text_field :pages %> + </div> + + <div> + <%= form.label :rate %> + <%= form.number_field :rate, min: 0, max: 10, required: true %> + </div> + + <div> + <%= form.label :status %> + <%= form.select :status, options_for_select(Thing.statuses.map { |k, _| [I18n.t("things.status.#{k}"), k] }.sort, thing.status) %> + </div> + + <div> + <%= form.label :kind %> + <%= form.select :kind, options_for_select(Thing.kinds.map { |k, _| [I18n.t("things.kind.#{k}"), k] }.sort, thing.kind) %> + </div> + + <div> + <%= form.label :bought_at %> + <%= form.date_field :bought_at %> + </div> + + <div> + <%= form.label :where_is_it %> + <%= form.text_field :where_is_it %> + </div> + </div> + + <%= render "tags/taggable", tag_references: thing.tag_references.pluck(:tag_id), form: form %> + + <div> + <%= form.submit %> + </div> +<% end %> + +<% if thing.id %> + <%= button_to I18n.t('general.delete').capitalize, thing, id: 'thing-delete-button', form: { data: { turbo_confirm: I18n.t('general.sure') } }, method: :delete %> +<% end %> diff --git a/app/views/things/_thing.html.erb b/app/views/things/_thing.html.erb new file mode 100644 index 0000000..5a31ccc --- /dev/null +++ b/app/views/things/_thing.html.erb @@ -0,0 +1,7 @@ +<div id="<%= dom_id thing %>" class="thing_row"> + <div class="thing_head"> + <div class="dot dot_<%= thing.kind %>" title="<%= I18n.t("things.kind.#{thing.kind}") %>"></div> + </div> + <div class="thing_body"><%= link_to thing.title, edit_thing_path(thing) %></div> + <div class="thing_tail"><%= thing.rate %></div> +</div> diff --git a/app/views/things/edit.html.erb b/app/views/things/edit.html.erb new file mode 100644 index 0000000..f30ec5c --- /dev/null +++ b/app/views/things/edit.html.erb @@ -0,0 +1,3 @@ +<%= render "form", thing: @thing %> + +<%= render "comments/list", thing: @thing, from: 'things/edit' %> diff --git a/app/views/things/new.html.erb b/app/views/things/new.html.erb new file mode 100644 index 0000000..d28df14 --- /dev/null +++ b/app/views/things/new.html.erb @@ -0,0 +1 @@ +<%= render "form", thing: @thing %> diff --git a/app/views/things/show.html.erb b/app/views/things/show.html.erb new file mode 100644 index 0000000..c534e49 --- /dev/null +++ b/app/views/things/show.html.erb @@ -0,0 +1,7 @@ +<%= render "things/thing", thing: @thing %> + +<p> + <%= link_to I18n.t('things.create-another'), new_thing_path %> +</p> + +<%= render "comments/list", thing: @thing, from: 'things/show' %> diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 0000000..50da5fd --- /dev/null +++ b/bin/bundle @@ -0,0 +1,109 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a.match?(Gem::Version::ANCHORED_VERSION_PATTERN) + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../Gemfile", __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, ".locked") + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_requirement + @bundler_requirement ||= + env_var_version || + cli_arg_version || + bundler_requirement_for(lockfile_version) + end + + def bundler_requirement_for(version) + return "#{Gem::Requirement.default}.a" unless version + + bundler_gem_version = Gem::Version.new(version) + + bundler_gem_version.approximate_recommendation + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/docker-entrypoint b/bin/docker-entrypoint new file mode 100755 index 0000000..67ef493 --- /dev/null +++ b/bin/docker-entrypoint @@ -0,0 +1,8 @@ +#!/bin/bash -e + +# If running the rails server then create or migrate existing database +if [ "${1}" == "./bin/rails" ] && [ "${2}" == "server" ]; then + ./bin/rails db:prepare +fi + +exec "${@}" diff --git a/bin/importmap b/bin/importmap new file mode 100755 index 0000000..d423864 --- /dev/null +++ b/bin/importmap @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../config/application' +require 'importmap/commands' diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..a31728a --- /dev/null +++ b/bin/rails @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative '../config/boot' +require 'rails/commands' diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..c199955 --- /dev/null +++ b/bin/rake @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative '../config/boot' +require 'rake' +Rake.application.run diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..016b7e2 --- /dev/null +++ b/bin/setup @@ -0,0 +1,35 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'fileutils' + +# path to your application root. +APP_ROOT = File.expand_path('..', __dir__) + +def system!(*) + system(*, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts '== Installing dependencies ==' + system! 'gem install bundler --conservative' + system('bundle check') || system!('bundle install') + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! 'bin/rails db:prepare' + + puts "\n== Removing old logs and tempfiles ==" + system! 'bin/rails log:clear tmp:clear' + + puts "\n== Restarting application server ==" + system! 'bin/rails restart' +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..6dc8321 --- /dev/null +++ b/config.ru @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This file is used by Rack-based servers to start the application. + +require_relative 'config/environment' + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..1c5d017 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative 'boot' + +require 'rails/all' + +# Require the gems listed in Gemfile, including any gems +# you've limited to :test, :development, or :production. +Bundler.require(*Rails.groups) + +module Operum + class Application < Rails::Application + # Initialize configuration defaults for originally generated Rails version. + config.load_defaults 7.1 + + # Please, add to the `ignore` list any other `lib` subdirectories that do + # not contain `.rb` files, or that should not be reloaded or eager loaded. + # Common ones are `templates`, `generators`, or `middleware`, for example. + config.autoload_lib(ignore: %w[assets tasks]) + + # Configuration for the application, engines, and railties goes here. + # + # These settings can be overridden in specific environments using the files + # in config/environments, which are processed later. + # + # config.time_zone = "Central Time (US & Canada)" + # config.eager_load_paths << Rails.root.join("extras") + end +end diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..c04863f --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) + +require 'bundler/setup' # Set up gems listed in the Gemfile. +require 'bootsnap/setup' # Speed up boot time by caching expensive operations. diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..8747fc1 --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,10 @@ +development: + adapter: async + +test: + adapter: test + +production: + adapter: redis + url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> + channel_prefix: operum_production diff --git a/config/credentials.yml.enc b/config/credentials.yml.enc new file mode 100644 index 0000000..9c7c0cb --- /dev/null +++ b/config/credentials.yml.enc @@ -0,0 +1 @@ +pIht0KY2mizAwrCdTEzPaWNu6vu/NQpI564SwxPwhLNexvZyWSrZ7L0YcGGENLlIW5xBH9gwVZJHpupRi0G1DcdP0NgHqVUHYqxnFXf6ngp7L15t/rWd4691K3HWF/9rebrjRwB33joitCvQQ/fud8SwIZ5nEQEq1edJzZywAX/9L+eMuI3hkxyq0+APQMnuOXN2yK38qTTdG46hI3lkcWm+ZcfOXKDg0oscjExdGT6zVpNoIqgtIdYbT+s1DXPzsxLt+Z4T0eSByQjSgfEHmNK0zmPpFR6LSpe97MCkqDM00rotJBQQpNeXqPOP6omdzJKsgiCst/PCJMYk74NtX+iv1SY4WvCzu8ReqnwDZyJ4MAzlAGLMO5ENwcoidugPoo72daoIcGVCX7iUEF6DUb4CZYtf--1M9Bc6cWdSUi6Y8k--dp3GZYMgAU4gvlnD+FlEhQ==
\ No newline at end of file diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..796466b --- /dev/null +++ b/config/database.yml @@ -0,0 +1,25 @@ +# SQLite. Versions 3.8.0 and up are supported. +# gem install sqlite3 +# +# Ensure the SQLite 3 gem is defined in your Gemfile +# gem "sqlite3" +# +default: &default + adapter: sqlite3 + pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + timeout: 5000 + +development: + <<: *default + database: storage/development.sqlite3 + +# 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: storage/test.sqlite3 + +production: + <<: *default + database: storage/production.sqlite3 diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..d5abe55 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require_relative 'application' + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..80e0587 --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/integer/time' + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # In the development environment your application's code is reloaded any time + # it changes. This slows down response time but is perfect for development + # since you don't have to restart the web server when you make code changes. + config.enable_reloading = true + + # Do not eager load code on boot. + config.eager_load = false + + # Show full error reports. + config.consider_all_requests_local = true + + # Enable server timing + config.server_timing = true + + # Enable/disable caching. By default caching is disabled. + # Run rails dev:cache to toggle caching. + if Rails.root.join('tmp/caching-dev.txt').exist? + config.action_controller.perform_caching = true + config.action_controller.enable_fragment_cache_logging = true + + config.cache_store = :memory_store + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{2.days.to_i}" + } + else + config.action_controller.perform_caching = false + + config.cache_store = :null_store + end + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Don't care if the mailer can't send. + config.action_mailer.raise_delivery_errors = false + + config.action_mailer.perform_caching = false + + # Print deprecation notices to the Rails logger. + config.active_support.deprecation = :log + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raise an error on page load if there are pending migrations. + config.active_record.migration_error = :page_load + + # Highlight code that triggered database queries in logs. + config.active_record.verbose_query_logs = true + + # Highlight code that enqueued background job in logs. + config.active_job.verbose_enqueue_logs = true + + # Suppress logger output for asset requests. + config.assets.quiet = true + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Uncomment if you wish to allow Action Cable access from any origin. + # config.action_cable.disable_request_forgery_protection = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..a9e4d6d --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/integer/time' + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # Code is not reloaded between requests. + config.enable_reloading = false + + # Eager load code on boot. This eager loads most of Rails and + # your application in memory, allowing both threaded web servers + # and those relying on copy on write to perform better. + # Rake tasks automatically ignore this option for performance. + config.eager_load = true + + # Full error reports are disabled and caching is turned on. + config.consider_all_requests_local = false + config.action_controller.perform_caching = true + + # Ensures that a master key has been made available in + # ENV["RAILS_MASTER_KEY"], config/master.key, or an environment key such as + # config/credentials/production.key. This key is used to decrypt credentials + # (and other encrypted files). + # config.require_master_key = true + + # Disable serving static files from `public/`, relying on NGINX/Apache to do so instead. + # config.public_file_server.enabled = false + + # Compress CSS using a preprocessor. + # config.assets.css_compressor = :sass + + # Do not fall back to assets pipeline if a precompiled asset is missed. + config.assets.compile = false + + # Enable serving of images, stylesheets, and JavaScripts from an asset server. + # config.asset_host = "http://assets.example.com" + + # Specifies the header that your server uses for sending files. + # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache + # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX + + # Store uploaded files on the local file system (see config/storage.yml for options). + config.active_storage.service = :local + + # Mount Action Cable outside main process or domain. + # config.action_cable.mount_path = nil + # config.action_cable.url = "wss://example.com/cable" + # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] + + # Assume all access to the app is happening through a SSL-terminating reverse proxy. + # Can be used together with config.force_ssl for Strict-Transport-Security and secure cookies. + # config.assume_ssl = true + + # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. + config.force_ssl = true + + # Log to STDOUT by default + config.logger = ActiveSupport::Logger.new($stdout) + .tap { |logger| logger.formatter = Logger::Formatter.new } + .then { |logger| ActiveSupport::TaggedLogging.new(logger) } + + # Prepend all log lines with the following tags. + config.log_tags = [:request_id] + + # "info" includes generic and useful information about system operation, but + # avoids logging too much information to avoid inadvertent exposure of + # personally identifiable information (PII). If you want to log everything, + # set the level to "debug". + config.log_level = ENV.fetch('RAILS_LOG_LEVEL', 'info') + + # Use a different cache store in production. + # config.cache_store = :mem_cache_store + + # Use a real queuing backend for Active Job (and separate queues per environment). + # config.active_job.queue_adapter = :resque + # config.active_job.queue_name_prefix = "operum_production" + + config.action_mailer.perform_caching = false + + # Ignore bad email addresses and do not raise email delivery errors. Set this + # to true and configure the email server for immediate delivery to raise + # delivery errors. + # config.action_mailer.raise_delivery_errors = false + + # Enable locale fallbacks for I18n (makes lookups for any locale fall back to + # the I18n.default_locale when a translation cannot be found). + config.i18n.fallbacks = true + + # Don't log any deprecations. + config.active_support.report_deprecations = false + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + + # Enable DNS rebinding protection and other `Host` header attacks. + # config.hosts = [ + # "example.com", # Allow requests from example.com + # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` + # ] + # Skip DNS rebinding protection for the default health check endpoint. + # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..f1d2fb5 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/integer/time' + +# The test environment is used exclusively to run your application's +# test suite. You never need to work with it otherwise. Remember that +# your test database is "scratch space" for the test suite and is wiped +# and recreated between test runs. Don't rely on the data there! + +Rails.application.configure do + # Settings specified here will take precedence over those in config/application.rb. + + # While tests run files are not watched, reloading is not necessary. + config.enable_reloading = false + + # Eager loading loads your entire application. When running a single test locally, + # this is usually not necessary, and can slow down your test suite. However, it's + # recommended that you enable it in continuous integration systems to ensure eager + # loading is working properly before deploying your code. + config.eager_load = ENV['CI'].present? + + # Configure public file server for tests with Cache-Control for performance. + config.public_file_server.enabled = true + config.public_file_server.headers = { + 'Cache-Control' => "public, max-age=#{1.hour.to_i}" + } + + # Show full error reports and disable caching. + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + + # Render exception templates for rescuable exceptions and raise for other exceptions. + config.action_dispatch.show_exceptions = :rescuable + + # Disable request forgery protection in test environment. + config.action_controller.allow_forgery_protection = false + + # Store uploaded files on the local file system in a temporary directory. + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + + # Tell Action Mailer not to deliver emails to the real world. + # The :test delivery method accumulates sent emails in the + # ActionMailer::Base.deliveries array. + config.action_mailer.delivery_method = :test + + # Print deprecation notices to the stderr. + config.active_support.deprecation = :stderr + + # Raise exceptions for disallowed deprecations. + config.active_support.disallowed_deprecation = :raise + + # Tell Active Support which deprecation messages to disallow. + config.active_support.disallowed_deprecation_warnings = [] + + # Raises error for missing translations. + # config.i18n.raise_on_missing_translations = true + + # Annotate rendered view with file names. + # config.action_view.annotate_rendered_view_with_filenames = true + + # Raise error when a before_action's only/except options reference missing actions + config.action_controller.raise_on_missing_callback_actions = true +end diff --git a/config/importmap.rb b/config/importmap.rb new file mode 100644 index 0000000..bca5021 --- /dev/null +++ b/config/importmap.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Pin npm packages by running ./bin/importmap + +pin 'application' +pin '@hotwired/turbo-rails', to: 'turbo.min.js' +pin '@hotwired/stimulus', to: 'stimulus.min.js' +pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js' +pin_all_from 'app/javascript/controllers', under: 'controllers' +pin 'trix' +pin '@rails/actiontext', to: 'actiontext.esm.js' diff --git a/config/initializers/assets.rb b/config/initializers/assets.rb new file mode 100644 index 0000000..bcafccd --- /dev/null +++ b/config/initializers/assets.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Version of your assets, change this if you want to expire all your assets. +Rails.application.config.assets.version = '1.0' + +# Add additional assets to the asset load path. +# Rails.application.config.assets.paths << Emoji.images_path + +# Precompile additional assets. +# application.js, application.css, and all non-JS/CSS in the app/assets +# folder are already added. +# Rails.application.config.assets.precompile += %w( admin.js admin.css ) diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 0000000..af395e4 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +# Be sure to restart your server when you modify this file. + +# Define an application-wide content security policy. +# See the Securing Rails Applications Guide for more information: +# https://guides.rubyonrails.org/security.html#content-security-policy-header + +# Rails.application.configure do +# config.content_security_policy do |policy| +# policy.default_src :self, :https +# policy.font_src :self, :https, :data +# policy.img_src :self, :https, :data +# policy.object_src :none +# policy.script_src :self, :https +# policy.style_src :self, :https +# # Specify URI for violation reports +# # policy.report_uri "/csp-violation-report-endpoint" +# end +# +# # Generate session nonces for permitted importmap, inline scripts, and inline styles. +# config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } +# config.content_security_policy_nonce_directives = %w(script-src style-src) +# +# # Report violations without enforcing the policy. +# # config.content_security_policy_report_only = true +# end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..7231487 --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and +# filtered from the log file. Use this to limit dissemination of sensitive +# information. See the ActiveSupport::ParameterFilter documentation for +# supported notations and behaviors. +Rails.application.config.filter_parameters += %i[ + passw secret token _key crypt salt certificate otp ssn +] diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb new file mode 100644 index 0000000..6c78420 --- /dev/null +++ b/config/initializers/inflections.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +# Be sure to restart your server when you modify this file. + +# Add new inflection rules using the following format. Inflections +# are locale specific, and you may define rules for as many different +# locales as you wish. All of these examples are active by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.plural /^(ox)$/i, "\\1en" +# inflect.singular /^(ox)en/i, "\\1" +# inflect.irregular "person", "people" +# inflect.uncountable %w( fish sheep ) +# end + +# These inflection rules are supported but not enabled by default: +# ActiveSupport::Inflector.inflections(:en) do |inflect| +# inflect.acronym "RESTful" +# end diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb new file mode 100644 index 0000000..f3d7af7 --- /dev/null +++ b/config/initializers/locale.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +I18n.available_locales = %i[en ca] +I18n.default_locale = ENV.fetch('OPERUM_DEFAULT_LOCALE', :ca).to_sym + +Rails.logger.info "Locale: #{I18n.default_locale}" diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb new file mode 100644 index 0000000..6043616 --- /dev/null +++ b/config/initializers/mime_types.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +Mime::Type.register 'application/x-tex', :uoc diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb new file mode 100644 index 0000000..b635b52 --- /dev/null +++ b/config/initializers/permissions_policy.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +# Be sure to restart your server when you modify this file. + +# Define an application-wide HTTP permissions policy. For further +# information see: https://developers.google.com/web/updates/2018/06/feature-policy + +# Rails.application.config.permissions_policy do |policy| +# policy.camera :none +# policy.gyroscope :none +# policy.microphone :none +# policy.usb :none +# policy.fullscreen :self +# policy.payment :self, "https://secure.example.com" +# end diff --git a/config/locales/ca.yml b/config/locales/ca.yml new file mode 100644 index 0000000..ae9983c --- /dev/null +++ b/config/locales/ca.yml @@ -0,0 +1,129 @@ +ca: + ## + # Rails thingies. + + activerecord: + models: + comment: Comentari + tag: Etiqueta + thing: Font + attributes: + comment: + content: Contingut + tag: + name: Nom + thing: + target: Identificador + title: TĂtol + publisher: Editorial + address: Adreça + year: Any + url: Enllaç + access: Ăšltima data de consulta + location: Lloc + insideof: Dins de + pages: PĂ gines + rate: QualificaciĂł + status: Estat + kind: Tipus + note: Notes + authors: Autors + editors: Editors d'una col·lecciĂł + bought_at: Data de compra + where_is_it: Per on para? + user: + username: Nom d'usuari + password: Contrasenya + + helpers: + submit: + create: Afegeix + update: Actualitza + + ## + # App-specific strings. + + general: + sure: N'estĂ s segur? + edit: edita + delete: elimina + new: nou + create: Afegeix + list: llista + in: a + back-to-list: torna a la llista + or: o + cancel: cancel·la + insideof: Dins de + insideof-vowel: Dins d' + + comments: + title: Comentari + title-plural: Comentaris + new: Comenta + none: De moment no sha fet cap comentari + edit-title: Edita comnetari + delete-title: Esborra comentari + bad-create: "No s'ha pogut afegir el comentari per motius desconeguts" + bad-update: "No s'ha pogut actualitzar el comentari per motius desconeguts" + + layout: + created-by: Creat per + this-page-license: Aquesta pĂ gina Ă©s programari lliure i estĂ sota la llicència + source-code: codi font + + license: + title: Llicència + at: El codi font es pot trobar + here: aquĂ + + sessions: + title: Benvingut! + wrong-credentials: Usuari o contrasenya incorrectes + sign-in: Entra + sign-out: Tanca la sessiĂł + + searches: + title: Cerca + object: cerca + home: Tot + save: guarda cerca + public: PĂşblic + + shared: + title: Cerques pĂşbliques + none: De moment no s'ha compartit cap cerca + + export: + action: exporta + note: ATENCIĂ“ + note-msg: nomĂ©s s'inclourĂ informaciĂł que pugui ser utilitzada per a escriure una secciĂł de bibliografia + chapter: Bibliografia + + tags: + title: Etiquetes + new: Etiqueta nova + new-action: nova + create-success: Etiqueta creada correctament + destroy-success: Etiqueta esborrada correctament + + things: + object: font + create-another: Afegeix-ne una altra + create-success: Nova font creada correctament + update-success: Font actualitzada correctament + destroy-success: Font esborrada correctament + + status: + read: Llegit + notread: Per llegir + tobepublished: Per publicar + + kind: + other: Altre + poetry: Poesia + theater: Teatre + essay: Assaig + shorts: NarraciĂł breu + novel: Novel·la + paper: Article diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..f00d7e5 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,131 @@ +en: + ## + # Rails thingies. + + activerecord: + models: + comment: Comment + tag: Tag + thing: Source + attributes: + comment: + content: Content + tag: + name: Name + thing: + target: Identifier + title: Title + publisher: Publisher + address: Address + year: Year + url: URL + access: Last consulted at + location: Place + insideof: Inside of + pages: Pages + rate: Rate + status: Status + kind: Kind + note: Notes + authors: Authors + editors: Editors of a collection + bought_at: Bought at + where_is_it: Where is it? + user: + username: Username + password: Password + + helpers: + submit: + create: Create + update: Update + + ## + # App-specific strings. + + general: + sure: Are you sure? + edit: edit + delete: delete + new: new + create: create + list: list + in: in + back-to-list: back to list + or: or + cancel: cancel + insideof: Inside of + insideof-vowel: Inside of + + comments: + title: Comment + title-plural: Comments + new: New comment + none: 'No comments have been made so far' + edit-title: Edit comment + delete-title: Delete comment + bad-create: Could not add comment for unknown reasons + bad-update: Could not update comment for unknown reasons + + layout: + created-by: Created by + this-page-license: This site is free software and is under the + source-code: source code + + license: + title: License + at: You can find the source code + here: here + + sessions: + title: Welcome! + wrong-credentials: Wrong credentials + sign-in: Sign in + sign-out: Sign out + + searches: + title: Search + object: search + home: Home + save: save search + public: Public + + shared: + title: Public searches + none: 'No searches have been shared for now' + + export: + action: export + note: 'NOTE' + note-msg: only information that can be used for building up a bibliography section will be included + chapter: Bibliography + + tags: + title: Tags + new: New tag + new-action: new + + create-success: Tag was successfully created + destroy-success: Tag was successfully deleted + + things: + object: source + create-another: Create another one + + create-success: New source was successfully created + update-success: Source was successfully updated + destroy-success: Source was successfully deleted + + status: + read: Read + notread: Not read + tobepublished: To be published + + kind: + other: Other + poetry: Poetry + theater: Theater + essay: Essay + shorts: Shorts + novel: Novel + paper: Paper diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..7ed4157 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# This configuration file will be evaluated by Puma. The top-level methods that +# are invoked here are part of Puma's configuration DSL. For more information +# about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. + +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) +min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count } +threads min_threads_count, max_threads_count + +# Specifies that the worker count should equal the number of processors in production. +if ENV['RAILS_ENV'] == 'production' + require 'concurrent-ruby' + worker_count = Integer(ENV.fetch('WEB_CONCURRENCY') { Concurrent.physical_processor_count }) + workers worker_count if worker_count > 1 +end + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +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) + +# Specifies the `environment` that Puma will run in. +environment ENV.fetch('RAILS_ENV', 'development') + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch('PIDFILE', 'tmp/pids/server.pid') + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..1e5eedb --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + resources :searches do + collection do + get 'search' + get 'shared' => 'shared_searches#index' + end + + get 'shared' => 'shared_searches#show' + + resource :exports, only: %i[new show] + end + + resources :tags, only: %i[index new create destroy] + resources :things, except: %i[index] do + resources :comments, only: %i[create update destroy] + end + + resource :license, only: %i[show] + resource :sessions, only: %i[new create destroy] + + # Provided by Rails for health checking :) + get 'up' => 'rails/health#show', as: :rails_health_check + + root 'searches#index' +end diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..4942ab6 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,34 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +# Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) +# amazon: +# service: S3 +# access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> +# secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> +# region: us-east-1 +# bucket: your_own_bucket-<%= Rails.env %> + +# Remember not to checkin your GCS keyfile to a repository +# google: +# service: GCS +# project: your_project +# credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> +# bucket: your_own_bucket-<%= Rails.env %> + +# Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) +# microsoft: +# service: AzureStorage +# storage_account_name: your_account_name +# storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> +# container: your_container_name-<%= Rails.env %> + +# mirror: +# service: Mirror +# primary: local +# mirrors: [ amazon, google, microsoft ] diff --git a/db/migrate/20240224162412_create_things.rb b/db/migrate/20240224162412_create_things.rb new file mode 100644 index 0000000..5fca815 --- /dev/null +++ b/db/migrate/20240224162412_create_things.rb @@ -0,0 +1,23 @@ +class CreateThings < ActiveRecord::Migration[7.1] + def change + create_table :things do |t| + t.string :target + t.string :title, null: false, index: { unique: true } + t.string :publisher + t.string :address + t.integer :year + t.string :url + t.datetime :access + t.string :location + t.string :insideof + t.string :pages + t.references :user, null: false, foreign_key: true + t.integer :rate + t.integer :status + t.integer :kind + t.datetime :bought_at + + t.timestamps + end + end +end diff --git a/db/migrate/20240224164039_create_users.rb b/db/migrate/20240224164039_create_users.rb new file mode 100644 index 0000000..cfd4944 --- /dev/null +++ b/db/migrate/20240224164039_create_users.rb @@ -0,0 +1,10 @@ +class CreateUsers < ActiveRecord::Migration[7.1] + def change + create_table :users do |t| + t.string :username, null: false, index: { unique: true } + t.string :password_digest + + t.timestamps + end + end +end diff --git a/db/migrate/20240224165311_create_comments.rb b/db/migrate/20240224165311_create_comments.rb new file mode 100644 index 0000000..49cb989 --- /dev/null +++ b/db/migrate/20240224165311_create_comments.rb @@ -0,0 +1,10 @@ +class CreateComments < ActiveRecord::Migration[7.1] + def change + create_table :comments do |t| + t.references :thing, null: false, foreign_key: true + t.text :content + + t.timestamps + end + end +end diff --git a/db/migrate/20240224165703_create_active_storage_tables.active_storage.rb b/db/migrate/20240224165703_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..e4706aa --- /dev/null +++ b/db/migrate/20240224165703_create_active_storage_tables.active_storage.rb @@ -0,0 +1,57 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :active_storage_blobs, id: primary_key_type do |t| + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, id: primary_key_type do |t| + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + t.references :blob, null: false, type: foreign_key_type + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, id: primary_key_type do |t| + t.belongs_to :blob, null: false, index: false, type: foreign_key_type + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end +end diff --git a/db/migrate/20240224165704_create_action_text_tables.action_text.rb b/db/migrate/20240224165704_create_action_text_tables.action_text.rb new file mode 100644 index 0000000..1be48d7 --- /dev/null +++ b/db/migrate/20240224165704_create_action_text_tables.action_text.rb @@ -0,0 +1,26 @@ +# This migration comes from action_text (originally 20180528164100) +class CreateActionTextTables < ActiveRecord::Migration[6.0] + def change + # Use Active Record's configured type for primary and foreign keys + primary_key_type, foreign_key_type = primary_and_foreign_key_types + + create_table :action_text_rich_texts, id: primary_key_type do |t| + t.string :name, null: false + t.text :body, size: :long + t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type + + t.timestamps + + t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true + end + end + + private + def primary_and_foreign_key_types + config = Rails.configuration.generators + setting = config.options[config.orm][:primary_key_type] + primary_key_type = setting || :primary_key + foreign_key_type = setting || :bigint + [primary_key_type, foreign_key_type] + end +end diff --git a/db/migrate/20240224220515_create_tags.rb b/db/migrate/20240224220515_create_tags.rb new file mode 100644 index 0000000..7db0657 --- /dev/null +++ b/db/migrate/20240224220515_create_tags.rb @@ -0,0 +1,9 @@ +class CreateTags < ActiveRecord::Migration[7.1] + def change + create_table :tags do |t| + t.string :name, null: false, index: { unique: true } + + t.timestamps + end + end +end diff --git a/db/migrate/20240225070152_create_tag_references.rb b/db/migrate/20240225070152_create_tag_references.rb new file mode 100644 index 0000000..ba945a8 --- /dev/null +++ b/db/migrate/20240225070152_create_tag_references.rb @@ -0,0 +1,10 @@ +class CreateTagReferences < ActiveRecord::Migration[7.1] + def change + create_table :tag_references do |t| + t.references :tag, null: false, foreign_key: true + t.references :taggable, null: false, polymorphic: true + + t.timestamps + end + end +end diff --git a/db/migrate/20240225071927_create_searches.rb b/db/migrate/20240225071927_create_searches.rb new file mode 100644 index 0000000..a2a42e7 --- /dev/null +++ b/db/migrate/20240225071927_create_searches.rb @@ -0,0 +1,11 @@ +class CreateSearches < ActiveRecord::Migration[7.1] + def change + create_table :searches do |t| + t.string :name + t.string :body + t.references :user, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20240225100002_add_note_to_things.rb b/db/migrate/20240225100002_add_note_to_things.rb new file mode 100644 index 0000000..b0c0f40 --- /dev/null +++ b/db/migrate/20240225100002_add_note_to_things.rb @@ -0,0 +1,5 @@ +class AddNoteToThings < ActiveRecord::Migration[7.1] + def change + add_column :things, :note, :string + end +end diff --git a/db/migrate/20240226063134_add_uniqueness_index_search.rb b/db/migrate/20240226063134_add_uniqueness_index_search.rb new file mode 100644 index 0000000..77247df --- /dev/null +++ b/db/migrate/20240226063134_add_uniqueness_index_search.rb @@ -0,0 +1,8 @@ +class AddUniquenessIndexSearch < ActiveRecord::Migration[7.1] + def change + add_index :searches, :name, unique: true + add_index :searches, :body, unique: true + + add_index :things, :target, unique: true + end +end diff --git a/db/migrate/20240227085442_add_last_search_id_to_users.rb b/db/migrate/20240227085442_add_last_search_id_to_users.rb new file mode 100644 index 0000000..be58999 --- /dev/null +++ b/db/migrate/20240227085442_add_last_search_id_to_users.rb @@ -0,0 +1,5 @@ +class AddLastSearchIdToUsers < ActiveRecord::Migration[7.1] + def change + add_column :users, :last_search_id, :integer + end +end diff --git a/db/migrate/20240227145317_add_authors_to_thing.rb b/db/migrate/20240227145317_add_authors_to_thing.rb new file mode 100644 index 0000000..0c26b81 --- /dev/null +++ b/db/migrate/20240227145317_add_authors_to_thing.rb @@ -0,0 +1,6 @@ +class AddAuthorsToThing < ActiveRecord::Migration[7.1] + def change + add_column :things, :authors, :string, null: false + add_column :things, :editors, :boolean, default: false + end +end diff --git a/db/migrate/20240301154452_add_shared_to_searches.rb b/db/migrate/20240301154452_add_shared_to_searches.rb new file mode 100644 index 0000000..4950c23 --- /dev/null +++ b/db/migrate/20240301154452_add_shared_to_searches.rb @@ -0,0 +1,5 @@ +class AddSharedToSearches < ActiveRecord::Migration[7.1] + def change + add_column :searches, :shared, :boolean, default: false + end +end diff --git a/db/migrate/20240307220309_add_where_is_it_to_things.rb b/db/migrate/20240307220309_add_where_is_it_to_things.rb new file mode 100644 index 0000000..6d18307 --- /dev/null +++ b/db/migrate/20240307220309_add_where_is_it_to_things.rb @@ -0,0 +1,5 @@ +class AddWhereIsItToThings < ActiveRecord::Migration[7.1] + def change + add_column :things, :where_is_it, :string + end +end diff --git a/db/migrate/20240313133513_change_access_and_bought_at_to_date.rb b/db/migrate/20240313133513_change_access_and_bought_at_to_date.rb new file mode 100644 index 0000000..ea2f5bd --- /dev/null +++ b/db/migrate/20240313133513_change_access_and_bought_at_to_date.rb @@ -0,0 +1,6 @@ +class ChangeAccessAndBoughtAtToDate < ActiveRecord::Migration[7.1] + def change + change_column :things, :access, :date + change_column :things, :bought_at, :date + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..967b074 --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,131 @@ +# This file is auto-generated from the current state of the database. Instead +# 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 `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[7.1].define(version: 2024_03_13_133513) 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.bigint "record_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true + end + + 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 "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.string "service_name", null: false + t.bigint "byte_size", null: false + t.string "checksum" + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + 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 "comments", force: :cascade do |t| + t.integer "thing_id", null: false + t.text "content" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["thing_id"], name: "index_comments_on_thing_id" + end + + create_table "searches", force: :cascade do |t| + t.string "name" + t.string "body" + t.integer "user_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "shared", default: false + t.index ["body"], name: "index_searches_on_body", unique: true + t.index ["name"], name: "index_searches_on_name", unique: true + t.index ["user_id"], name: "index_searches_on_user_id" + end + + create_table "tag_references", force: :cascade do |t| + t.integer "tag_id", null: false + t.string "taggable_type", null: false + t.integer "taggable_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["tag_id"], name: "index_tag_references_on_tag_id" + t.index ["taggable_type", "taggable_id"], name: "index_tag_references_on_taggable" + end + + create_table "tags", force: :cascade do |t| + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_tags_on_name", unique: true + end + + create_table "things", force: :cascade do |t| + t.string "target" + t.string "title", null: false + t.string "publisher" + t.string "address" + t.integer "year" + t.string "url" + t.date "access" + t.string "location" + t.string "insideof" + t.string "pages" + t.integer "user_id", null: false + t.integer "rate" + t.integer "status" + t.integer "kind" + t.date "bought_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "note" + t.string "authors", null: false + t.boolean "editors", default: false + t.string "where_is_it" + t.index ["target"], name: "index_things_on_target", unique: true + t.index ["title"], name: "index_things_on_title", unique: true + t.index ["user_id"], name: "index_things_on_user_id" + end + + create_table "users", force: :cascade do |t| + t.string "username", null: false + t.string "password_digest" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "last_search_id" + t.index ["username"], name: "index_users_on_username", 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" + add_foreign_key "comments", "things" + add_foreign_key "searches", "users" + add_foreign_key "tag_references", "tags" + add_foreign_key "things", "users" +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..6e07c8b --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +return unless Rails.env == 'development' + +u = User.find_by(username: 'user') +unless u + u = User.new(username: 'user', password: '12341234', password_confirmation: '12341234') + u.save! +end + +## +# Books, articles, whatever. + +[ + { + target: 'Rossich2006', + title: 'Poesia catalana del barroc. Antologia', + authors: 'Albert Rossich, Pep Valsalobre', + publisher: 'Edicions Vitel·la', + address: "Bellcaire d'EmpordĂ ", + year: 2006, + kind: Thing.kinds[:poetry], + status: Thing.statuses[:read], + rate: 10, + user: u + }, + { + target: 'OvidiMetamorfosisParramon', + title: 'Metamorfosis', + authors: 'Ovidi', + publisher: 'Quaderns Crema', + address: 'Barcelona', + note: 'TraducciĂł en versos a cĂ rrec de Jordi Parramon', + year: 2000, + kind: Thing.kinds[:poetry], + status: Thing.statuses[:read], + rate: 10, + user: u + }, + { + target: 'Rossich2023', + title: "Sou lo que podeu mostrar que haveu begut d'aquesta font", + authors: 'EulĂ lia Miralles, Marc Sogues, Pep Valsalobre', + editors: true, + publisher: 'Editorial Afers', + address: 'Catarroja - Barcelona', + note: 'Homenatge al professor Albert Rossich', + year: 2023, + kind: Thing.kinds[:essay], + status: Thing.statuses[:notread], + rate: 10, + user: u + }, + { + target: 'EuripidesIX2', + title: "Tragèdies. IX, 2", + authors: 'EurĂpides', + publisher: 'FundaciĂł Bernat Metge', + address: 'Barcelona', + note: 'Ifigènia a Ă€ulida', + year: 2023, + kind: Thing.kinds[:theater], + status: Thing.statuses[:notread], + rate: 10, + user: u + }, + { + target: 'RodoredaContes', + title: "Tots els contes", + authors: 'Mercè Rodoreda', + publisher: 'Edicions 62 - labutxaca', + address: 'Barcelona', + note: 'Segona ediciĂł', + year: 2017, + kind: Thing.kinds[:shorts], + status: Thing.statuses[:read], + rate: 8, + user: u + }, + { + target: 'CussaEmperador', + title: 'El primer emperador i la reina Lluna', + authors: 'Jordi CussĂ ', + publisher: 'Comanegra', + address: 'Barcelona', + note: '', + year: 2020, + kind: Thing.kinds[:novel], + status: Thing.statuses[:read], + rate: 9, + user: u + } +].each { |params| Thing.find_or_create_by!(params) } + +## +# Comments + +Thing.find_by(target: 'Rossich2006').comments.find_or_create_by!(content: 'Primer comentari a Rossich') +Thing.find_by(target: 'Rossich2006').comments.find_or_create_by!(content: 'Segon comentari a Rossich') + +## +# Tags + +['Per llegir', 'Moderna', 'ClĂ ssiques', 'TFG'].each { |t| Tag.find_or_create_by!(name: t) } + +## +# Tag references + +Thing.find_by(target: 'Rossich2006').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'Moderna')) +Thing.find_by(target: 'Rossich2006').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'TFG')) +Thing.find_by(target: 'OvidiMetamorfosisParramon').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'ClĂ ssiques')) +Thing.find_by(target: 'OvidiMetamorfosisParramon').tag_references.find_or_create_by!(tag: Tag.find_by(name: 'TFG')) +Comment.find(1).tag_references.find_or_create_by!(tag: Tag.find_by(name: 'TFG')) + +## +# Searches + +Search.find_or_create_by!(name: 'tfg', body: 'tag:"TFG"', user: u) diff --git a/lib/assets/.keep b/lib/assets/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/assets/.keep diff --git a/lib/base_exporter.rb b/lib/base_exporter.rb new file mode 100644 index 0000000..b040592 --- /dev/null +++ b/lib/base_exporter.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class BaseExporter + def initialize(things:) + @things = things + end + + # Returns a string which forces a new TeX line. + def tex_new_line + '\\\\~\\\\' + end + + # Returns a two-sized array containing the first name and the last name as + # parsed from the `author` string. This method assumes the following format is + # followed: + # - 'Name' -> ['Name', nil] + # - 'Name Surname' -> ['Name', 'Surname'] + # - 'Compound Name Surname' -> ['Compound Name', 'Surname'] + # - 'Name _Surname1 Surname2_' -> ['Name', 'Surname1 Surname2'] + def parse_author(author:) + matches = /(.+)?\s_(.+)_/.match(author) + return matches[1].strip, matches[2].strip if matches&.size == 3 + + a = author.split + return [author, nil] if a.size == 1 + + [a[0..-2].join(' '), a.last] + end +end diff --git a/lib/csv_exporter.rb b/lib/csv_exporter.rb new file mode 100644 index 0000000..a44815d --- /dev/null +++ b/lib/csv_exporter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'csv' + +class CsvExporter < BaseExporter + def export + str = '' + @things.each do |thing| + str += thing_to_csv(thing:) + end + + str + end + + def thing_to_csv(thing:) + CSV.generate_line([thing.target, thing.authors, thing.editors, thing.year, thing.title, + thing.note, thing.insideof, thing.url, thing.publisher, thing.kind, + thing.access, thing.bought_at], + quote_empty: false) + end +end diff --git a/lib/tasks/.keep b/lib/tasks/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/lib/tasks/.keep diff --git a/lib/uoc_exporter.rb b/lib/uoc_exporter.rb new file mode 100644 index 0000000..9a83d7c --- /dev/null +++ b/lib/uoc_exporter.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class UocExporter < BaseExporter + def export + @things.map.with_index do |thing, idx| + if idx.zero? + "\\hypertarget{#{thing.target}}{#{head(thing:)} #{body(thing:)}}" + else + "#{tex_new_line}\\hypertarget{#{thing.target}}{#{head(thing:)} #{body(thing:)}}" + end + end.join("\n\n") + end + + protected + + # Returns the head of a line, containing authors, year, title and, if + # available, the "inside of" information. + def head(thing:) + str = "#{parse_authors(thing:)} (#{thing.year}). \\emph{#{thing.title}}." + + if thing.insideof.present? + str += " #{inside_of(thing.insideof)}" + str += thing.pages.present? ? ", #{thing.pages}." : '.' + end + + str + end + + # Parse the authors as delivered by `thing.authors` and render them in the + # proper format. + def parse_authors(thing:) + str = thing.authors.split(',').map do |author| + first, last = parse_author(author: author.strip) + last.blank? ? "\\textsc{#{first}}" : "\\textsc{#{last}}, #{first}" + end.join('; ') + + str += ' (eds.)' if thing.editors + str + end + + # Returns the proper 'inside of' translated text. + def inside_of(sub) + str = if sub.downcase.start_with?(*%w[a e i o u]) + I18n.t('general.insideof-vowel') + else + I18n.t('general.insideof') + end + + str + " \\emph{#{sub}}" + end + + # Returns the 'body' of the line, which is the rest of it (i.e. note, + # publisher, year). + def body(thing:) + union, tail = thing_union_tail(thing:) + + if thing.publisher.present? + pub = thing.note.present? ? "#{thing.note}. #{thing.publisher}" : thing.publisher + pub + union + tail + else + tail + end + end + + # Returns a two-sized array containing the last bits of info from a line, + # being either the address, the url + access date, or just a final dot. The + # first item contains the 'union' of the last item of the returned array and + # the expected string that is going to be prefixed to it. + def thing_union_tail(thing:) + if thing.address.present? + [', ', "#{thing.address}: #{thing.year}."] + elsif thing.url.present? + ['. ', + "[\\url{#{thing.url}}]. [#{I18n.t('activerecord.attributes.thing.access')}: #{I18n.l( + thing.access, format: :long + ).downcase}]."] + else + ['', '.'] + end + end +end diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/log/.keep diff --git a/public/404.html b/public/404.html new file mode 100644 index 0000000..2be3af2 --- /dev/null +++ b/public/404.html @@ -0,0 +1,67 @@ +<!DOCTYPE html> +<html> +<head> + <title>The page you were looking for doesn't exist (404)</title> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <style> + .rails-default-error-page { + background-color: #EFEFEF; + color: #2E2F30; + text-align: center; + font-family: arial, sans-serif; + margin: 0; + } + + .rails-default-error-page div.dialog { + width: 95%; + max-width: 33em; + margin: 4em auto 0; + } + + .rails-default-error-page div.dialog > div { + border: 1px solid #CCC; + border-right-color: #999; + border-left-color: #999; + border-bottom-color: #BBB; + border-top: #B00100 solid 4px; + border-top-left-radius: 9px; + border-top-right-radius: 9px; + background-color: white; + padding: 7px 12% 0; + box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17); + } + + .rails-default-error-page h1 { + font-size: 100%; + color: #730E15; + line-height: 1.5em; + } + + .rails-default-error-page div.dialog > p { + margin: 0 0 1em; + padding: 1em; + background-color: #F7F7F7; + border: 1px solid #CCC; + border-right-color: #999; + border-left-color: #999; + border-bottom-color: #999; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-top-color: #DADADA; + color: #666; + box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17); + } + </style> +</head> + +<body class="rails-default-error-page"> + <!-- This file lives in public/404.html --> + <div class="dialog"> + <div> + <h1>The page you were looking for doesn't exist.</h1> + <p>You may have mistyped the address or the page may have moved.</p> + </div> + <p>If you are the application owner check the logs for more information.</p> + </div> +</body> +</html> diff --git a/public/422.html b/public/422.html new file mode 100644 index 0000000..c08eac0 --- /dev/null +++ b/public/422.html @@ -0,0 +1,67 @@ +<!DOCTYPE html> +<html> +<head> + <title>The change you wanted was rejected (422)</title> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <style> + .rails-default-error-page { + background-color: #EFEFEF; + color: #2E2F30; + text-align: center; + font-family: arial, sans-serif; + margin: 0; + } + + .rails-default-error-page div.dialog { + width: 95%; + max-width: 33em; + margin: 4em auto 0; + } + + .rails-default-error-page div.dialog > div { + border: 1px solid #CCC; + border-right-color: #999; + border-left-color: #999; + border-bottom-color: #BBB; + border-top: #B00100 solid 4px; + border-top-left-radius: 9px; + border-top-right-radius: 9px; + background-color: white; + padding: 7px 12% 0; + box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17); + } + + .rails-default-error-page h1 { + font-size: 100%; + color: #730E15; + line-height: 1.5em; + } + + .rails-default-error-page div.dialog > p { + margin: 0 0 1em; + padding: 1em; + background-color: #F7F7F7; + border: 1px solid #CCC; + border-right-color: #999; + border-left-color: #999; + border-bottom-color: #999; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-top-color: #DADADA; + color: #666; + box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17); + } + </style> +</head> + +<body class="rails-default-error-page"> + <!-- This file lives in public/422.html --> + <div class="dialog"> + <div> + <h1>The change you wanted was rejected.</h1> + <p>Maybe you tried to change something you didn't have access to.</p> + </div> + <p>If you are the application owner check the logs for more information.</p> + </div> +</body> +</html> diff --git a/public/500.html b/public/500.html new file mode 100644 index 0000000..78a030a --- /dev/null +++ b/public/500.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> +<head> + <title>We're sorry, but something went wrong (500)</title> + <meta name="viewport" content="width=device-width,initial-scale=1"> + <style> + .rails-default-error-page { + background-color: #EFEFEF; + color: #2E2F30; + text-align: center; + font-family: arial, sans-serif; + margin: 0; + } + + .rails-default-error-page div.dialog { + width: 95%; + max-width: 33em; + margin: 4em auto 0; + } + + .rails-default-error-page div.dialog > div { + border: 1px solid #CCC; + border-right-color: #999; + border-left-color: #999; + border-bottom-color: #BBB; + border-top: #B00100 solid 4px; + border-top-left-radius: 9px; + border-top-right-radius: 9px; + background-color: white; + padding: 7px 12% 0; + box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17); + } + + .rails-default-error-page h1 { + font-size: 100%; + color: #730E15; + line-height: 1.5em; + } + + .rails-default-error-page div.dialog > p { + margin: 0 0 1em; + padding: 1em; + background-color: #F7F7F7; + border: 1px solid #CCC; + border-right-color: #999; + border-left-color: #999; + border-bottom-color: #999; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-top-color: #DADADA; + color: #666; + box-shadow: 0 3px 8px rgba(50, 50, 50, 0.17); + } + </style> +</head> + +<body class="rails-default-error-page"> + <!-- This file lives in public/500.html --> + <div class="dialog"> + <div> + <h1>We're sorry, but something went wrong.</h1> + </div> + <p>If you are the application owner check the logs for more information.</p> + </div> +</body> +</html> diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/public/apple-touch-icon-precomposed.png diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/public/apple-touch-icon.png diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/public/favicon.ico diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/storage/.keep diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..eaa28b0 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [1400, 1400] + + # Sign in with the default user. + def sign_in! + visit new_sessions_url + + fill_in I18n.t('activerecord.attributes.user.username'), with: users(:user).username + fill_in I18n.t('activerecord.attributes.user.password'), with: '12341234' + click_on I18n.t('sessions.sign-in') + + assert_text I18n.t('searches.home') + end + + # Sign out the current user if it's already signed in, otherwise do nothing. + def sign_out_maybe! + click_on I18n.t('sessions.sign-out') + assert_text I18n.t('sessions.title') + rescue Capybara::ElementNotFound + # We were not logged in, do nothing. + end +end diff --git a/test/controllers/exports_controller_test.rb b/test/controllers/exports_controller_test.rb new file mode 100644 index 0000000..42ed1a2 --- /dev/null +++ b/test/controllers/exports_controller_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ExportsControllerTest < ActionDispatch::IntegrationTest + # We need the session to be initialized as a signed in user. + setup { post sessions_url, params: { username: users(:user).username, password: '12341234' } } + + test 'csv: works' do + get search_exports_url(0, format: 'csv') + + body = @response.body.split("\n") + assert body.size == 2 + assert body.first.split(',')[0] == things(:thing2).target + assert body.last.split(',')[0] == things(:thing1).target + + assert @response.media_type == 'text/csv' + end + + test 'uoc: works' do + get search_exports_url(0, format: 'uoc') + + assert @response.body == file_fixture('all.uoc.tex').read + assert @response.media_type == 'application/x-tex' + end +end diff --git a/test/fixtures/action_text/rich_texts.yml b/test/fixtures/action_text/rich_texts.yml new file mode 100644 index 0000000..8c95217 --- /dev/null +++ b/test/fixtures/action_text/rich_texts.yml @@ -0,0 +1,4 @@ +comment1: + record: comment1 (Comment) + name: content + body: <p>Such awesome content from Miquel</p> diff --git a/test/fixtures/comments.yml b/test/fixtures/comments.yml new file mode 100644 index 0000000..f61437d --- /dev/null +++ b/test/fixtures/comments.yml @@ -0,0 +1,2 @@ +comment1: + thing_id: <%= ActiveRecord::FixtureSet.identify(:thing1) %> diff --git a/test/fixtures/files/all.uoc.tex b/test/fixtures/files/all.uoc.tex new file mode 100644 index 0000000..227a2bb --- /dev/null +++ b/test/fixtures/files/all.uoc.tex @@ -0,0 +1,9 @@ +\chapter{Bibliografia} + +{\setlength{\parskip}{-0.3cm} + +\hypertarget{target2}{\textsc{SabatĂ©}, Miquel; \textsc{Nom} (eds.) (2024). \emph{This is a 2}. Dins de \emph{Some other thing}, 22-24. This is a note 2. Publisher 2, Address 2: 2024.} + +\\~\\\hypertarget{target1}{\textsc{SabatĂ©}, Miquel; \textsc{Smith}, John; \textsc{cognom2}, Nom cognom1; \textsc{cognom3 cognom4}, Nom (2024). \emph{This is a title 1}. Dins de \emph{Some other thing}, 22-24. This is a note 1. Publisher 1. [\url{http://example.com}]. [Ăšltima data de consulta: 24 de febrer de 2024].} + +} diff --git a/test/fixtures/searches.yml b/test/fixtures/searches.yml new file mode 100644 index 0000000..ffc7a28 --- /dev/null +++ b/test/fixtures/searches.yml @@ -0,0 +1,4 @@ +search1: + name: 'search1' + body: "tag:\"tag1\"" + user_id: <%= ActiveRecord::FixtureSet.identify(:user) %> diff --git a/test/fixtures/tag_references.yml b/test/fixtures/tag_references.yml new file mode 100644 index 0000000..d6267d5 --- /dev/null +++ b/test/fixtures/tag_references.yml @@ -0,0 +1,15 @@ +thing1tag1: + tag_id: <%= ActiveRecord::FixtureSet.identify(:tag1) %> + taggable: thing1 (Thing) + +thing1tag2: + tag_id: <%= ActiveRecord::FixtureSet.identify(:tag2) %> + taggable: thing1 (Thing) + +thing2tag1: + tag_id: <%= ActiveRecord::FixtureSet.identify(:tag1) %> + taggable: thing2 (Thing) + +comment1tag1: + tag_id: <%= ActiveRecord::FixtureSet.identify(:tag1) %> + taggable: comment1 (Comment) diff --git a/test/fixtures/tags.yml b/test/fixtures/tags.yml new file mode 100644 index 0000000..1e0ce52 --- /dev/null +++ b/test/fixtures/tags.yml @@ -0,0 +1,5 @@ +tag1: + name: 'tag1' + +tag2: + name: 'tag2' diff --git a/test/fixtures/things.yml b/test/fixtures/things.yml new file mode 100644 index 0000000..069cf85 --- /dev/null +++ b/test/fixtures/things.yml @@ -0,0 +1,39 @@ +thing1: + target: 'target1' + title: 'This is a title 1' + publisher: 'Publisher 1' + authors: 'Miquel SabatĂ©, John Smith, Nom cognom1 cognom2, Nom _cognom3 cognom4_' + editors: false + address: + year: 2024 + url: 'http://example.com' + location: 'Somewhere' + insideof: 'Some other thing' + pages: '22-24' + note: 'This is a note 1' + rate: 5 + status: <%= Thing.statuses[:read] %> + kind: <%= Thing.kinds[:novel] %> + access: 2024-02-24 17:24:12 + bought_at: 2024-02-24 17:24:12 + user_id: <%= ActiveRecord::FixtureSet.identify(:user) %> + +thing2: + target: 'target2' + title: 'This is a 2' + publisher: 'Publisher 2' + authors: 'Miquel SabatĂ©, Nom' + editors: true + address: 'Address 2' + year: 2024 + url: 'http://example.com' + location: 'Somewhere' + insideof: 'Some other thing' + pages: '22-24' + note: 'This is a note 2' + rate: 8 + status: <%= Thing.statuses[:read] %> + kind: <%= Thing.kinds[:poetry] %> + access: 2024-02-24 17:24:12 + bought_at: 2024-02-24 17:24:12 + user_id: <%= ActiveRecord::FixtureSet.identify(:user) %> diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..66baf92 --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,4 @@ +user: + username: user + password_digest: <%= BCrypt::Password.create("12341234", cost: 4) %> + last_search_id: null diff --git a/test/models/comment_test.rb b/test/models/comment_test.rb new file mode 100644 index 0000000..087963a --- /dev/null +++ b/test/models/comment_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require 'test_helper' + +class CommentTest < ActiveSupport::TestCase + test 'content is present' do + comment = Comment.new(thing_id: things(:thing1).id) + assert_raise(ActiveRecord::RecordInvalid) { comment.save! } + + comment.content = 'whatever' + assert_difference('Comment.count') { comment.save! } + end + + test 'has many tags through tag_references' do + comment = comments(:comment1) + assert comment.tags.size == 1 + end +end diff --git a/test/models/search_test.rb b/test/models/search_test.rb new file mode 100644 index 0000000..9614bc0 --- /dev/null +++ b/test/models/search_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'test_helper' + +class SearchTest < ActiveSupport::TestCase + test 'name and body have to be present and unique' do + search = Search.new(user_id: users(:user).id) + assert_raise(ActiveRecord::RecordInvalid) { search.save! } + + search.name = 'new search' + assert_raise(ActiveRecord::RecordInvalid) { search.save! } + + search.name = nil + search.body = 'body' + assert_raise(ActiveRecord::RecordInvalid) { search.save! } + + search.name = searches(:search1).name + assert_raise(ActiveRecord::RecordInvalid) { search.save! } + + search.name = 'another' + search.body = searches(:search1).body + assert_raise(ActiveRecord::RecordInvalid) { search.save! } + + search.body = 'another body' + assert_difference('Search.count') { search.save! } + end + + ## + # Results. + + test 'returns all things when an empty body is given' do + res = Search.new.results + + assert res[:things].size == 2 + assert res[:things][0][:target] == things(:thing2).target + assert res[:things][1][:target] == things(:thing1).target + end + + test 'returns everything matching a specific tag' do + res = Search.new(body: "tag:'#{tags(:tag1).name}'").results + + assert res[:things].size == 2 + assert res[:things][0][:target] == things(:thing2).target + assert res[:things][1][:target] == things(:thing1).target + assert res[:comments].size == 1 + assert res[:comments][0][:id] == comments(:comment1).id + end + + test 'returns everything matching two tags' do + res = Search.new(body: "tag:'#{tags(:tag1).name}' tag:'#{tags(:tag2).name}'").results + + assert res[:things].size == 1 + assert res[:things][0][:target] == things(:thing1).target + assert res[:comments].empty? + end + + test 'returns everything matching a given text' do + res = Search.new(body: 'Miquel').results + + assert res[:things].size == 2 + assert res[:things][0][:target] == things(:thing2).target + assert res[:things][1][:target] == things(:thing1).target + assert res[:comments].size == 1 + assert res[:comments][0][:id] == comments(:comment1).id + end + + test 'returns everything matching two tags and a given text' do + res = Search.new(body: "Miquel tag:'#{tags(:tag1).name}' tag:'#{tags(:tag2).name}'").results + + assert res[:things].size == 1 + assert res[:things][0][:target] == things(:thing1).target + assert res[:comments].empty? + end + + test 'returns everything matching a given compound text' do + res = Search.new(body: 'some other').results + + assert res[:things].size == 2 + assert res[:things][0][:target] == things(:thing2).target + assert res[:things][1][:target] == things(:thing1).target + assert res[:comments].empty? + end + + test 'returns an empty result with unknown fields' do + res = Search.new(body: 'whatever:"unknown"').results + + assert res[:things].empty? + assert res[:comments].empty? + end +end diff --git a/test/models/tag_test.rb b/test/models/tag_test.rb new file mode 100644 index 0000000..dafb8e4 --- /dev/null +++ b/test/models/tag_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TagTest < ActiveSupport::TestCase + test 'name is present and unique' do + tag = Tag.new + assert_raise(ActiveRecord::RecordInvalid) { tag.save! } + + tag = Tag.new(name: tags(:tag1).name) + assert_raise(ActiveRecord::RecordInvalid) { tag.save! } + + tag.name = 'another' + assert_difference('Tag.count') { tag.save! } + end +end diff --git a/test/models/thing_test.rb b/test/models/thing_test.rb new file mode 100644 index 0000000..0b3961e --- /dev/null +++ b/test/models/thing_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ThingTest < ActiveSupport::TestCase + test 'validates presence' do + assert_raise(ActiveRecord::RecordInvalid) { Thing.new.save! } + + # Missing 'title' + assert_raise(ActiveRecord::RecordInvalid) do + Thing.new(target: 'target', authors: 'Author', + user_id: users(:user).id, rate: 5, + status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save! + end + + # Missing 'target' + assert_raise(ActiveRecord::RecordInvalid) do + Thing.new(title: 'title', authors: 'Author', + user_id: users(:user).id, rate: 5, + status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save! + end + + # Missing 'authors' + assert_raise(ActiveRecord::RecordInvalid) do + Thing.new(title: 'title', target: 'target', + user_id: users(:user).id, rate: 5, + status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save! + end + + # Valid! + assert_difference('Thing.count') do + Thing.new(title: 'title', target: 'target', authors: 'Author', + user_id: users(:user).id, rate: 5, + status: Thing.statuses[:read], kind: Thing.kinds[:novel]).save! + end + end + + test "'rate' has to be between 0 and 10" do + thing = things(:thing1) + + thing.rate = -1 + assert_raise(ActiveRecord::RecordInvalid) { thing.save! } + + thing.rate = 11 + assert_raise(ActiveRecord::RecordInvalid) { thing.save! } + + thing.rate = 5 + thing.save! + end + + test "'status' has to have a value as defined by its enum" do + thing = things(:thing1) + + thing.status = 'whatever' + assert_raise(ActiveRecord::RecordInvalid) { thing.save! } + + thing.status = Thing.statuses[:tobepublished] + thing.save! + end + + test "'kind' has to have a value as defined by its enum" do + thing = things(:thing1) + + thing.kind = 'whatever' + assert_raise(ActiveRecord::RecordInvalid) { thing.save! } + + thing.kind = Thing.kinds[:other] + thing.save! + end + + test 'title and target must be unique' do + thing = things(:thing1).dup + + thing.title = 'another' + assert_raise(ActiveRecord::RecordInvalid) { thing.save! } + + thing.target = 'also another' + thing.title = things(:thing1).title + assert_raise(ActiveRecord::RecordInvalid) { thing.save! } + + thing.target = 'also another' + thing.title = 'now for real' + assert_difference('Thing.count') { thing.save! } + end + + test 'has many comments' do + thing = things(:thing1) + assert thing.comments.size == 1 + end + + test 'has many tags through tag_references' do + thing = things(:thing1) + assert thing.tags.size == 2 + end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 0000000..6ff5e19 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'test_helper' + +class UserTest < ActiveSupport::TestCase + test 'has to provide username and password' do + user = User.new + assert_raise(ActiveRecord::RecordInvalid) { user.save! } + + user.username = 'whatever' + assert_raise(ActiveRecord::RecordInvalid) { user.save! } + + user.password = '1234' + user.password_confirmation = '12345' + assert_raise(ActiveRecord::RecordInvalid) { user.save! } + + user.password_confirmation = '1234' + assert_difference('User.count') do + user.save! + end + end + + test 'username has to be unique' do + user = User.new(username: users(:user).username, + password: '1234', password_confirmation: '1234') + assert_raise(ActiveRecord::RecordInvalid) { user.save! } + end +end diff --git a/test/system/comments_test.rb b/test/system/comments_test.rb new file mode 100644 index 0000000..b2490b4 --- /dev/null +++ b/test/system/comments_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class CommentsTest < ApplicationSystemTestCase + setup { sign_in! } + + ## + # In things#show + + test 'things#show: the hidden form is shown when clicking the proper button' do + visit thing_url(things(:thing1)) + + click_on I18n.t('comments.new') + assert_text I18n.t('tags.new-action'), count: 1 + + click_on I18n.t('general.cancel') + assert_text I18n.t('tags.new-action'), count: 0 + end + + test 'things#show: you can add a new comment' do + visit thing_url(things(:thing1)) + + click_on I18n.t('comments.new') + find(:css, '#comment_content').set('a new comment') + find("#comment_tag_ids_#{tags(:tag1).id}").click + find("#comment_tag_ids_#{tags(:tag2).id}").click + + click_on I18n.t('helpers.submit.create') + + assert_text "#{I18n.t('comments.title')} #2" + assert_text 'a new comment' + assert_text "#{I18n.t('tags.title')}: #{tags(:tag1).name}, #{tags(:tag2).name}" + assert page.current_path == thing_path(things(:thing1)) + end + + test 'things#show: you can update a comment' do + visit thing_url(things(:thing1)) + + click_on I18n.t('general.edit').capitalize + find(:css, '#comment_content').set('updated comment') + + find("#comment_#{comments(:comment1).id} input[type=submit]").click + assert_text 'updated comment' + assert page.current_path == thing_path(things(:thing1)) + end + + test 'things#show: you can delete a comment' do + visit thing_url(things(:thing1)) + + all('.delete-comment').last.click + assert_text I18n.t('comments.none') + assert page.current_path == thing_path(things(:thing1)) + end + + ## + # In things#edit + + test 'things#edit: the hidden form is shown when clicking the proper button' do + visit edit_thing_url(things(:thing1)) + + click_on I18n.t('comments.new') + assert_text I18n.t('tags.new-action'), count: 2 + + click_on I18n.t('general.cancel') + assert_text I18n.t('tags.new-action'), count: 1 + end + + test 'things#edit: you can add a new comment' do + visit edit_thing_url(things(:thing1)) + + click_on I18n.t('comments.new') + find(:css, '#comment_content').set('a new comment') + find("#comment_tag_ids_#{tags(:tag1).id}").click + find("#comment_tag_ids_#{tags(:tag2).id}").click + + click_on I18n.t('helpers.submit.create') + + assert_text "#{I18n.t('comments.title')} #2" + assert_text 'a new comment' + assert_text "#{I18n.t('tags.title')}: #{tags(:tag1).name}, #{tags(:tag2).name}" + assert page.current_path == edit_thing_path(things(:thing1)) + end + + test 'things#edit: you can update a comment' do + visit edit_thing_url(things(:thing1)) + + click_on I18n.t('general.edit').capitalize + find(:css, '#comment_content').set('updated comment') + + find("#comment_#{comments(:comment1).id} input[type=submit]").click + assert_text 'updated comment' + assert page.current_path == edit_thing_path(things(:thing1)) + end + + test 'things#edit: you can delete a comment' do + visit edit_thing_url(things(:thing1)) + + all('.delete-comment').last.click + assert_text I18n.t('comments.none') + assert page.current_path == edit_thing_path(things(:thing1)) + end +end diff --git a/test/system/exports_test.rb b/test/system/exports_test.rb new file mode 100644 index 0000000..b324321 --- /dev/null +++ b/test/system/exports_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class ExportsTest < ApplicationSystemTestCase + setup do + sign_in! + end + + test 'can access exports from the hidden menu' do + find('#toggle-hidden-global-menu').click + assert_text I18n.t('searches.export.action') + + click_on I18n.t('searches.export.action') + assert_text I18n.t('searches.export.note-msg') + assert page.current_path == new_search_exports_path(0) + end + + test 'can export using multiple formats' do + visit new_search_exports_path(0) + + assert find('#export-format-button')[:href].ends_with?('/searches/0/exports.csv') + + find('#export-select-format').select('UOC') + assert find('#export-format-button')[:href].ends_with?('/searches/0/exports.uoc') + end +end diff --git a/test/system/licenses_test.rb b/test/system/licenses_test.rb new file mode 100644 index 0000000..1e26664 --- /dev/null +++ b/test/system/licenses_test.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class LicensesTest < ApplicationSystemTestCase + test 'can reach #show without login' do + visit license_url + + assert_text 'AGPLv3' + end +end diff --git a/test/system/searches_test.rb b/test/system/searches_test.rb new file mode 100644 index 0000000..a400e71 --- /dev/null +++ b/test/system/searches_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class SearchesTest < ApplicationSystemTestCase + setup do + # All actions will require the user to be logged in. + sign_in! + end + + test 'root url is searches#index, which contains all things plus the tabs we expect' do + visit root_url + + # All searches are listed. + assert_text I18n.t('searches.home') + searches.each { |s| assert_text s.name } + + # All things are listed + things.each { |t| assert_text t.title } + end + + test 'the hidden menu should allow us to go into the searches#new url' do + find('#toggle-hidden-global-menu').click + assert_text I18n.t('searches.title') + + click_on I18n.t('searches.object') + assert find('#search_body') + assert page.current_path == new_search_path + end + + test 'can perform a new search' do + visit new_search_url + + # Fill in the body input and press enter, otherwise the javascript + # controller won't fire up. + fill_in 'search_body', with: 'Miquel' + find('#search_body').native.send_keys(:return) + + # It's actually a pretty generous search: it should give us all thing and a + # comment. + assert_text I18n.t('searches.save') + assert_text things(:thing1).title + assert_text things(:thing2).title + assert_text I18n.t('comments.title') + assert_text tags(:tag1).name + end + + test 'can save a new search' do + visit new_search_url + + # Fill in the body input and press enter, otherwise the javascript + # controller won't fire up. + fill_in 'search_body', with: 'Autor' + find('#search_body').native.send_keys(:return) + + # Hit the save button so the hidden form appears. + assert_text I18n.t('searches.save') + find('#save-search').click + assert_text I18n.t('searches.public') + + fill_in 'Name', with: 'Nova cerca' + click_on I18n.t('helpers.submit.create') + + assert_text 'Nova cerca' + assert page.current_path == search_path(Search.find_by(name: 'Nova cerca')) + end + + test 'can edit a search that is not the Home' do + # Home cannot be edited. + find('#toggle-hidden-global-menu').click + assert_selector 'a', text: I18n.t('general.edit'), count: 0 + + # Select search1 + click_on searches(:search1).name + assert_selector 'a', text: things(:thing1).title, count: 2 + find('#toggle-hidden-global-menu').click + + # It can be edited! + assert_text I18n.t('general.edit') + click_on I18n.t('general.edit') + + # If you click on it, you will be on the edit page for it. + assert find('#search_body') + assert page.current_path == edit_search_path(searches(:search1)) + end + + test 'edit an existing search works' do + visit edit_search_url(searches(:search1)) + + find('#search_body').native.send_keys(:return) + + # Hit the save button so the hidden form appears. + assert_text I18n.t('searches.save') + find('#save-search').click + assert_text I18n.t('searches.public') + + # Update the name. + fill_in 'Name', with: 'Nova cerca' + click_on I18n.t('helpers.submit.update') + + # We have been redirected to search#show, and the name has been refreshed. + assert_selector 'a', text: searches(:search1).name, count: 0 + assert_text 'Nova cerca' + assert page.current_path == search_path(searches(:search1)) + end + + test 'can delete a search that is not the Home' do + # Home cannot be deleted. + find('#toggle-hidden-global-menu').click + assert_selector 'a', text: I18n.t('general.delete'), count: 0 + + # Select search1 + click_on searches(:search1).name + assert_selector 'a', text: things(:thing1).title, count: 2 + find('#toggle-hidden-global-menu').click + + # It can be deleted! + assert_text I18n.t('general.delete') + accept_alert { click_on I18n.t('general.delete') } + assert_selector 'a', text: searches(:search1).name, count: 0 + assert Search.none? + end +end diff --git a/test/system/sessions_test.rb b/test/system/sessions_test.rb new file mode 100644 index 0000000..2d01eef --- /dev/null +++ b/test/system/sessions_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class SessionsTest < ApplicationSystemTestCase + test 'logs in successfully' do + sign_in! + end + + test 'fails to log in with wrong password' do + visit new_sessions_url + + fill_in I18n.t('activerecord.attributes.user.username'), with: users(:user).username + fill_in I18n.t('activerecord.attributes.user.password'), with: 'I AM ERROR' + click_on I18n.t('sessions.sign-in') + + assert_text I18n.t('sessions.wrong-credentials') + end + + test 'logs out successfully' do + sign_in! + + click_on I18n.t('sessions.sign-out') + + assert_text I18n.t('sessions.title') + end +end diff --git a/test/system/shared_searches_test.rb b/test/system/shared_searches_test.rb new file mode 100644 index 0000000..0cfc4bd --- /dev/null +++ b/test/system/shared_searches_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class SharedSearchesTest < ApplicationSystemTestCase + test 'can access shared_searches#index from the hidden menu' do + sign_in! + + find('#toggle-hidden-global-menu').click + assert_text I18n.t('searches.shared.title').downcase + + click_on I18n.t('searches.shared.title').downcase + assert_text I18n.t('searches.shared.none') + end + + test 'cannot access shared_searches#index if not logged in' do + sign_out_maybe! + + visit shared_searches_path + assert_text I18n.t('sessions.title') + assert page.current_path == new_sessions_path + end + + test 'shared_searches#show gives us the expected results' do + # NOTE: not logged in! + + searches(:search1).update!(shared: true) + + visit search_shared_path(searches(:search1)) + assert_text things(:thing1).title + assert_text things(:thing2).title + assert_text I18n.t('comments.title') + end +end diff --git a/test/system/tags_test.rb b/test/system/tags_test.rb new file mode 100644 index 0000000..1ee83a9 --- /dev/null +++ b/test/system/tags_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class SharedSearchesTest < ApplicationSystemTestCase + setup { sign_in! } + + test 'lists all the tags' do + visit tags_url + + tags.each { |t| assert_text t.name } + end + + test 'can create a new tag' do + visit new_tag_url + + fill_in I18n.t('activerecord.attributes.tag.name'), with: 'whatever' + assert_difference 'Tag.count' do + click_on I18n.t('helpers.submit.create') + assert_text 'whatever' + end + end + + test 'gives feedback on errors' do + visit new_tag_url + + fill_in I18n.t('activerecord.attributes.tag.name'), with: tags(:tag1).name + click_on I18n.t('helpers.submit.create') + assert_text "#{I18n.t('activerecord.attributes.tag.name')} #{I18n.t('errors.messages.taken')}" + end + + test 'can delete an existing tag' do + visit tags_url + + assert_difference 'Tag.count', -1 do + click_link(I18n.t('general.delete'), match: :first) + assert_text I18n.t('tags.title') + end + end +end diff --git a/test/system/things_test.rb b/test/system/things_test.rb new file mode 100644 index 0000000..9dd482d --- /dev/null +++ b/test/system/things_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'application_system_test_case' + +class ThingsTest < ApplicationSystemTestCase + setup { sign_in! } + + test 'the hidden menu has a things#new link' do + find('#toggle-hidden-global-menu').click + assert_text I18n.t('things.object').downcase + + click_on I18n.t('things.object').downcase + assert_text I18n.t('activerecord.attributes.thing.title') + + assert page.current_path == new_thing_path + end + + test 'you can click on a thing listed on the search to go to the its #show page' do + click_on things(:thing1).title + + assert_text I18n.t('activerecord.attributes.thing.title') + + assert page.current_path == edit_thing_path(things(:thing1)) + end + + test 'you can create a new thing' do + visit new_thing_url + + fill_in I18n.t('activerecord.attributes.thing.title'), with: 'new title' + fill_in I18n.t('activerecord.attributes.thing.authors'), with: 'author' + fill_in I18n.t('activerecord.attributes.thing.target'), with: 'identifier' + fill_in I18n.t('activerecord.attributes.thing.rate'), with: 5 + find("#thing_tag_ids_#{tags(:tag1).id}").click + + click_on I18n.t('helpers.submit.create') + + assert_text I18n.t('things.create-success') + + # Ensure that tag references are properly created. + tags = Thing.find_by(title: 'new title').tags + assert tags.size == 1 + assert tags.first.name == tags(:tag1).name + + # You can go back to create another thing with a convenient link. + click_on I18n.t('things.create-another') + assert_text I18n.t('activerecord.attributes.thing.title') + assert page.current_path == new_thing_path + end + + test 'you get feedback from wrong values for a new thing' do + visit new_thing_url + + fill_in I18n.t('activerecord.attributes.thing.title'), with: things(:thing1).title + fill_in I18n.t('activerecord.attributes.thing.authors'), with: 'author' + fill_in I18n.t('activerecord.attributes.thing.target'), with: 'identifier' + fill_in I18n.t('activerecord.attributes.thing.rate'), with: 5 + find("#thing_tag_ids_#{tags(:tag1).id}").click + + click_on I18n.t('helpers.submit.create') + + assert_text "#{I18n.t('activerecord.attributes.thing.title')} " \ + "#{I18n.t('errors.messages.taken')}" + end + + test 'you can update a value from a thing' do + visit edit_thing_url(things(:thing1)) + + # Originally :thing1 has references to :tag1 and :tag2. This test will + # remove the reference to :tag1. + assert things(:thing1).tag_references.size == 2 + + fill_in I18n.t('activerecord.attributes.thing.title'), with: 'another thing entirely' + find("#thing_tag_ids_#{tags(:tag1).id}").click + click_on I18n.t('helpers.submit.update') + + assert_text I18n.t('things.update-success') + + # Tag references updated: only one reference (:tag2). + tags = things(:thing1).reload.tags + assert tags.size == 1 + assert tags.first.id = tags(:tag2).id + end + + test 'you get feedback from wrong values for a thing' do + visit edit_thing_url(things(:thing1)) + + fill_in I18n.t('activerecord.attributes.thing.title'), with: things(:thing2).title + click_on I18n.t('helpers.submit.update') + + assert_text "#{I18n.t('activerecord.attributes.thing.title')} " \ + "#{I18n.t('errors.messages.taken')}" + end + + test 'you can delete a thing' do + visit edit_thing_url(things(:thing1)) + + assert_difference 'Thing.count', -1 do + accept_alert { click_on I18n.t('general.delete').capitalize, match: :first } + assert_text I18n.t('things.destroy-success') + end + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..b13faef --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' +require 'rails/test_help' + +# It helps when writing tests. +require 'ap' + +module ActiveSupport + class TestCase + # Run tests in parallel with specified workers + parallelize(workers: :number_of_processors) + + # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. + fixtures :all + end +end diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tmp/.keep diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tmp/pids/.keep diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tmp/storage/.keep diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/vendor/.keep diff --git a/vendor/assets/stylesheets/simple.css b/vendor/assets/stylesheets/simple.css new file mode 100644 index 0000000..788d069 --- /dev/null +++ b/vendor/assets/stylesheets/simple.css @@ -0,0 +1,698 @@ +/* Global variables. */ +:root, +::backdrop { + /* Set sans-serif & mono fonts */ + --sans-font: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, + "Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica, + "Helvetica Neue", sans-serif; + --mono-font: Consolas, Menlo, Monaco, "Andale Mono", "Ubuntu Mono", monospace; + --standard-border-radius: 5px; + + /* Default (light) theme */ + --bg: #fff; + --accent-bg: #f5f7ff; + --text: #212121; + --text-light: #585858; + --border: #898EA4; + --accent: #0d47a1; + --accent-hover: #1266e2; + --accent-text: var(--bg); + --code: #d81b60; + --preformatted: #444; + --marked: #ffdd33; + --disabled: #efefef; +} + +/* Dark theme */ +@media (prefers-color-scheme: dark) { + :root, + ::backdrop { + color-scheme: dark; + --bg: #212121; + --accent-bg: #2b2b2b; + --text: #dcdcdc; + --text-light: #ababab; + --accent: #ffb300; + --accent-hover: #ffe099; + --accent-text: var(--bg); + --code: #f06292; + --preformatted: #ccc; + --disabled: #111; + } + /* Add a bit of transparency so light media isn't so glaring in dark mode */ + img, + video { + opacity: 0.8; + } +} + +/* Reset box-sizing */ +*, *::before, *::after { + box-sizing: border-box; +} + +/* Reset default appearance */ +textarea, +select, +input, +progress { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; +} + +html { + /* Set the font globally */ + font-family: var(--sans-font); + scroll-behavior: smooth; +} + +/* Make the body a nice central block */ +body { + color: var(--text); + background-color: var(--bg); + font-size: 1.15rem; + line-height: 1.5; + display: grid; + grid-template-columns: 1fr min(45rem, 90%) 1fr; + margin: 0; +} +body > * { + grid-column: 2; +} + +/* Make the header bg full width, but the content inline with body */ +body > header { + background-color: var(--accent-bg); + border-bottom: 1px solid var(--border); + text-align: center; + padding: 0 0.5rem 2rem 0.5rem; + grid-column: 1 / -1; +} + +body > header > *:only-child { + margin-block-start: 2rem; +} + +body > header h1 { + max-width: 1200px; + margin: 1rem auto; +} + +body > header p { + max-width: 40rem; + margin: 1rem auto; +} + +/* Add a little padding to ensure spacing is correct between content and header > nav */ +main { + padding-top: 1.5rem; +} + +body > footer { + margin-top: 4rem; + padding: 2rem 1rem 1.5rem 1rem; + color: var(--text-light); + font-size: 0.9rem; + text-align: center; + border-top: 1px solid var(--border); +} + +/* Format headers */ +h1 { + font-size: 3rem; +} + +h2 { + font-size: 2.6rem; + margin-top: 3rem; +} + +h3 { + font-size: 2rem; + margin-top: 3rem; +} + +h4 { + font-size: 1.44rem; +} + +h5 { + font-size: 1.15rem; +} + +h6 { + font-size: 0.96rem; +} + +p { + margin: 1.5rem 0; +} + +/* Prevent long strings from overflowing container */ +p, h1, h2, h3, h4, h5, h6 { + overflow-wrap: break-word; +} + +/* Fix line height when title wraps */ +h1, +h2, +h3 { + line-height: 1.1; +} + +/* Reduce header size on mobile */ +@media only screen and (max-width: 720px) { + h1 { + font-size: 2.5rem; + } + + h2 { + font-size: 2.1rem; + } + + h3 { + font-size: 1.75rem; + } + + h4 { + font-size: 1.25rem; + } +} + +/* Format links & buttons */ +a, +a:visited { + color: var(--accent); +} + +a:hover { + text-decoration: none; +} + +button, +.button, +a.button, /* extra specificity to override a */ +input[type="submit"], +input[type="reset"], +input[type="button"], +label[type="button"] { + border: 1px solid var(--accent); + background-color: var(--accent); + color: var(--accent-text); + padding: 0.5rem 0.9rem; + text-decoration: none; + line-height: normal; +} + +.button[aria-disabled="true"], +input:disabled, +textarea:disabled, +select:disabled, +button[disabled] { + cursor: not-allowed; + background-color: var(--disabled); + border-color: var(--disabled); + color: var(--text-light); +} + +input[type="range"] { + padding: 0; +} + +/* Set the cursor to '?' on an abbreviation and style the abbreviation to show that there is more information underneath */ +abbr[title] { + cursor: help; + text-decoration-line: underline; + text-decoration-style: dotted; +} + +button:enabled:hover, +.button:not([aria-disabled="true"]):hover, +input[type="submit"]:enabled:hover, +input[type="reset"]:enabled:hover, +input[type="button"]:enabled:hover, +label[type="button"]:hover { + background-color: var(--accent-hover); + border-color: var(--accent-hover); + cursor: pointer; +} + +.button:focus-visible, +button:focus-visible:where(:enabled), +input:enabled:focus-visible:where( + [type="submit"], + [type="reset"], + [type="button"] +) { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +/* Format navigation */ +header > nav { + font-size: 1rem; + line-height: 2; + padding: 1rem 0 0 0; +} + +/* Use flexbox to allow items to wrap, as needed */ +header > nav ul, +header > nav ol { + align-content: space-around; + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + list-style-type: none; + margin: 0; + padding: 0; +} + +/* List items are inline elements, make them behave more like blocks */ +header > nav ul li, +header > nav ol li { + display: inline-block; +} + +header > nav a, +header > nav a:visited { + margin: 0 0.5rem 1rem 0.5rem; + border: 1px solid var(--border); + border-radius: var(--standard-border-radius); + color: var(--text); + display: inline-block; + padding: 0.1rem 1rem; + text-decoration: none; +} + +header > nav a:hover, +header > nav a.current, +header > nav a[aria-current="page"] { + border-color: var(--accent); + color: var(--accent); + cursor: pointer; +} + +/* Reduce nav side on mobile */ +@media only screen and (max-width: 720px) { + header > nav a { + border: none; + padding: 0; + text-decoration: underline; + line-height: 1; + } +} + +/* Consolidate box styling */ +aside, details, pre, progress { + background-color: var(--accent-bg); + border: 1px solid var(--border); + border-radius: var(--standard-border-radius); + margin-bottom: 1rem; +} + +aside { + font-size: 1rem; + width: 30%; + padding: 0 15px; + margin-inline-start: 15px; + float: right; +} +*[dir="rtl"] aside { + float: left; +} + +/* Make aside full-width on mobile */ +@media only screen and (max-width: 720px) { + aside { + width: 100%; + float: none; + margin-inline-start: 0; + } +} + +article, fieldset, dialog { + border: 1px solid var(--border); + padding: 1rem; + border-radius: var(--standard-border-radius); + margin-bottom: 1rem; +} + +article h2:first-child, +section h2:first-child { + margin-top: 1rem; +} + +section { + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + padding: 2rem 1rem; + margin: 3rem 0; +} + +/* Don't double separators when chaining sections */ +section + section, +section:first-child { + border-top: 0; + padding-top: 0; +} + +section:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +details { + padding: 0.7rem 1rem; +} + +summary { + cursor: pointer; + font-weight: bold; + padding: 0.7rem 1rem; + margin: -0.7rem -1rem; + word-break: break-all; +} + +details[open] > summary + * { + margin-top: 0; +} + +details[open] > summary { + margin-bottom: 0.5rem; +} + +details[open] > :last-child { + margin-bottom: 0; +} + +/* Format tables */ +table { + border-collapse: collapse; + margin: 1.5rem 0; +} + +figure > table { + width: max-content; +} + +td, +th { + border: 1px solid var(--border); + text-align: start; + padding: 0.5rem; +} + +th { + background-color: var(--accent-bg); + font-weight: bold; +} + +tr:nth-child(even) { + /* Set every other cell slightly darker. Improves readability. */ + background-color: var(--accent-bg); +} + +table caption { + font-weight: bold; + margin-bottom: 0.5rem; +} + +/* Format forms */ +textarea, +select, +input, +button, +.button { + font-size: inherit; + font-family: inherit; + padding: 0.5rem; + margin-bottom: 0.5rem; + border-radius: var(--standard-border-radius); + box-shadow: none; + max-width: 100%; + display: inline-block; +} +textarea, +select, +input { + color: var(--text); + background-color: var(--bg); + border: 1px solid var(--border); +} +label { + display: block; +} +textarea:not([cols]) { + width: 100%; +} + +/* Add arrow to drop-down */ +select:not([multiple]) { + background-image: linear-gradient(45deg, transparent 49%, var(--text) 51%), + linear-gradient(135deg, var(--text) 51%, transparent 49%); + background-position: calc(100% - 15px), calc(100% - 10px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + padding-inline-end: 25px; +} +*[dir="rtl"] select:not([multiple]) { + background-position: 10px, 15px; +} + +/* checkbox and radio button style */ +input[type="checkbox"], +input[type="radio"] { + vertical-align: middle; + position: relative; + width: min-content; +} + +input[type="checkbox"] + label, +input[type="radio"] + label { + display: inline-block; +} + +input[type="radio"] { + border-radius: 100%; +} + +input[type="checkbox"]:checked, +input[type="radio"]:checked { + background-color: var(--accent); +} + +input[type="checkbox"]:checked::after { + /* Creates a rectangle with colored right and bottom borders which is rotated to look like a check mark */ + content: " "; + width: 0.18em; + height: 0.32em; + border-radius: 0; + position: absolute; + top: 0.05em; + left: 0.17em; + background-color: transparent; + border-right: solid var(--bg) 0.08em; + border-bottom: solid var(--bg) 0.08em; + font-size: 1.8em; + transform: rotate(45deg); +} +input[type="radio"]:checked::after { + /* creates a colored circle for the checked radio button */ + content: " "; + width: 0.25em; + height: 0.25em; + border-radius: 100%; + position: absolute; + top: 0.125em; + background-color: var(--bg); + left: 0.125em; + font-size: 32px; +} + +/* Makes input fields wider on smaller screens */ +@media only screen and (max-width: 720px) { + textarea, + select, + input { + width: 100%; + } +} + +/* Set a height for color input */ +input[type="color"] { + height: 2.5rem; + padding: 0.2rem; +} + +/* do not show border around file selector button */ +input[type="file"] { + border: 0; +} + +/* Misc body elements */ +hr { + border: none; + height: 1px; + background: var(--border); + margin: 1rem auto; +} + +mark { + padding: 2px 5px; + border-radius: var(--standard-border-radius); + background-color: var(--marked); + color: black; +} + +mark a { + color: #0d47a1; +} + +img, +video { + max-width: 100%; + height: auto; + border-radius: var(--standard-border-radius); +} + +figure { + margin: 0; + display: block; + overflow-x: auto; +} + +figcaption { + text-align: center; + font-size: 0.9rem; + color: var(--text-light); + margin-bottom: 1rem; +} + +blockquote { + margin-inline-start: 2rem; + margin-inline-end: 0; + margin-block: 2rem; + padding: 0.4rem 0.8rem; + border-inline-start: 0.35rem solid var(--accent); + color: var(--text-light); + font-style: italic; +} + +cite { + font-size: 0.9rem; + color: var(--text-light); + font-style: normal; +} + +dt { + color: var(--text-light); +} + +/* Use mono font for code elements */ +code, +pre, +pre span, +kbd, +samp { + font-family: var(--mono-font); + color: var(--code); +} + +kbd { + color: var(--preformatted); + border: 1px solid var(--preformatted); + border-bottom: 3px solid var(--preformatted); + border-radius: var(--standard-border-radius); + padding: 0.1rem 0.4rem; +} + +pre { + padding: 1rem 1.4rem; + max-width: 100%; + overflow: auto; + color: var(--preformatted); +} + +/* Fix embedded code within pre */ +pre code { + color: var(--preformatted); + background: none; + margin: 0; + padding: 0; +} + +/* Progress bars */ +/* Declarations are repeated because you */ +/* cannot combine vendor-specific selectors */ +progress { + width: 100%; +} + +progress:indeterminate { + background-color: var(--accent-bg); +} + +progress::-webkit-progress-bar { + border-radius: var(--standard-border-radius); + background-color: var(--accent-bg); +} + +progress::-webkit-progress-value { + border-radius: var(--standard-border-radius); + background-color: var(--accent); +} + +progress::-moz-progress-bar { + border-radius: var(--standard-border-radius); + background-color: var(--accent); + transition-property: width; + transition-duration: 0.3s; +} + +progress:indeterminate::-moz-progress-bar { + background-color: var(--accent-bg); +} + +dialog { + max-width: 40rem; + margin: auto; +} + +dialog::backdrop { + background-color: var(--bg); + opacity: 0.8; +} + +@media only screen and (max-width: 720px) { + dialog { + max-width: 100%; + margin: auto 1em; + } +} + +/* Superscript & Subscript */ +/* Prevent scripts from affecting line-height. */ +sup, sub { + vertical-align: baseline; + position: relative; +} + +sup { + top: -0.4em; +} + +sub { + top: 0.3em; +} + +/* Classes for notices */ +.notice { + background: var(--accent-bg); + border: 2px solid var(--border); + border-radius: var(--standard-border-radius); + padding: 1.5rem; + margin: 2rem 0; +} diff --git a/vendor/javascript/.keep b/vendor/javascript/.keep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/vendor/javascript/.keep |
