From a7791635aa98f012d35f97c3c5288494bbb28e38 Mon Sep 17 00:00:00 2001 From: Miquel Sabaté Solà Date: Wed, 13 Mar 2024 18:03:43 +0100 Subject: Initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Miquel Sabaté Solà --- .dockerignore | 37 ++ .gitattributes | 9 + .github/ISSUE_TEMPLATE/custom.md | 27 + .github/PULL_REQUEST_TEMPLATE.md | 10 + .github/workflows/ci.yml | 56 ++ .gitignore | 35 ++ .rubocop.yml | 106 ++++ .ruby-version | 1 + CHANGELOG.md | 3 + CONTRIBUTING.md | 53 ++ Dockerfile | 62 ++ Gemfile | 64 ++ Gemfile.lock | 337 ++++++++++ README.md | 54 ++ Rakefile | 8 + app/assets/config/manifest.js | 4 + app/assets/images/.keep | 0 app/assets/stylesheets/actiontext.css | 31 + app/assets/stylesheets/application.css | 40 ++ app/assets/stylesheets/comment.css | 27 + app/assets/stylesheets/icons.css | 51 ++ app/assets/stylesheets/search.css | 75 +++ app/assets/stylesheets/tags.css | 27 + app/assets/stylesheets/things.css | 94 +++ app/assets/stylesheets/utils.css | 24 + app/controllers/application_controller.rb | 22 + app/controllers/comments_controller.rb | 68 ++ app/controllers/concerns/.keep | 0 app/controllers/concerns/taggable.rb | 17 + app/controllers/exports_controller.rb | 38 ++ app/controllers/licenses_controller.rb | 7 + app/controllers/searches_controller.rb | 60 ++ app/controllers/sessions_controller.rb | 27 + app/controllers/shared_searches_controller.rb | 13 + app/controllers/tags_controller.rb | 39 ++ app/controllers/things_controller.rb | 69 ++ app/helpers/shared_searches_helper.rb | 17 + app/javascript/application.js | 6 + app/javascript/controllers/application.js | 9 + app/javascript/controllers/comment_controller.js | 36 ++ app/javascript/controllers/export_controller.js | 19 + app/javascript/controllers/header_controller.js | 21 + app/javascript/controllers/index.js | 11 + app/javascript/controllers/search_controller.js | 36 ++ app/models/application_record.rb | 5 + app/models/comment.rb | 18 + app/models/concerns/.keep | 0 app/models/search.rb | 99 +++ app/models/tag.rb | 7 + app/models/tag_reference.rb | 6 + app/models/thing.rb | 32 + app/models/user.rb | 9 + app/views/active_storage/blobs/_blob.html.erb | 14 + app/views/comments/_comment.html.erb | 42 ++ app/views/comments/_list.html.erb | 30 + app/views/exports/new.html.erb | 14 + app/views/exports/show.csv.erb | 1 + app/views/exports/show.uoc.erb | 7 + app/views/layouts/_errors.html.erb | 13 + .../layouts/action_text/contents/_content.html.erb | 3 + app/views/layouts/application.html.erb | 78 +++ app/views/licenses/show.html.erb | 23 + app/views/searches/_form.html.erb | 29 + app/views/searches/_search.html.erb | 5 + app/views/searches/edit.html.erb | 1 + app/views/searches/index.html.erb | 1 + app/views/searches/new.html.erb | 1 + app/views/searches/show.html.erb | 1 + app/views/sessions/new.html.erb | 21 + app/views/shared_searches/index.html.erb | 13 + app/views/shared_searches/show.html.erb | 28 + app/views/tags/_form.html.erb | 12 + app/views/tags/_tag.html.erb | 8 + app/views/tags/_taggable.html.erb | 17 + app/views/tags/index.html.erb | 13 + app/views/tags/new.html.erb | 9 + app/views/things/_form.html.erb | 102 +++ app/views/things/_thing.html.erb | 7 + app/views/things/edit.html.erb | 3 + app/views/things/new.html.erb | 1 + app/views/things/show.html.erb | 7 + bin/bundle | 109 ++++ bin/docker-entrypoint | 8 + bin/importmap | 5 + bin/rails | 6 + bin/rake | 6 + bin/setup | 35 ++ config.ru | 8 + config/application.rb | 29 + config/boot.rb | 6 + config/cable.yml | 10 + config/credentials.yml.enc | 1 + config/database.yml | 25 + config/environment.rb | 7 + config/environments/development.rb | 78 +++ config/environments/production.rb | 103 +++ config/environments/test.rb | 66 ++ config/importmap.rb | 11 + config/initializers/assets.rb | 14 + config/initializers/content_security_policy.rb | 26 + config/initializers/filter_parameter_logging.rb | 11 + config/initializers/inflections.rb | 17 + config/initializers/locale.rb | 6 + config/initializers/mime_types.rb | 3 + config/initializers/permissions_policy.rb | 14 + config/locales/ca.yml | 129 ++++ config/locales/en.yml | 131 ++++ config/puma.rb | 37 ++ config/routes.rb | 27 + config/storage.yml | 34 + db/migrate/20240224162412_create_things.rb | 23 + db/migrate/20240224164039_create_users.rb | 10 + db/migrate/20240224165311_create_comments.rb | 10 + ..._create_active_storage_tables.active_storage.rb | 57 ++ ...165704_create_action_text_tables.action_text.rb | 26 + db/migrate/20240224220515_create_tags.rb | 9 + db/migrate/20240225070152_create_tag_references.rb | 10 + db/migrate/20240225071927_create_searches.rb | 11 + db/migrate/20240225100002_add_note_to_things.rb | 5 + .../20240226063134_add_uniqueness_index_search.rb | 8 + .../20240227085442_add_last_search_id_to_users.rb | 5 + db/migrate/20240227145317_add_authors_to_thing.rb | 6 + .../20240301154452_add_shared_to_searches.rb | 5 + .../20240307220309_add_where_is_it_to_things.rb | 5 + ...13133513_change_access_and_bought_at_to_date.rb | 6 + db/schema.rb | 131 ++++ db/seeds.rb | 118 ++++ lib/assets/.keep | 0 lib/base_exporter.rb | 29 + lib/csv_exporter.rb | 21 + lib/tasks/.keep | 0 lib/uoc_exporter.rb | 81 +++ log/.keep | 0 public/404.html | 67 ++ public/422.html | 67 ++ public/500.html | 66 ++ public/apple-touch-icon-precomposed.png | 0 public/apple-touch-icon.png | 0 public/favicon.ico | 0 public/robots.txt | 1 + storage/.keep | 0 test/application_system_test_case.rb | 26 + test/controllers/exports_controller_test.rb | 24 + test/fixtures/action_text/rich_texts.yml | 4 + test/fixtures/comments.yml | 2 + test/fixtures/files/all.uoc.tex | 9 + test/fixtures/searches.yml | 4 + test/fixtures/tag_references.yml | 15 + test/fixtures/tags.yml | 5 + test/fixtures/things.yml | 39 ++ test/fixtures/users.yml | 4 + test/models/comment_test.rb | 18 + test/models/search_test.rb | 90 +++ test/models/tag_test.rb | 16 + test/models/thing_test.rb | 95 +++ test/models/user_test.rb | 28 + test/system/comments_test.rb | 103 +++ test/system/exports_test.rb | 27 + test/system/licenses_test.rb | 11 + test/system/searches_test.rb | 123 ++++ test/system/sessions_test.rb | 27 + test/system/shared_searches_test.rb | 34 + test/system/tags_test.rb | 40 ++ test/system/things_test.rb | 102 +++ test/test_helper.rb | 18 + tmp/.keep | 0 tmp/pids/.keep | 0 tmp/storage/.keep | 0 vendor/.keep | 0 vendor/assets/stylesheets/simple.css | 698 +++++++++++++++++++++ vendor/javascript/.keep | 0 171 files changed, 5731 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/custom.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .rubocop.yml create mode 100644 .ruby-version create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 Gemfile create mode 100644 Gemfile.lock create mode 100644 README.md create mode 100644 Rakefile create mode 100644 app/assets/config/manifest.js create mode 100644 app/assets/images/.keep create mode 100644 app/assets/stylesheets/actiontext.css create mode 100644 app/assets/stylesheets/application.css create mode 100644 app/assets/stylesheets/comment.css create mode 100644 app/assets/stylesheets/icons.css create mode 100644 app/assets/stylesheets/search.css create mode 100644 app/assets/stylesheets/tags.css create mode 100644 app/assets/stylesheets/things.css create mode 100644 app/assets/stylesheets/utils.css create mode 100644 app/controllers/application_controller.rb create mode 100644 app/controllers/comments_controller.rb create mode 100644 app/controllers/concerns/.keep create mode 100644 app/controllers/concerns/taggable.rb create mode 100644 app/controllers/exports_controller.rb create mode 100644 app/controllers/licenses_controller.rb create mode 100644 app/controllers/searches_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/controllers/shared_searches_controller.rb create mode 100644 app/controllers/tags_controller.rb create mode 100644 app/controllers/things_controller.rb create mode 100644 app/helpers/shared_searches_helper.rb create mode 100644 app/javascript/application.js create mode 100644 app/javascript/controllers/application.js create mode 100644 app/javascript/controllers/comment_controller.js create mode 100644 app/javascript/controllers/export_controller.js create mode 100644 app/javascript/controllers/header_controller.js create mode 100644 app/javascript/controllers/index.js create mode 100644 app/javascript/controllers/search_controller.js create mode 100644 app/models/application_record.rb create mode 100644 app/models/comment.rb create mode 100644 app/models/concerns/.keep create mode 100644 app/models/search.rb create mode 100644 app/models/tag.rb create mode 100644 app/models/tag_reference.rb create mode 100644 app/models/thing.rb create mode 100644 app/models/user.rb create mode 100644 app/views/active_storage/blobs/_blob.html.erb create mode 100644 app/views/comments/_comment.html.erb create mode 100644 app/views/comments/_list.html.erb create mode 100644 app/views/exports/new.html.erb create mode 100644 app/views/exports/show.csv.erb create mode 100644 app/views/exports/show.uoc.erb create mode 100644 app/views/layouts/_errors.html.erb create mode 100644 app/views/layouts/action_text/contents/_content.html.erb create mode 100644 app/views/layouts/application.html.erb create mode 100644 app/views/licenses/show.html.erb create mode 100644 app/views/searches/_form.html.erb create mode 100644 app/views/searches/_search.html.erb create mode 100644 app/views/searches/edit.html.erb create mode 100644 app/views/searches/index.html.erb create mode 100644 app/views/searches/new.html.erb create mode 100644 app/views/searches/show.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/shared_searches/index.html.erb create mode 100644 app/views/shared_searches/show.html.erb create mode 100644 app/views/tags/_form.html.erb create mode 100644 app/views/tags/_tag.html.erb create mode 100644 app/views/tags/_taggable.html.erb create mode 100644 app/views/tags/index.html.erb create mode 100644 app/views/tags/new.html.erb create mode 100644 app/views/things/_form.html.erb create mode 100644 app/views/things/_thing.html.erb create mode 100644 app/views/things/edit.html.erb create mode 100644 app/views/things/new.html.erb create mode 100644 app/views/things/show.html.erb create mode 100755 bin/bundle create mode 100755 bin/docker-entrypoint create mode 100755 bin/importmap create mode 100755 bin/rails create mode 100755 bin/rake create mode 100755 bin/setup create mode 100644 config.ru create mode 100644 config/application.rb create mode 100644 config/boot.rb create mode 100644 config/cable.yml create mode 100644 config/credentials.yml.enc create mode 100644 config/database.yml create mode 100644 config/environment.rb create mode 100644 config/environments/development.rb create mode 100644 config/environments/production.rb create mode 100644 config/environments/test.rb create mode 100644 config/importmap.rb create mode 100644 config/initializers/assets.rb create mode 100644 config/initializers/content_security_policy.rb create mode 100644 config/initializers/filter_parameter_logging.rb create mode 100644 config/initializers/inflections.rb create mode 100644 config/initializers/locale.rb create mode 100644 config/initializers/mime_types.rb create mode 100644 config/initializers/permissions_policy.rb create mode 100644 config/locales/ca.yml create mode 100644 config/locales/en.yml create mode 100644 config/puma.rb create mode 100644 config/routes.rb create mode 100644 config/storage.yml create mode 100644 db/migrate/20240224162412_create_things.rb create mode 100644 db/migrate/20240224164039_create_users.rb create mode 100644 db/migrate/20240224165311_create_comments.rb create mode 100644 db/migrate/20240224165703_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20240224165704_create_action_text_tables.action_text.rb create mode 100644 db/migrate/20240224220515_create_tags.rb create mode 100644 db/migrate/20240225070152_create_tag_references.rb create mode 100644 db/migrate/20240225071927_create_searches.rb create mode 100644 db/migrate/20240225100002_add_note_to_things.rb create mode 100644 db/migrate/20240226063134_add_uniqueness_index_search.rb create mode 100644 db/migrate/20240227085442_add_last_search_id_to_users.rb create mode 100644 db/migrate/20240227145317_add_authors_to_thing.rb create mode 100644 db/migrate/20240301154452_add_shared_to_searches.rb create mode 100644 db/migrate/20240307220309_add_where_is_it_to_things.rb create mode 100644 db/migrate/20240313133513_change_access_and_bought_at_to_date.rb create mode 100644 db/schema.rb create mode 100644 db/seeds.rb create mode 100644 lib/assets/.keep create mode 100644 lib/base_exporter.rb create mode 100644 lib/csv_exporter.rb create mode 100644 lib/tasks/.keep create mode 100644 lib/uoc_exporter.rb create mode 100644 log/.keep create mode 100644 public/404.html create mode 100644 public/422.html create mode 100644 public/500.html create mode 100644 public/apple-touch-icon-precomposed.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon.ico create mode 100644 public/robots.txt create mode 100644 storage/.keep create mode 100644 test/application_system_test_case.rb create mode 100644 test/controllers/exports_controller_test.rb create mode 100644 test/fixtures/action_text/rich_texts.yml create mode 100644 test/fixtures/comments.yml create mode 100644 test/fixtures/files/all.uoc.tex create mode 100644 test/fixtures/searches.yml create mode 100644 test/fixtures/tag_references.yml create mode 100644 test/fixtures/tags.yml create mode 100644 test/fixtures/things.yml create mode 100644 test/fixtures/users.yml create mode 100644 test/models/comment_test.rb create mode 100644 test/models/search_test.rb create mode 100644 test/models/tag_test.rb create mode 100644 test/models/thing_test.rb create mode 100644 test/models/user_test.rb create mode 100644 test/system/comments_test.rb create mode 100644 test/system/exports_test.rb create mode 100644 test/system/licenses_test.rb create mode 100644 test/system/searches_test.rb create mode 100644 test/system/sessions_test.rb create mode 100644 test/system/shared_searches_test.rb create mode 100644 test/system/tags_test.rb create mode 100644 test/system/things_test.rb create mode 100644 test/test_helper.rb create mode 100644 tmp/.keep create mode 100644 tmp/pids/.keep create mode 100644 tmp/storage/.keep create mode 100644 vendor/.keep create mode 100644 vendor/assets/stylesheets/simple.css create mode 100644 vendor/javascript/.keep 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"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..d125a8d --- /dev/null +++ b/Gemfile @@ -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 @@ +

+ Build Status for main branch +

+ +--- + +> mille dea est operum: certe dea carminis illa est;
+>   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à + +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 . +``` 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 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 + * 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 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 = '
Something went wrong!
' + } + } + + 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 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 @@ +
attachment--<%= blob.filename.extension %>"> + <% if blob.representable? %> + <%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> + <% end %> + +
+ <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <%= blob.filename %> + <%= number_to_human_size blob.byte_size %> + <% end %> +
+
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 @@ +
+
+
+ <% if defined?(idx) %> + <%= I18n.t('comments.title') %> #<%= idx + 1 %> + <% else %> + <%= I18n.t('comments.title') %> <%= I18n.t('general.in') %> <%= link_to comment.thing.title, thing_path(comment.thing) %> + <% end %> +
+ +
+ <% if defined?(from) %> + <%= I18n.t('general.edit').capitalize %> + <%= 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 %> +
+
+ +
+ <%= comment.content %> +
+ + <% if comment.tag_references.any? %> + + <% end %> + + <% if defined?(from) %> + + <% end %> +
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 @@ +
+
<%= I18n.t('comments.title-plural') %>
+ +
+ <% if thing.comments.any? %> + <% thing.comments.each_with_index do |comment, idx| %> + <%= render comment, from: from, idx: idx %> + <% end %> + <% else %> +

<%= I18n.t('comments.none') %>.

+ <% end %> +
+ +
+ <%= I18n.t('comments.new') %> + + +
+
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 @@ +
<%= I18n.t('searches.export.note') %>: <%= I18n.t('searches.export.note-msg') %>.
+ +
+
+ + + + + + Export +
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? %> +
+ <% if model.errors.size == 1 %> + <%= model.errors.first.full_message %>. + <% else %> +
    + <% model.errors.each do |error| %> +
  • <%= error.full_message %>.
  • + <% end %> +
+ <% end %> +
+<% 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 @@ +
+ <%= yield -%> +
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 @@ + + + + <%= ENV.fetch('OPERUM_BASE_TITLE', 'Operum') %> + + <%= csrf_meta_tags %> + <%= csp_meta_tag %> + + <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> + <%= javascript_importmap_tags %> + + + + <% if current_user && @searches %> +
+ + + +
+ <% end %> + +
+ <% if flash[:notice] %> +
+ <%= flash[:notice] %>. +
+ <% end %> + + <% if flash[:alert] %> +
+ <%= flash[:alert] %>. +
+ <% end %> + + <%= yield %> +
+ + + + 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 @@ +

<%= t('license.title') %> AGPLv3

+ +

<%= t('license.at') %> <%= t('license.here') %>.

+ +
+
+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/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 @@ +
+ <%= form_with(model: search) do |form| %> + <%= render "layouts/errors", model: search %> + +
+ + +
+ <%= form.text_field :body, "data-action": "keydown.enter->search#load", "data-search-target": "body", placeholder: 'Search body' %> + +
+ + + + +
+ <% end %> + +
+
+
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 @@ +
+ <% items.values.flatten.each do |item| %> + <%= render item %> + <% end %> +
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 @@ +
+
+
+

<%= I18n.t('sessions.title') %>

+ + <%= form_tag sessions_path do |form| %> +
+ <%= label_tag :username, I18n.t('activerecord.attributes.user.username') %> + <%= text_field_tag :username, '', autofocus: true, class: 'width-100' %> +
+
+ <%= label_tag :password, I18n.t('activerecord.attributes.user.password') %> + <%= password_field_tag :password, '', class: 'width-100' %> +
+
+ <%= submit_tag I18n.t('sessions.sign-in') %> +
+ <% end %> +
+
+
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 @@ +

<%= I18n.t('searches.shared.title') %>

+ +<% if @searches.empty? %> +
<%= I18n.t('searches.shared.none') %>.
+<% else %> +
+ <% @searches.each do |s| %> +
+ <%= link_to s.name, search_shared_path(s) %> +
+ <% end %> +
+<% 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 @@ + + <% @search.results.values.flatten.each do |res| %> + + + + <% end %> +
+
+ <% if res.is_a? Thing %> + <%= res.title %> +
+
Authors: <%= authors_or_editors(res) %>
+
Publisher: <%= string_maybe(res.publisher) %>
+
Year: <%= string_maybe(res.year) %>
+
Kind: <%= res.kind %>
+
Note: <%= string_maybe(res.note) %>
+ <% unless res.url.blank? %> + + <% end %> +
+ <% else %> + <%= I18n.t('comments.title') %> <%= I18n.t('general.in') %> «<%= res.thing.title %>» +
+ <%= res.content %> +
+ <% end %> +
+
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 %> + +
+ <%= form.label :name, style: "display: block" %> + <%= form.text_field :name, autofocus: true, autocomplete: 'off', class: 'width-100' %> +
+ +
+ <%= form.submit %> +
+<% 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 @@ +
+
+ <%= tag.name %> +
+
+ <%= link_to I18n.t('general.delete'), tag_path(tag), data: { "turbo-method": :delete } %> +
+
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? %> +
+
<%= I18n.t('tags.title') %> — <%= link_to I18n.t('tags.new-action'), new_tag_path %>
+ +
+ <% Tag.find_each do |tag| %> +
+ +
+ <% end %> +
+
+

+<% 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 @@ +
+
+
+

<%= I18n.t('tags.title') %> — <%= link_to I18n.t('tags.new-action'), new_tag_path %>

+ +
+ <% @tags.each do |tag| %> + <%= render tag %> + <% end %> +
+
+
+
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 @@ +
+
+
+

<%= I18n.t('tags.new') %> — <%= link_to I18n.t('general.back-to-list'), tags_path %>

+ + <%= render "form", tag: @tag %> +
+
+
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 %> + +
+
+ <%= form.label :title %> + <%= form.text_field :title, autofocus: true, required: true %> +
+ +
+ <%= form.label :authors %> + <%= form.text_field :authors, required: true %> + <%= form.check_box :editors %> + <%= form.label :editors %> +
+ +
+ <%= form.label :note %> + <%= form.text_field :note %> +
+ +
+ <%= form.label :target %> + <%= form.text_field :target, required: true %> +
+ +
+ <%= form.label :publisher %> + <%= form.text_field :publisher %> +
+ +
+ <%= form.label :year %> + <%= form.number_field :year %> +
+ +
+ <%= form.label :address %> + <%= form.text_field :address %> +
+ +
+ <%= form.label :url %> + <%= form.text_field :url %> +
+ +
+ <%= form.label :access %> + <%= form.date_field :access %> +
+ +
+ <%= form.label :location %> + <%= form.text_field :location %> +
+ +
+ <%= form.label :insideof %> + <%= form.text_field :insideof %> +
+ +
+ <%= form.label :pages %> + <%= form.text_field :pages %> +
+ +
+ <%= form.label :rate %> + <%= form.number_field :rate, min: 0, max: 10, required: true %> +
+ +
+ <%= form.label :status %> + <%= form.select :status, options_for_select(Thing.statuses.map { |k, _| [I18n.t("things.status.#{k}"), k] }.sort, thing.status) %> +
+ +
+ <%= form.label :kind %> + <%= form.select :kind, options_for_select(Thing.kinds.map { |k, _| [I18n.t("things.kind.#{k}"), k] }.sort, thing.kind) %> +
+ +
+ <%= form.label :bought_at %> + <%= form.date_field :bought_at %> +
+ +
+ <%= form.label :where_is_it %> + <%= form.text_field :where_is_it %> +
+
+ + <%= render "tags/taggable", tag_references: thing.tag_references.pluck(:tag_id), form: form %> + +
+ <%= form.submit %> +
+<% 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 @@ +
+
+
">
+
+
<%= link_to thing.title, edit_thing_path(thing) %>
+
<%= thing.rate %>
+
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 %> + +

+ <%= link_to I18n.t('things.create-another'), new_thing_path %> +

+ +<%= 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 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 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 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 @@ + + + + The page you were looking for doesn't exist (404) + + + + + + +
+
+

The page you were looking for doesn't exist.

+

You may have mistyped the address or the page may have moved.

+
+

If you are the application owner check the logs for more information.

+
+ + 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 @@ + + + + The change you wanted was rejected (422) + + + + + + +
+
+

The change you wanted was rejected.

+

Maybe you tried to change something you didn't have access to.

+
+

If you are the application owner check the logs for more information.

+
+ + 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 @@ + + + + We're sorry, but something went wrong (500) + + + + + + +
+
+

We're sorry, but something went wrong.

+
+

If you are the application owner check the logs for more information.

+
+ + diff --git a/public/apple-touch-icon-precomposed.png b/public/apple-touch-icon-precomposed.png new file mode 100644 index 0000000..e69de29 diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..e69de29 diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 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 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:

Such awesome content from Miquel

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 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29 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 -- cgit v1.2.3